From c07575f573dd3a1af4f734297d17f7c951c95f10 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 19:02:01 -0700 Subject: [PATCH 01/27] feat(server): show finished paragraphs and code blocks while the response streams (#11062) Co-authored-by: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 231 +++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 102 +++++++- apps/web/src/components/ChatMarkdown.tsx | 12 +- .../components/chat/MessagesTimeline.test.tsx | 87 +++++++ .../src/components/chat/MessagesTimeline.tsx | 32 ++- apps/web/src/index.css | 16 ++ patches/@legendapp__list@3.3.5.patch | 162 ++++++++++-- pnpm-lock.yaml | 10 +- 8 files changed, 626 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..c22cf3e53bea 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -55,7 +55,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; -import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { + ProviderRuntimeIngestionLive, + splitBufferedAssistantText, +} from "./ProviderRuntimeIngestion.ts"; import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -297,7 +300,24 @@ describe("ProviderRuntimeIngestion", () => { }); }), ).pipe(Layer.provide(projectionSnapshotLayer)); + // Real clock plus an offset the test can advance, so delivery pacing in + // ingestion can be driven without sleeping. Sleeps stay real. + let clockOffsetMs = 0; + const realClock = Effect.runSync(Effect.service(Clock.Clock)); + const shiftedClock: Clock.Clock = { + currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs, + currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs), + currentTimeNanosUnsafe: () => + realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + currentTimeNanos: Effect.sync( + () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + ), + monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: realClock.monotonicTimeNanos, + sleep: (duration) => realClock.sleep(duration), + }; const layer = ProviderRuntimeIngestionLive.pipe( + Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the @@ -392,6 +412,9 @@ describe("ProviderRuntimeIngestion", () => { .pipe(Effect.map(Option.getOrThrow)), ), emit: provider.emit, + advanceClock: (ms: number) => { + clockOffsetMs += ms; + }, emitAndDrain, sqlCount: sqlCounter.count, setProviderSession: provider.setSession, @@ -3129,6 +3152,145 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("delivers finished paragraphs while the rest of the message stays buffered", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paragraph-flush"); + const itemId = asItemId("item-paragraph-flush"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paragraph-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + + // Each delta lands well outside the pacing window of the one before. + const emitDelta = (eventId: string, delta: string) => { + harness.advanceClock(1_000); + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + }; + + emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); + const afterFirst = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`, + ), + ); + expect( + afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`), + ).toMatchObject({ + text: "First paragraph.\n\n", + streaming: true, + }); + + // An open code block holds the whole block until its closing fence lands. + emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); + await harness.drain(); + expect( + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text, + ).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + + emitDelta("evt-paragraph-3", "```\n\nTail without newline"); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-paragraph-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + const finalThread = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && !message.streaming, + ), + ); + expect( + finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`) + ?.text, + ).toBe( + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline", + ); + }); + + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { + const harness = await createHarness(); + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paced"); + const itemId = asItemId("item-paced"); + // Every delta carries the same event time, like OpenCode does for one + // part. Pacing must follow the server clock, not the event stamp. + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paced-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + // Emit is fire-and-forget, so drain after each delta before moving the + // clock. Otherwise the worker reads a clock that has already advanced. + let clockMs = 0; + const emitDelta = async (eventId: string, delta: string, offsetMs: number) => { + harness.advanceClock(offsetMs - clockMs); + clockMs = offsetMs; + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }, + ]); + }; + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + + await emitDelta("evt-paced-1", "One.\n\n", 0); + await emitDelta("evt-paced-2", "Two.\n\n", 100); + await emitDelta("evt-paced-3", "Three.\n\n", 200); + // The first paragraph lands right away. The next two are inside the window. + expect(await messageText()).toBe("One.\n\n"); + + await emitDelta("evt-paced-4", "Four.\n\n", 500); + expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -4367,3 +4529,70 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + +describe("splitBufferedAssistantText", () => { + it("keeps a partial trailing line buffered", () => { + expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" }); + expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); + }); + + it("does not split inside an open fence and delivers the block at its closing fence", () => { + const open = "intro\n\n```\ncode\n\nmore\n"; + expect(splitBufferedAssistantText(open)).toEqual({ + ready: "intro\n\n", + rest: "```\ncode\n\nmore\n", + }); + expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({ + ready: `${open}\`\`\`\n`, + rest: "after", + }); + }); + + it("does not treat a fence with an info string as a closing fence", () => { + const text = "```\n```javascript\nstill code\n\nmore\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + }); + + it("treats a fence indented four or more spaces as code, not a closing fence", () => { + const text = "```\n ```\n\nstill code\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({ + ready: "```\n ```\n", + rest: "after", + }); + }); + + it("keeps a fence nested under a list item open across its blank lines", () => { + const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n", + rest: "after\n", + }); + }); + + it("does not treat a no-break-space line as blank", () => { + expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({ + ready: "para\n\u00a0\ncont\n\n", + rest: "next", + }); + }); + + it("treats CRLF blank lines as boundaries", () => { + expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ + ready: "one\r\n\r\n", + rest: "two", + }); + }); + + it("only closes a fence with the same marker of equal or greater length", () => { + const text = "````\n```\nstill code\n\n````\n\nout\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "````\n```\nstill code\n\n````\n\n", + rest: "out\n", + }); + expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({ + ready: "", + rest: "~~~\n```\n\nx\n", + }); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 964f60d3a306..8126537bd9f7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -107,6 +108,11 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; +// Paragraphs that finish within this window after a delivery stay buffered +// and land together on the next one. Keeps fast models from repainting the +// message several times a second while still showing the first paragraph +// as soon as it is done. +const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< @@ -180,6 +186,58 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } +// An opening fence may sit at any indentation, since fences inside list +// items are indented past the marker. A closing fence may be indented at most +// three spaces more than its opener. Deeper lines are content in the block. +const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; +// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as +// a no-break space, is paragraph content. +const BLANK_LINE_PATTERN = /^[ \t]*$/; + +/** + * Splits buffered assistant text at the last blank line or closing code fence + * that is not inside an open fenced code block. `ready` is safe to deliver now + * because the markdown before it will not change shape as more text arrives. + * `rest` stays buffered until the next boundary or completion. Only fully + * terminated lines count, so a trailing partial line never leaks. + */ +export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { + let openFence: { marker: string; indent: number } | null = null; + let boundary = -1; + let lineStart = 0; + for (;;) { + const newline = text.indexOf("\n", lineStart); + if (newline === -1) { + break; + } + const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, ""); + const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (fenceMatch) { + const indent = fenceMatch[1]!.length; + const marker = fenceMatch[2]!; + if (openFence === null) { + openFence = { marker, indent }; + } else if ( + marker[0] === openFence.marker[0] && + marker.length >= openFence.marker.length && + indent <= openFence.indent + 3 && + line.length === indent + marker.length + ) { + // CommonMark: a closing fence carries no info string. + openFence = null; + boundary = newline + 1; + } + } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { + boundary = newline + 1; + } + lineStart = newline + 1; + } + if (boundary === -1) { + return { ready: "", rest: text }; + } + return { ready: text.slice(0, boundary), rest: text.slice(boundary) }; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -927,6 +985,12 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(""), }); + // Epoch millis of the last early delivery per message, for pacing. + const lastAssistantDeliveryAtByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(0), + }); const assistantSegmentStateByTurnKey = yield* Cache.make({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1103,7 +1167,7 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string) => + const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1111,6 +1175,30 @@ const make = Effect.gen(function* () { onNone: () => delta, onSome: (text) => `${text}${delta}`, }); + + // Deliver finished paragraphs and closed code blocks early so the + // user sees progress without token-by-token repaints. + const { ready, rest } = splitBufferedAssistantText(nextText); + const lastDeliveredAt = Option.getOrUndefined( + yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), + ); + const paced = + lastDeliveredAt === undefined || + atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS; + if ( + paced && + hasRenderableAssistantText(ready) && + rest.length <= MAX_BUFFERED_ASSISTANT_CHARS + ) { + if (rest.length > 0) { + yield* Cache.set(bufferedAssistantTextByMessageId, messageId, rest); + } else { + yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + } + yield* Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis); + return ready; + } + if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); return ""; @@ -1133,7 +1221,9 @@ const make = Effect.gen(function* () { ); const clearBufferedAssistantText = (messageId: MessageId) => - Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe( + Effect.andThen(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)), + ); const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( @@ -1675,7 +1765,13 @@ const make = Effect.gen(function* () { : "buffered", ); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + // Pace on the server clock. OpenCode stamps every delta of a part + // with the part's start time, so the event time cannot measure gaps. + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + yield* Clock.currentTimeMillis, + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3aeaaa5b8443..b0209754b0d0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3196,7 +3196,15 @@ const CHAT_MARKDOWN_COMPONENTS = { resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} fallback={
{children}
} > - {children}}> + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6991f4432594..8f982064869b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -901,6 +901,93 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain(" { + const entries = [buildUserTimelineEntry("Hello")]; + const working = renderToStaticMarkup( + , + ); + expect(working).toContain('data-maintain-scroll-at-end-animated="true"'); + + const idle = renderToStaticMarkup( + , + ); + expect(idle).toContain('data-maintain-scroll-at-end-animated="false"'); + }); + + it("snaps to the end while a thread switch settles, even mid-turn", async () => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + // A work entry renders without the DOM globals that message rows need + // under react-test-renderer. + const entries = [ + { + id: "entry-settle-work", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "work-settle", + createdAt: MESSAGE_CREATED_AT, + toolCallId: "call-settle", + label: "Run lint", + tone: "tool" as const, + itemType: "command_execution" as const, + command: "pnpm lint", + toolLifecycleStatus: "completed" as const, + }, + }, + ]; + const animatedAttr = (renderer: ReactTestRenderer) => + renderer.root.findByProps({ "data-testid": "legend-list" }).props[ + "data-maintain-scroll-at-end-animated" + ]; + let renderer!: ReactTestRenderer; + try { + act(() => { + renderer = create( + , + ); + }); + expect(animatedAttr(renderer)).toBe(true); + + act(() => { + renderer.update( + , + ); + }); + expect(animatedAttr(renderer)).toBe(false); + + // Two frames later the switch has settled and gliding resumes. + flushFrame(); + flushFrame(); + expect(animatedAttr(renderer)).toBe(true); + } finally { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f388db604450..084287080bd9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -227,6 +227,7 @@ import { import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -348,6 +349,13 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const satisfies MaintainScrollAtEndOptions; +// Streamed text lands a paragraph at a time. A smooth scroll to the end +// turns each landing into a short glide instead of a jump. Thread switches +// and layout settles keep the instant variant so nothing visibly travels. +const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { + ...TIMELINE_MAINTAIN_SCROLL_AT_END, + animated: true, +} as const satisfies MaintainScrollAtEndOptions; // --------------------------------------------------------------------------- // Props (public API) @@ -470,14 +478,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ new Set(), ); const listIdentityKey = displayThreadKey ?? routeThreadKey; + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); + // The list stays mounted across thread switches. Its first end pins on the + // new thread must snap, not glide, even if that thread is mid-turn. + const [settlingListIdentity, setSettlingListIdentity] = useState(null); let paintedExpandedTurnIds = expandedTurnIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; previousLatestTurnRef.current = latestTurn; + setSettlingListIdentity(listIdentityKey); paintedExpandedTurnIds = new Set(); paintedExpandedWorkGroupIds = new Set(); paintedExpandedSpawnEntryIds = new Set(); @@ -522,6 +535,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); + useEffect(() => { + if (settlingListIdentity === null) return; + // Two frames covers the fresh-data layout pass and the initial end pin. + let second: number | null = null; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + setSettlingListIdentity((current) => (current === settlingListIdentity ? null : current)); + }); + }); + return () => { + cancelAnimationFrame(first); + if (second !== null) cancelAnimationFrame(second); + }; + }, [settlingListIdentity]); + const suspendEndScrollMaintenanceForDisclosure = useCallback( (anchorKey: string, collapsed = false) => { disclosureAnchorKeyRef.current = anchorKey; @@ -977,7 +1005,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ !liveFollowEnabled || disclosureToggleSettling ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ citationPositioning ? false : maintainVisibleContentPosition diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a7f6d91f2e40..e7c5d919a211 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1891,6 +1891,22 @@ code { background: transparent !important; } +/* Paragraphs and code blocks arrive in chunks while a response streams. Fade + each new block in so the chunk does not pop. @starting-style only applies + when an element is first inserted, and the rule is gated on data-streaming, + so opening a finished thread never replays the fade. Opacity only, one + shot, no layout change. */ +@media (prefers-reduced-motion: no-preference) { + .chat-markdown[data-streaming] > *, + .chat-markdown[data-streaming] .chat-markdown-shiki { + transition: opacity 600ms ease-out; + + @starting-style { + opacity: 0; + } + } +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 98784b999549..88b739824b01 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644 +index 367945c..0ac7bf8 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { @@ -23,7 +23,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644 +index 6645bcb..321c4c3 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -119,7 +119,7 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d5172 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644 +index 87b38b9..111eb24 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -1,7 +1,7 @@ @@ -224,7 +224,7 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85c renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 +index ce1fe00..3ccf6f1 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -293,6 +293,12 @@ interface LegendListSpecificProps { @@ -241,13 +241,13 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d0677d75f 100644 +index b3c5a30..5ac6bbe 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; } - + +// Size-only changes may not emit a scroll event to refresh the edge signal. +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; @@ -809,13 +809,13 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644 +index 40e87cd..93aac74 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; } - + +// Size-only changes may not emit a scroll event to refresh the edge signal. +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; @@ -1376,8 +1376,78 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce3599636243910817 onScroll: onScrollHandler, recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { +diff --git a/react-native.web.js b/react-native.web.js +index 914d2da..8b88b09 100644 +--- a/react-native.web.js ++++ b/react-native.web.js +@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +diff --git a/react-native.web.mjs b/react-native.web.mjs +index 95465f2..d7cbe1c 100644 +--- a/react-native.web.mjs ++++ b/react-native.web.mjs +@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } diff --git a/react.js b/react.js -index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644 +index 914d2da..0c2917d 100644 --- a/react.js +++ b/react.js @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -1403,7 +1473,38 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658 } return nextSize; } -@@ -6446,8 +6453,8 @@ function ScrollAdjust() { +@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +@@ -6446,8 +6465,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1414,7 +1515,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/react.mjs b/react.mjs -index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644 +index 95465f2..b73bfdf 100644 --- a/react.mjs +++ b/react.mjs @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -1440,7 +1541,38 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8 } return nextSize; } -@@ -6425,8 +6432,8 @@ function ScrollAdjust() { +@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +@@ -6425,8 +6444,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1451,7 +1583,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/reanimated.d.ts b/reanimated.d.ts -index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 +index e504332..2ce6383 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -294,6 +294,12 @@ interface LegendListSpecificProps { @@ -1468,7 +1600,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 +index f1265fa..fc03be2 100644 --- a/reanimated.js +++ b/reanimated.js @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -1631,7 +1763,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7 renderScrollComponent: renderReanimatedScrollComponent, ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 +index 29a00d5..6ab9292 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -1,7 +1,7 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06b0578ae057..26f13005de4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b + '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -248,7 +248,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 @@ -607,7 +607,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -13997,7 +13997,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -14005,7 +14005,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From 2d737465027b18cafd0c4704e9025b124bc94b2c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 19:38:35 -0700 Subject: [PATCH 02/27] fix(web): disconnect offline servers from threads (#11671) --- apps/web/src/components/ChatView.tsx | 117 ++++++++++++------ .../useEnvironmentDisconnectDelay.test.tsx | 65 ++++++++++ .../hooks/useEnvironmentDisconnectDelay.ts | 25 ++++ 3 files changed, 172 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx create mode 100644 apps/web/src/hooks/useEnvironmentDisconnectDelay.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7994b3196f58..9e11132269e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -308,6 +308,8 @@ import { } from "../lib/composerContextRecords"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -1497,6 +1499,9 @@ export default function ChatView(props: ChatViewProps) { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { + reportFailure: false, + }); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], @@ -2208,6 +2213,37 @@ export default function ChatView(props: ChatViewProps) { }, [retryEnvironment], ); + const disconnectDelayElapsed = useEnvironmentDisconnectDelay( + activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, + ); + const canDisconnectActiveEnvironment = + disconnectDelayElapsed && + activeEnvironment !== null && + activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && + !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); + const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); + const handleDisconnectActiveEnvironment = useCallback( + async (environmentId: EnvironmentId) => { + setDisconnectingEnvironment(true); + const result = await setEnvironmentEnabled({ environmentId, enabled: false }); + setDisconnectingEnvironment(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); + } + return; + } + void navigate({ to: "/", replace: true }); + }, + [navigate, setEnvironmentEnabled], + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2448,6 +2484,20 @@ export default function ChatView(props: ChatViewProps) { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const disconnectAction = + canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( + + ) : undefined; const environmentReconnecting = unavailableConnection !== null && (unavailableConnection.phase === "connecting" || @@ -2481,6 +2531,7 @@ export default function ChatView(props: ChatViewProps) { ), title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`, description: "Finishing an update", + actions: disconnectAction, }); } else { items.push({ @@ -2488,28 +2539,22 @@ export default function ChatView(props: ChatViewProps) { variant: unavailableConnection.phase === "error" ? "error" : "warning", icon: , title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: environmentReconnecting ? "Trying again" : "Reconnect to continue", actions: ( <> - - + {!environmentReconnecting ? ( + + ) : null} + {disconnectAction} ), }); @@ -2564,22 +2609,22 @@ export default function ChatView(props: ChatViewProps) { (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) ? serverUpdateGuidance(versionMismatchSelfUpdate) : undefined, - actions: - updateInProgress || - !versionMismatch || + actions: updateInProgress ? ( + disconnectAction + ) : !versionMismatch || (versionMismatchSelfUpdate === "desktop-managed" && !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), + + ), ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) ? {} : { @@ -2603,7 +2648,9 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, - navigate, + canDisconnectActiveEnvironment, + disconnectingEnvironment, + handleDisconnectActiveEnvironment, setDismissedVersionMismatchKey, showVersionMismatchBanner, serverUpdateFailureDismissed, diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx new file mode 100644 index 000000000000..f584112df4db --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx @@ -0,0 +1,65 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useEnvironmentDisconnectDelay } from "./useEnvironmentDisconnectDelay"; + +const environmentId = EnvironmentId.make("remote"); +let renderer: ReactTestRenderer; +let elapsed = false; + +function Probe({ unavailableId }: { unavailableId: EnvironmentId | null }) { + const value = useEnvironmentDisconnectDelay(unavailableId); + useLayoutEffect(() => { + elapsed = value; + }); + return null; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + act(() => { + renderer = create(); + }); +}); + +afterEach(() => { + act(() => renderer.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("waits 20 seconds without restarting on renders for the same environment", () => { + act(() => vi.advanceTimersByTime(10_000)); + expect(elapsed).toBe(false); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(9_999)); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(elapsed).toBe(true); +}); + +it("cancels a brief outage and starts a fresh delay on the next outage", () => { + act(() => vi.advanceTimersByTime(10_000)); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(false); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(19_999)); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(elapsed).toBe(true); + act(() => renderer.update()); + expect(elapsed).toBe(false); +}); + +it("does not carry elapsed time to another environment", () => { + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(true); + act(() => renderer.update()); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(true); +}); diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts b/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts new file mode 100644 index 000000000000..17c6f8f87be8 --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts @@ -0,0 +1,25 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; + +/** Wait through brief outages before offering to switch off the active environment. */ +export function useEnvironmentDisconnectDelay(unavailableEnvironmentId: EnvironmentId | null) { + const [delay, setDelay] = useState({ environmentId: unavailableEnvironmentId, elapsed: false }); + if (delay.environmentId !== unavailableEnvironmentId) { + setDelay({ environmentId: unavailableEnvironmentId, elapsed: false }); + } + + useEffect(() => { + if (unavailableEnvironmentId === null) return; + const timeout = setTimeout( + () => setDelay({ environmentId: unavailableEnvironmentId, elapsed: true }), + 20_000, + ); + return () => clearTimeout(timeout); + }, [unavailableEnvironmentId]); + + return ( + unavailableEnvironmentId !== null && + delay.environmentId === unavailableEnvironmentId && + delay.elapsed + ); +} From 5e961d3d7fc07ff1ba998bfd20bb366c2ab8c9a9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 19:40:19 -0700 Subject: [PATCH 03/27] feat(web): flatten the connections page into one environments list (#11672) Co-authored-by: Claude Fable 5.1 --- .../CloudEnvironmentConnectList.test.tsx | 6 +- .../cloud/CloudEnvironmentConnectList.tsx | 11 +- .../settings/ConnectionsSettings.tsx | 639 ++++++++---------- .../settings/EnvironmentIconPicker.tsx | 138 ++-- .../components/settings/EnvironmentRow.tsx | 74 ++ .../settings/FoldedSettingsSection.tsx | 67 ++ .../settings/GitHubRoutingSettings.test.ts | 20 + .../settings/GitHubRoutingSettings.tsx | 146 ++-- .../settings/LoadBalancingSettings.test.ts | 32 + .../settings/LoadBalancingSettings.tsx | 162 +++-- .../src/components/settings/settingsSearch.ts | 6 +- docs/user/remote-access.md | 3 +- docs/user/source-control.md | 2 +- 13 files changed, 720 insertions(+), 586 deletions(-) create mode 100644 apps/web/src/components/settings/EnvironmentRow.tsx create mode 100644 apps/web/src/components/settings/FoldedSettingsSection.tsx create mode 100644 apps/web/src/components/settings/GitHubRoutingSettings.test.ts create mode 100644 apps/web/src/components/settings/LoadBalancingSettings.test.ts diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx index 6474c2208c78..edefc54914d8 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -175,7 +175,7 @@ describe("cloud onboarding discovery", () => { finishDiscovery(linkedMachines); }); expect(onDiscoveryReady).toHaveBeenCalledTimes(1); - expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + expect(renderer!.root.findByType("button").children).toEqual(["Add"]); }); it("connects and selects discovered computers by default without overwriting deselection", async () => { @@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => { expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ "Work laptop", ]); - expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + expect(renderer!.root.findByType("button").children).toEqual(["Add"]); await advance(30_000); expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); }); @@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => { it("keeps a discovered computer visible when it is added to the browser", async () => { discovery.listEnvironments.mockResolvedValue(linkedMachines); await mount(); - expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + expect(renderer!.root.findByType("button").children).toEqual(["Add"]); await act(async () => { renderer!.update( 0} onClick={() => void connectEnvironment(environment)} > - {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"} + {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"} )} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index a6e29cf72312..c450e849307b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,7 +6,6 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { useLocation } from "@tanstack/react-router"; import { Atom } from "effect/unstable/reactivity"; import { type KeyboardEvent, @@ -42,7 +41,7 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -67,8 +66,14 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; -import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings"; +import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; +import { + EnvironmentRow, + environmentTransportLabel, + formatDesktopSshTarget, +} from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; @@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; import { Spinner } from "../ui/spinner"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -158,7 +163,7 @@ import { import { requestConfirmDialog } from "~/confirmDialog"; import { useAtomCommand } from "../../state/use-atom-command"; import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; -import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress, @@ -288,11 +293,6 @@ function AccessScopeSummary({ ); } -function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { - const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; - return target.port ? `${authority}:${target.port}` : authority; -} - function parseManualDesktopSshTarget(input: { readonly host: string; readonly username: string; @@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray) ); } +/** Closed-header summary for the Authorized clients fold. */ +function summarizeAuthorizedClients( + sessions: ReadonlyArray, + links: ReadonlyArray, +): string { + const parts = [ + `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`, + links.length > 0 + ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}` + : null, + ]; + return parts.filter((part): part is string => part !== null).join(" · "); +} + function sortDesktopClientSessions(sessions: ReadonlyArray) { return [...sessions].toSorted((left, right) => { if (left.current !== right.current) { @@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = { onRemove: (environment: EnvironmentPresentation) => void; }; +/** + * Status word for a row subtitle: "Reconnecting: " instead of the + * long-form sentence, since the row has one line and the full text is one + * hover away. + */ +function savedBackendStatus(environment: EnvironmentPresentation): { + readonly text: string; + readonly tone: "muted" | "error"; +} { + if (!environment.entry.enabled) return { text: "Off", tone: "muted" }; + const { connection } = environment; + switch (connection.phase) { + case "connected": + return { text: "Connected", tone: "muted" }; + case "connecting": + return { text: "Connecting", tone: "muted" }; + case "reconnecting": + return { + text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", + tone: "error", + }; + case "error": + return { + text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", + tone: "error", + }; + case "offline": + return { text: "Offline", tone: "muted" }; + case "available": + return { text: "Not connected", tone: "muted" }; + } +} + +/** + * One added machine in the Environments list. The switch is the main action; + * the update icon appears only when that machine can take an update; the + * row menu holds the icon override, trace ID, and removal. + */ function SavedBackendListRow({ environment, removingEnvironmentId, @@ -1427,19 +1479,8 @@ function SavedBackendListRow({ }: SavedBackendListRowProps) { const environmentId = environment.environmentId; const enabled = environment.entry.enabled; - const connectionState = environment.connection.phase; - const isConnected = connectionState === "connected"; + const isConnected = environment.connection.phase === "connected"; const isRemoving = removingEnvironmentId === environmentId; - const stateDotClassName = !enabled - ? "bg-muted-foreground/40" - : connectionState === "connected" - ? "bg-success" - : connectionState === "connecting" || connectionState === "reconnecting" - ? "bg-warning" - : connectionState === "error" - ? "bg-destructive" - : "bg-muted-foreground/40"; - const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off"; const errorTraceId = environment.connection.traceId; const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ target: "trace ID", @@ -1470,25 +1511,18 @@ function SavedBackendListRow({ const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; - const sshTarget = - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(environment.entry.profile) && - environment.entry.profile.value._tag === "SshConnectionProfile" - ? environment.entry.profile.value.target - : null; - const metadataBits = [ - sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, - environment.relayManaged ? "T3 Connect" : null, - enabled ? null : "Off", - ].filter((value): value is string => value !== null); + const status = savedBackendStatus(environment); + const serverVersion = environment.serverConfig?.environment.serverVersion ?? null; + const subtitleText = [ + environmentTransportLabel(environment), + resumingServerUpdate ? "Restarting" : status.text, + enabled && versionMismatch ? serverVersion : null, + ] + .filter((value): value is string => value !== null) + .join(" · "); - // The WSL backend is a desktop-managed local backend (it surfaces as a bearer - // environment whose connection id is prefixed "local:"), not a remote - // environment you connect to or remove here — its lifecycle is driven by the - // WSL on/off + distro picker on this page. - const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target); // Only a connected, enabled machine can take a remote update; a switched-off - // one keeps the "update available" note so the icon is not a surprise later. + // one keeps the version note so the icon is not a surprise later. const showUpdateAction = enabled && isConnected && @@ -1496,153 +1530,96 @@ function SavedBackendListRow({ (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); return ( -
-
-
-
-
- - + -

- {environment.label} -

-
- {isConnected ? ( -
- -
- ) : null} + } + > + {subtitleText} + + + {enabled ? connectionStatusText(environment.connection) : "Switched off"} + {versionMismatch + ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` + : ""} + + + } + below={ + serverUpdateState.status !== "idle" ? ( +
+
- {metadataBits.length > 0 ? ( -

{metadataBits.join(" · ")}

- ) : null} - {serverUpdateState.status !== "idle" ? ( -
- -
- ) : versionMismatch ? ( - - - Server update available - - } - /> - - {versionMismatch.serverVersion} {" "} - {versionMismatch.clientVersion} - - - ) : null} - {enabled && environment.connection.error && !resumingServerUpdate ? ( -

- - {connectionStatusText(environment.connection)} - - {errorTraceId ? ( - - ) : null} -

- ) : null} -
-
- {showUpdateAction ? ( - + {showUpdateAction ? ( + + ) : null} + + onSetEnabled(environmentId, checked)} + /> + } + /> + {enabled ? "Switch off" : "Switch on"} + + + + } + > + + + + + {errorTraceId ? ( + copyTraceId(errorTraceId)}>Copy trace ID ) : null} - {isWslEnvironment ? ( - - - Managed locally - - } - /> - - Select the primary environment to turn the WSL backend on or off. - - - ) : ( - <> - - onSetEnabled(environmentId, checked)} - /> - } - /> - {enabled ? "Switch off" : "Switch on"} - - - - } - > - - - - {errorTraceId ? ( - copyTraceId(errorTraceId)}>Copy trace ID - ) : null} - onRemove(environment)}> - {isRemoving ? "Removing…" : "Remove from this device…"} - - - - - )} -
-
-
+ + onRemove(environment)}> + {isRemoving ? "Removing…" : "Remove from this device…"} + + + + ); } @@ -1817,19 +1794,6 @@ export function ConnectionsSettings() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); - const [handledSearchTargetId, setHandledSearchTargetId] = useState(null); - if (primaryEnvironment && handledSearchTargetId !== searchTargetId) { - setHandledSearchTargetId(searchTargetId); - if (["connections-environment", "wsl-backend"].includes(searchTargetId)) { - setSelectedEnvironmentId(primaryEnvironment.environmentId); - } - } - const selectedEnvironment = - environments.find((environment) => environment.environmentId === selectedEnvironmentId) ?? - primaryEnvironment ?? - environments[0]; const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, @@ -1846,13 +1810,24 @@ export function ConnectionsSettings() { ? (primarySessionState.data.scopes ?? null) : null; const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); + // Catalog order is the order the machines were added; rows never jump when + // one is switched off. const savedEnvironments = useMemo( () => - environments - .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget") - .toSorted((left, right) => left.label.localeCompare(right.label)), + environments.filter( + (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", + ), [environments], ); + // The WSL backend is managed from the WSL row under this machine, so it has + // no row of its own in the list. + const listedEnvironments = useMemo( + () => + savedEnvironments.filter( + (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), + ), + [savedEnvironments], + ); // Machines "Update all" can reach: switched on, connected, behind the client // version, remotely updatable, and not already mid-update. The button only // renders when this list is non-empty. @@ -1901,10 +1876,15 @@ export function ConnectionsSettings() { [savedServerUpdateStates], ); // Switched-off machines never receive threads, so they stay out of the - // load balancing list. + // load balancing and GitHub sharing lists. The WSL backend has no row in + // the Environments list but does take threads, so it stays in here. This + // machine leads the list. const loadBalancingEnvironments = useMemo( - () => environments.filter((environment) => environment.entry.enabled), - [environments], + () => [ + ...(primaryEnvironment ? [primaryEnvironment] : []), + ...savedEnvironments.filter((environment) => environment.entry.enabled), + ], + [primaryEnvironment, savedEnvironments], ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); @@ -3259,69 +3239,78 @@ export function ConnectionsSettings() { - {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( - - ) : primaryVersionMismatch ? ( - - - Update to match this client. - - } + icon={ + + } + headerAction={ + primaryEnvironmentId !== null ? ( + + - - {primaryVersionMismatch.serverVersion} {" "} - {primaryVersionMismatch.clientVersion} - - - ) : null - } - control={ - primaryVersionMismatch && - primaryEnvironmentId !== null && - primaryServerUpdateState.status !== "running" ? ( - + + + + - ) : undefined - } - /> - ) : null} - {primaryEnvironmentId !== null ? ( - + + ) : null + } + > + + ) : ( + [ + primaryServerConfig?.environment.serverVersion ?? null, + primaryEnvironment?.displayUrl ?? null, + ] + .filter((value): value is string => value !== null) + .join(" · ") || "Loading…" + ) + } + control={ + primaryVersionMismatch && + primaryEnvironmentId !== null && + primaryServerUpdateState.status !== "running" ? ( + - } - /> - ) : null} + ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( + Up to date + ) : undefined + } + /> {desktopBridge ? ( <> {renderNetworkAccessRow()} @@ -3339,10 +3328,14 @@ export function ConnectionsSettings() { {isLocalBackendRemotelyReachable ? ( - {renderAuthorizedClients("current")} - + ) : null} + {primarySettings} {savedServerUpdateTargets.length > 0 ? ( @@ -3725,118 +3718,22 @@ export function ConnectionsSettings() {
} > -
-
- -
- {(primaryEnvironment - ? [primaryEnvironment, ...savedEnvironments] - : savedEnvironments - ).map((environment) => { - const selected = environment.environmentId === selectedEnvironment?.environmentId; - const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - return ( - - ); - })} -
-
-
- - {selectedEnvironment ? ( -
- {selectedEnvironment.entry.target._tag === "PrimaryConnectionTarget" ? ( - primarySettings - ) : ( - - - - )} - {selectedEnvironment.entry.enabled && loadBalancingEnvironments.length > 1 ? ( - - - - ) : null} - -
- ) : null} -
-
+ {listedEnvironments.map((environment) => ( + + ))} + ); } diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx index f990aa5a2441..b3b33fab0252 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.tsx +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -5,7 +5,6 @@ import { type EnvironmentId, type ServerConfig, } from "@t3tools/contracts"; -import { useCallback } from "react"; import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; @@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useEnvironmentSessionState } from "../../state/session"; import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + MenuItem, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, +} from "../ui/menu"; import { resolvePrimaryOperateAccess, resolveRemoteOperateAccess, } from "./ProviderSettingsPanel.logic"; -const AUTOMATIC_VALUE = "automatic"; - /** * Why the picker is inert, in the order the user can do something about it. * Null means it can be changed. @@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) { } /** - * Picks the machine glyph an environment wears everywhere it is listed. - * "Automatic" clears the override so the server's own detection shows - * through; the label says what that currently resolves to so the user can - * tell whether detection got it right before overriding. The control stays - * visible while locked so the current icon still reads, the same way - * server-scoped rows go inert instead of disappearing. + * "Icon" submenu for an environment's row menu. Lists the machine kinds with + * the server's own detection marked, so the user can tell whether detection + * got it right before overriding. Picking the detected kind clears the + * override. Locked environments show the reason as a disabled item instead of + * hiding the submenu, so the current icon still reads. */ -export function EnvironmentIconPicker({ +export function EnvironmentIconMenu({ environmentId, serverConfig, - size = "sm", }: { readonly environmentId: EnvironmentId; readonly serverConfig: ServerConfig | null; - readonly size?: "xs" | "sm"; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const operateAccess = useEnvironmentOperateAccess(environmentId); const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); - const override = serverConfig?.settings.environmentIcon ?? null; - const detected = serverConfig?.environment.platform.machine ?? null; + // With no detection the server falls back to "server", so picking that + // kind clears the override the same way picking the detected kind does. + const detected = serverConfig?.environment.platform.machine ?? "server"; const resolved = resolveEnvironmentMachineKind(serverConfig); - const value = override ?? AUTOMATIC_VALUE; - const automaticLabel = - detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; - - const handleValueChange = useCallback( - (next: string | null) => { - if (next === null) return; - if (next === AUTOMATIC_VALUE) { - updateSettings({ environmentIcon: null }); - } else if (isEnvironmentMachineKind(next)) { - updateSettings({ environmentIcon: next }); - } - }, - [updateSettings], - ); - const select = ( - - ); - - if (lock === null) { - return select; - } return ( - - - } - > - {select} - - - {lock} - - + + + + Icon + + + {lock !== null ? ( + <> + + {lock} + + + + ) : null} + { + if (lock !== null || !isEnvironmentMachineKind(next)) return; + updateSettings({ environmentIcon: next === detected ? null : next }); + }} + > + {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( + + + + + {ENVIRONMENT_MACHINE_KIND_LABELS[kind]} + + {kind === detected ? ( + + {serverConfig?.environment.platform.machine ? "detected" : "default"} + + ) : null} + + + ))} + + + ); } diff --git a/apps/web/src/components/settings/EnvironmentRow.tsx b/apps/web/src/components/settings/EnvironmentRow.tsx new file mode 100644 index 000000000000..5f3dae7e47c1 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentRow.tsx @@ -0,0 +1,74 @@ +import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { EnvironmentPresentation } from "~/state/environments"; +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; + +export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +/** + * How this client reaches a machine, printed first in every environment row so + * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend. + */ +export function environmentTransportLabel(environment: EnvironmentPresentation): string { + const { entry } = environment; + if (entry.target._tag === "PrimaryConnectionTarget") return "This machine"; + if (environment.relayManaged) return "T3 Connect"; + if (isDesktopLocalConnectionTarget(entry.target)) return "WSL"; + if ( + entry.target._tag === "SshConnectionTarget" && + Option.isSome(entry.profile) && + entry.profile.value._tag === "SshConnectionProfile" + ) { + return `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`; + } + return environment.displayUrl ?? "Remote link"; +} + +/** + * One machine in a grouped settings list: icon, name, a single subtitle line, + * and controls on the right. Every environment list on the Connections page + * uses this so the lists share one rhythm. + */ +export function EnvironmentRow({ + kind, + label, + subtitle, + below, + dimmed = false, + className, + children, +}: { + readonly kind: EnvironmentMachineKind; + readonly label: string; + readonly subtitle: ReactNode; + /** Extra content under the subtitle, such as update progress. */ + readonly below?: ReactNode; + readonly dimmed?: boolean; + readonly className?: string; + readonly children?: ReactNode; +}) { + return ( +
+ +
+

{label}

+
{subtitle}
+ {below} +
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx new file mode 100644 index 000000000000..109e97b9d8e8 --- /dev/null +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -0,0 +1,67 @@ +import { ChevronRightIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { useSettingsSearchTarget, useSettingsSearchTargetId } from "./settingsLayout"; + +/** + * A grouped settings section that starts closed. The header carries the title, + * a one line summary of what is set inside, and an optional control such as + * the section's own switch. A settings search that targets the section opens it. + */ +export function FoldedSettingsSection({ + id, + title, + summary, + control, + children, +}: { + readonly id: string; + readonly title: string; + readonly summary?: string | null; + readonly control?: ReactNode; + readonly children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); + const targetRef = useSettingsSearchTarget(id); + // A search jump lands inside the fold, so open it before the scroll runs. + const [openedForTarget, setOpenedForTarget] = useState(null); + if (searchTargetId === id && openedForTarget !== id) { + setOpenedForTarget(id); + if (!open) setOpen(true); + } + + return ( +
+ +
+ + + {title} + {summary ? ( + {summary} + ) : null} + + {control ?
{control}
: null} +
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.test.ts b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts new file mode 100644 index 000000000000..5f09ab69c99d --- /dev/null +++ b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { summarizeGitHubRouting } from "./GitHubRoutingSettings"; + +describe("summarizeGitHubRouting", () => { + it("is empty when no machine shares", () => { + expect(summarizeGitHubRouting([{ label: "alvin", permission: "off" }])).toBeNull(); + }); + + it("groups sharing machines by permission, read and act first", () => { + expect( + summarizeGitHubRouting([ + { label: "alvin", permission: "read" }, + { label: "bb-1", permission: "read-write" }, + { label: "cup2", permission: "off" }, + { label: "Theo's MacBook Pro", permission: "read-write" }, + ]), + ).toBe("bb-1, Theo's MacBook Pro read and act · alvin read PRs"); + }); +}); diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.tsx b/apps/web/src/components/settings/GitHubRoutingSettings.tsx index fad58b4f644b..10bb2dd98e35 100644 --- a/apps/web/src/components/settings/GitHubRoutingSettings.tsx +++ b/apps/web/src/components/settings/GitHubRoutingSettings.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { gitHubRoutingConnectionKey, gitHubRoutingPermissionFor, @@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { toastManager } from "../ui/toast"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [ @@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> { value: "read-write", label: "Read and act" }, ]; +const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; + +/** + * Closed-header summary: the machines that share, grouped by permission. + * Null when nothing is shared. + */ +export function summarizeGitHubRouting( + entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, +): string | null { + const groups = (["read-write", "read"] as const).flatMap((permission) => { + const labels = entries.filter((entry) => entry.permission === permission); + return labels.length === 0 + ? [] + : [`${labels.map((entry) => entry.label).join(", ")} ${summaryLabels[permission]}`]; + }); + return groups.length === 0 ? null : groups.join(" · "); +} + +/** + * Folded section under the environments list. One row per switched-on machine + * with how much of its GitHub access the other machines may use. The trust + * warning is the first line of the body so it sits next to the control. + * Rendered only when two or more machines are on. + */ export function GitHubRoutingSettings({ environments, - selectedEnvironmentId, }: { readonly environments: ReadonlyArray; - readonly selectedEnvironmentId: EnvironmentId; }) { const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); const [saving, setSaving] = useState(false); - const selectedEnvironments = environments.filter( - (environment) => environment.environmentId === selectedEnvironmentId, - ); + if (environments.length < 2) return null; + + const { id, title } = searchableSetting("github-routing"); return ( - - - {selectedEnvironments.map((environment) => ( - ({ + label: environment.label, + permission: gitHubRoutingPermissionFor(environment.entry, permissions), + })), + ) ?? "Off" + } + > +

+ Machines you trust here can read PR data through each other's GitHub access. Enable both + machines. Read and act may use broader permissions than the machine that owns them. This + applies only to this device. +

+ {environments.map((environment) => ( + { - if (permission === null) return; - setSaving(true); - void update({ environmentId: environment.environmentId, permission }).then( - (result) => { - setSaving(false); - if (result._tag === "Failure") - toastManager.add({ - type: "error", - title: "Could not save GitHub routing permission", - }); - }, - ); - }} + kind={resolveEnvironmentMachineKind(environment.serverConfig)} + label={environment.label} + subtitle={environmentTransportLabel(environment)} + > + - } - /> + + + + {options.map(({ value, label }) => ( + + {label} + + ))} + + + ))} -
+ ); } diff --git a/apps/web/src/components/settings/LoadBalancingSettings.test.ts b/apps/web/src/components/settings/LoadBalancingSettings.test.ts new file mode 100644 index 000000000000..53e3c16d6f8d --- /dev/null +++ b/apps/web/src/components/settings/LoadBalancingSettings.test.ts @@ -0,0 +1,32 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { loadPreferenceForWeight, summarizeLoadPreferences } from "./LoadBalancingSettings"; + +const machines = [ + { environmentId: EnvironmentId.make("a"), label: "alvin" }, + { environmentId: EnvironmentId.make("b"), label: "bb-1" }, + { environmentId: EnvironmentId.make("c"), label: "ProMini" }, +]; + +describe("loadPreferenceForWeight", () => { + it("snaps legacy slider weights onto the four preferences", () => { + expect(loadPreferenceForWeight(undefined)).toBe(50); + expect(loadPreferenceForWeight(0)).toBe(0); + expect(loadPreferenceForWeight(10)).toBe(25); + expect(loadPreferenceForWeight(50)).toBe(50); + expect(loadPreferenceForWeight(80)).toBe(100); + }); +}); + +describe("summarizeLoadPreferences", () => { + it("is empty when every machine is at Normal", () => { + expect(summarizeLoadPreferences(machines, { a: 50 })).toBeNull(); + }); + + it("lists only the machines that differ from Normal, in list order", () => { + expect(summarizeLoadPreferences(machines, { b: 100, c: 0 })).toBe( + "bb-1 prefer · ProMini manual only", + ); + }); +}); diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx index ae42139f58de..1784e3732105 100644 --- a/apps/web/src/components/settings/LoadBalancingSettings.tsx +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -1,3 +1,5 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; + import { useClientSettings, useClientSettingsHydrated, @@ -6,7 +8,8 @@ import { import type { EnvironmentPresentation } from "~/state/environments"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; const preferences = [ @@ -14,8 +17,44 @@ const preferences = [ { value: 50, label: "Normal" }, { value: 25, label: "Less often" }, { value: 0, label: "Manual only" }, -]; +] as const; + +type LoadPreference = (typeof preferences)[number]["value"]; + +/** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */ +export function loadPreferenceForWeight(weight: number | undefined): LoadPreference { + if (weight === undefined || weight === 50) return 50; + if (weight === 0) return 0; + return weight < 50 ? 25 : 100; +} + +function preferenceLabel(preference: LoadPreference): string { + return preferences.find((entry) => entry.value === preference)!.label; +} +/** + * Closed-header summary: the machines not at Normal, so the folded section + * still tells you what is set. Null when every machine is at the default. + */ +export function summarizeLoadPreferences( + environments: ReadonlyArray>, + weights: Readonly>, +): string | null { + const parts = environments.flatMap((environment) => { + const preference = loadPreferenceForWeight(weights[environment.environmentId]); + return preference === 50 + ? [] + : [`${environment.label} ${preferenceLabel(preference).toLowerCase()}`]; + }); + return parts.length === 0 ? null : parts.join(" · "); +} + +/** + * Folded section under the environments list. Its switch turns balancing on + * for this client, and the body holds one row per switched-on machine with + * how often that machine should receive new threads. Rendered only when two + * or more machines are on, since one machine has nothing to balance against. + */ export function LoadBalancingSettings({ environments, }: { @@ -25,78 +64,71 @@ export function LoadBalancingSettings({ const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdateClientSettings(); - if (environments.length < 2) { - return ( - -

- Connect another machine to automatically balance load across environments. -

-
- ); - } + if (environments.length < 2) return null; + const { id, title } = searchableSetting("load-balancing"); return ( - - updateSettings({ loadBalancingEnabled })} - /> - } - /> - - ); -} - -export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) { - const settings = useClientSettings(); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdateClientSettings(); - const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; - // Keep saved slider weights until the user chooses a different preference. - const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; - - return ( - { - if (value !== null) { + updateSettings({ loadBalancingEnabled })} + /> + } + > +

+ New threads in shared projects start on the machine with the most free CPU and memory, + weighted by each machine's preference. +

+ {environments.map((environment) => ( + + - } - /> + + + + + {preferences.map(({ value, label }) => ( + + {label} + + ))} + + + + ))} + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 373ab7885c42..e6961badcfc5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "connections-environment", - title: "This environment", + title: "This machine", to: "/settings/connections", searchTerms: [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", @@ -690,7 +690,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "remote-environments", - title: "Remote environments", + title: "Environments", to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, @@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "github-routing", - title: "GitHub routing", + title: "GitHub sharing", to: "/settings/connections", searchTerms: ["pull request trusted environments shared credentials permissions read actions"], }, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..55514fc83ff4 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -65,7 +65,8 @@ another link to share. Auto balance is off by default. On web and desktop, enable it in **Settings → Connections → Load balancing** to automatically choose a machine for -new threads in projects grouped across connected environments. +new threads in projects grouped across connected environments. The section +appears once two or more machines are switched on. Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and memory available, **Less often** to reduce its share, or **Manual only** to exclude it from automatic selection. These are preferences, not fixed traffic percentages. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d95444b4ae05..64cdc19c2812 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -99,7 +99,7 @@ GitLab calls these merge requests. GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also supports approving waiting fork workflows and opening a revert pull request for a merged change. -GitHub routing is off by default. In Settings → Connections (Environments on mobile), choose +GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose **Read PRs** or **Read and act** for each environment you trust to share GitHub access. Enable both the original environment and the environment answering its requests on this client. **Read and act** can use broader GitHub permissions than the original environment's credential; From 564719165e9131ab2aa55e9c805b860a415690da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 19:45:37 -0700 Subject: [PATCH 04/27] fix(mobile): keep usage widget rows consistently sized (#11669) --- apps/mobile/src/widgets/SubscriptionUsage.tsx | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index 600383765d59..c8cde5d444fe 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -2,12 +2,12 @@ import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; import { accessibilityElement, accessibilityLabel, + fixedSize, font, foregroundStyle, frame, layoutPriority, lineLimit, - minimumScaleFactor, progressViewStyle, tint, widgetURL, @@ -33,6 +33,8 @@ function SubscriptionUsage( const accessory = family === "accessoryRectangular"; const compact = family === "systemSmall" || accessory || environment.levelOfDetail === "simplified"; + // Budget short cards for two quotas per provider, including their secondary text. + const dense = family === "systemSmall" || family === "systemMedium"; const limit = family === "systemExtraLarge" ? 6 : family === "systemLarge" ? 4 : 2; const monochrome = environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; @@ -77,6 +79,7 @@ function SubscriptionUsage( : provider.detail; const barModifiers = [ progressViewStyle("linear"), + frame({ height: 4 }), ...(monochrome ? [] : [tint(provider.name === "Claude" ? "#d97757" : "#8e8e93")]), ]; if (accessory) { @@ -99,7 +102,6 @@ function SubscriptionUsage( modifiers={[ font({ textStyle: "caption", weight: "semibold" }), lineLimit(1), - minimumScaleFactor(0.75), foregroundStyle("primary"), ]} > @@ -132,35 +134,37 @@ function SubscriptionUsage( {provider.name} - {(!compact || shown.length === 0) && detail !== "Subscription remaining" ? ( + {!compact || shown.length === 0 ? ( - {detail} + {detail === "Subscription remaining" ? " " : detail} ) : null} {shown.map((window) => ( {window.label} @@ -182,9 +185,11 @@ function SubscriptionUsage( {!compact ? ( - + {window.reset} ) : null} @@ -216,7 +214,13 @@ function SubscriptionUsage( {!compact && !stale && (period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) > limit ? ( - + {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) - limit}{" "} more in T3 @@ -228,11 +232,11 @@ function SubscriptionUsage( return ( {compact ? ( - + {columns} ) : ( @@ -243,12 +247,7 @@ function SubscriptionUsage( {!accessory ? : null} {!accessory ? ( {props.checkedAt ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` From 3b75e607eb909522a8f5562fb40e44efc72bb66c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 20:18:17 -0700 Subject: [PATCH 05/27] feat(server): add reusable auth token for dev worktrees (#8606) --- AGENTS.md | 4 +- apps/server/src/auth/EnvironmentAuth.test.ts | 218 +++++++++++++++++- apps/server/src/auth/EnvironmentAuth.ts | 109 ++++++++- apps/server/src/auth/ReusableDevAuth.ts | 31 +++ apps/server/src/auth/SessionStore.test.ts | 153 +++++++++++- apps/server/src/auth/SessionStore.ts | 71 +++++- apps/server/src/auth/http.test.ts | 137 +++++++++++ apps/server/src/auth/http.ts | 25 +- apps/server/src/cli/config.test.ts | 89 +++++++ apps/server/src/cli/config.ts | 24 ++ apps/server/src/config.ts | 2 + apps/server/src/persistence/AuthSessions.ts | 29 ++- apps/web/src/authBootstrap.test.ts | 139 +++++++++++ apps/web/src/environments/primary/auth.ts | 33 ++- docs/internals/environment-auth.md | 19 ++ docs/operations/development.md | 43 +++- .../client-runtime/src/rpc/session.test.ts | 31 +++ packages/client-runtime/src/rpc/session.ts | 40 +++- scripts/dev-runner.test.ts | 21 ++ scripts/dev-runner.ts | 1 + 20 files changed, 1177 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/auth/ReusableDevAuth.ts create mode 100644 apps/server/src/auth/http.test.ts diff --git a/AGENTS.md b/AGENTS.md index e3b5771d797c..ccf1fdc1d85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. +- To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 028fe53e0191..bfa1b87deb99 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PersistenceErrors from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts"; /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ const TEST_SERVER_PORT = 13_773; +const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError); +const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError); const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( @@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), @@ -72,6 +78,216 @@ const requestMetadata = { }; it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { + it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const scopedExchange = yield* serverAuth.createBrowserSession( + pairing.credential, + requestMetadata, + ); + const request = { + cookies: { + [sessions.cookieName]: scopedExchange.sessionToken, + [devExchange.cookieName ?? "missing"]: devExchange.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const authenticated = yield* serverAuth.authenticateHttpRequest(request); + expect(devExchange.cookieName).toMatch(/^t3_dev_session_/); + expect(devExchange.expireNormalCookie).toBe(true); + expect(authenticated.scopes).toEqual(["orchestration:read"]); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const scopedExchange = yield* serverAuth.createBrowserSession( + pairing.credential, + requestMetadata, + ); + const scoped = yield* sessions.verify(scopedExchange.sessionToken); + yield* sessions.revoke(scoped.sessionId); + const request = { + cookies: { + [sessions.cookieName]: scopedExchange.sessionToken, + [devExchange.cookieName ?? "missing"]: devExchange.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request)); + expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("does not use the dev cookie when Authorization is invalid or empty", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + for (const authorization of ["Bearer invalid", ""] as const) { + const request = { + cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken }, + headers: { authorization }, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request)); + expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true); + } + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("exchanges the reusable dev token for a local scoped OAuth session", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + ); + + expect(exchanged.access_token).not.toBe(token); + expect(exchanged.scope).toBe("orchestration:read"); + const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + { proofKeyThumbprint: "test-proof-key" }, + ); + expect(dpop.access_token).not.toBe(token); + expect(dpop.access_token).not.toBe(exchanged.access_token); + expect(dpop.token_type).toBe("DPoP"); + expect(dpop.scope).toBe("orchestration:read"); + + const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + ); + const firstSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(exchanged.access_token), + ); + const secondSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(secondBearer.access_token), + ); + expect(firstSession.subject).toBe("reusable-dev-token-child"); + expect(secondSession.subject).toBe("reusable-dev-token-child"); + expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("uses a one-time startup credential after local dev token revocation", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const initial = yield* serverAuth.issueStartupPairingCredential(); + const seeded = yield* sessions.verify(token); + + yield* sessions.revoke(seeded.sessionId); + const recovery = yield* serverAuth.issueStartupPairingCredential(); + + expect(initial.credential).toBe(token); + expect(recovery.credential).not.toBe(token); + expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); + expect( + (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response, + ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes }); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("keeps the pairing issue error as the immediate recovery failure", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const seeded = yield* sessions.verify(token); + + yield* sessions.revoke(seeded.sessionId); + yield* sql` + CREATE TRIGGER reject_startup_pairing_link + BEFORE INSERT ON auth_pairing_links + BEGIN + SELECT RAISE(ABORT, 'startup pairing insert rejected'); + END + `; + + const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential()); + + expect(error._tag).toBe("ServerAuthPairingLinkCreationError"); + expect(isPairingCredentialIssueError(error.cause)).toBe(true); + if (isPairingCredentialIssueError(error.cause)) { + expect(isPersistenceSqlError(error.cause.cause)).toBe(true); + } + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 964fe6220d6b..481ffc2fad64 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -31,10 +31,12 @@ import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; +import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; @@ -427,6 +429,8 @@ export class EnvironmentAuth extends Context.Service< { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; + readonly cookieName?: string; + readonly expireNormalCookie?: boolean; }, ServerAuthInvalidCredentialError | ServerAuthInternalError >; @@ -504,6 +508,8 @@ export class EnvironmentAuth extends Context.Service< type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; + readonly cookieName?: string; + readonly expireNormalCookie?: boolean; }; const AUTHORIZATION_PREFIX = "Bearer "; @@ -599,6 +605,8 @@ export const make = Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); + const config = yield* ServerConfig.ServerConfig; + const devAuth = resolveReusableDevAuth(config); const authenticateToken = ( token: string, @@ -630,15 +638,22 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const credential = selectRequestCredential( + const selectedCredential = selectRequestCredential( request, sessions.cookieName, sessions.legacyCookieName, ); + const dpopToken = parseDpopToken(request); + const hasAuthorization = request.headers.authorization !== undefined; + const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined; + const credential = + selectedCredential ?? + (!hasAuthorization && devCookieToken !== undefined + ? { token: devCookieToken, source: "dev-cookie" as const } + : undefined); if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - const dpopToken = parseDpopToken(request); return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { @@ -697,8 +712,32 @@ export const make = Effect.gen(function* () { const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, - ) => - bootstrapCredentials.consume(credential).pipe( + ) => { + if (devAuth?.matches(credential)) { + return sessions.verify(credential).pipe( + mapSessionVerificationErrors, + Effect.flatMap((session) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + response: { + authenticated: true, + scopes: session.scopes, + sessionMethod: session.method, + expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })), + } satisfies AuthBrowserSessionResult, + sessionToken: credential, + cookieName: devAuth.cookieName, + expireNormalCookie: true, + }) satisfies BootstrapExchangeResult, + ), + ), + ), + Effect.withSpan("EnvironmentAuth.createBrowserSession"), + ); + } + return bootstrapCredentials.consume(credential).pipe( Effect.mapError(toBootstrapExchangeError), Effect.flatMap((grant) => sessions @@ -729,11 +768,42 @@ export const make = Effect.gen(function* () { ), Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); + }; + + type ResolvedBootstrapGrant = Pick< + PairingGrantStore.BootstrapGrant, + "scopes" | "subject" | "label" + > & { + readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token"; + }; + const resolveBootstrapGrant = ( + credential: string, + input?: { readonly proofKeyThumbprint?: string }, + ): Effect.Effect< + ResolvedBootstrapGrant, + ServerAuthInvalidCredentialError | ServerAuthInternalError + > => { + if (!devAuth?.matches(credential)) { + return bootstrapCredentials + .consume(credential, input) + .pipe(Effect.mapError(toBootstrapExchangeError)); + } + return sessions.verify(credential).pipe( + mapSessionVerificationErrors, + Effect.map( + (session) => + ({ + method: "reusable-dev-token", + scopes: session.scopes, + subject: "reusable-dev-token-child", + }) satisfies ResolvedBootstrapGrant, + ), + ); + }; const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), + resolveBootstrapGrant(credential, input).pipe( Effect.flatMap((grant) => Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; @@ -917,12 +987,33 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = - () => - issuePairingCredentialForSubject({ + () => { + const fallback = issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, purpose: "startup", - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + }); + if (!devAuth) { + return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + } + return sessions.verify(devAuth.credential).pipe( + Effect.map( + (session) => + ({ + id: session.sessionId, + credential: devAuth.credential, + label: "Reusable dev token", + expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT), + }) satisfies AuthPairingCredentialResult, + ), + Effect.catch((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? fallback + : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })), + ), + Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"), + ); + }; const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( diff --git a/apps/server/src/auth/ReusableDevAuth.ts b/apps/server/src/auth/ReusableDevAuth.ts new file mode 100644 index 000000000000..5b7d59db8da2 --- /dev/null +++ b/apps/server/src/auth/ReusableDevAuth.ts @@ -0,0 +1,31 @@ +import * as NodeCrypto from "node:crypto"; +import { AuthSessionId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Redacted from "effect/Redacted"; + +import type { ServerConfig } from "../config.ts"; + +export const REUSABLE_DEV_SESSION_PREFIX = "dev-auth-"; +// The database schema requires an expiry for a configured token with no normal session TTL. +export const REUSABLE_DEV_SESSION_EXPIRES_AT = DateTime.makeUnsafe("9999-12-31T23:59:59.999Z"); + +export function resolveReusableDevAuth( + config: Pick, +) { + if (config.mode !== "web" || config.devUrl === undefined || config.devAuthToken === undefined) { + return undefined; + } + const token = config.devAuthToken; + if (Redacted.value(token).length === 0) { + return undefined; + } + const hash = NodeCrypto.createHash("sha256").update(Redacted.value(token)).digest(); + const tokenId = hash.toString("hex"); + return { + credential: Redacted.value(token), + sessionId: AuthSessionId.make(`${REUSABLE_DEV_SESSION_PREFIX}${tokenId}`), + cookieName: `t3_dev_session_${tokenId}`, + matches: (credential: string) => + NodeCrypto.timingSafeEqual(hash, NodeCrypto.createHash("sha256").update(credential).digest()), + }; +} diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1e2d5c60e9c9..72f91e21d16f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -3,9 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -13,7 +15,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -54,6 +59,32 @@ const relaySessionInput = { client: { label: "Relay desktop", deviceType: "desktop" }, } as const; +const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* ( + baseDir: string, + token?: string, +) { + const devUrl = new URL("http://127.0.0.1:5173"); + const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { + baseDirIsExplicit: true, + }); + yield* ServerConfig.ensureServerDirectories(paths); + const persistence = makeSqlitePersistenceLive(paths.dbPath); + return SessionStore.layer.pipe( + Layer.provide(persistence), + Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make(baseDir))), + Layer.provide( + makeServerConfigLayer({ + ...paths, + baseDir, + mode: "web", + devUrl, + ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }), + }), + ), + ); +}); + const repositoryFailure = new PersistenceSqlError({ operation: "AuthSessionRepository.getById:query", detail: "sqlite is unavailable", @@ -62,6 +93,7 @@ const repositoryFailure = new PersistenceSqlError({ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, createReplacingActive: () => Effect.succeed([]), + createIfAbsent: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), revoke: () => Effect.fail(repositoryFailure), @@ -103,6 +135,125 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }), ); + it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" }); + const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" }); + const layerA = yield* makeDiskSessionStoreLayer(baseA, token); + const fromA = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + const local = yield* sessions.issue({ subject: "environment-a" }); + const ticket = yield* sessions.issueWebSocketToken(local.sessionId); + yield* sessions.revoke(dev.sessionId); + return { dev, local, ticket }; + }).pipe(Effect.provide(layerA), Effect.scoped); + + const layerB = yield* makeDiskSessionStoreLayer(baseB, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + expect(dev.sessionId).toBe(fromA.dev.sessionId); + expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe( + "InvalidSessionTokenSignatureError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe( + "InvalidWebSocketTokenSignatureError", + ); + }).pipe(Effect.provide(layerB), Effect.scoped); + + const reopenedB = yield* makeDiskSessionStoreLayer(baseB, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + expect(dev.sessionId).toBe(fromA.dev.sessionId); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId); + }).pipe(Effect.provide(reopenedB), Effect.scoped); + + const reopenedA = yield* makeDiskSessionStoreLayer(baseA, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); + }).pipe(Effect.provide(reopenedA), Effect.scoped); + }), + ); + + it.effect("invalidates old dev credentials and tickets after rotation or removal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" }); + const oldToken = "old-reusable-dev-auth-token-that-is-long-enough"; + const newToken = "new-reusable-dev-auth-token-that-is-long-enough"; + const initialLayer = yield* makeDiskSessionStoreLayer(baseDir, oldToken); + const old = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(oldToken); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + return { dev, ticket }; + }).pipe(Effect.provide(initialLayer), Effect.scoped); + + const rotatedLayer = yield* makeDiskSessionStoreLayer(baseDir, newToken); + const rotated = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe( + "MalformedSessionTokenError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe( + "UnknownWebSocketSessionError", + ); + expect( + (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId), + ).toBe(true); + const dev = yield* sessions.verify(newToken); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + return { dev, ticket }; + }).pipe(Effect.provide(rotatedLayer), Effect.scoped); + + const removedLayer = yield* makeDiskSessionStoreLayer(baseDir); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe( + "MalformedSessionTokenError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe( + "UnknownWebSocketSessionError", + ); + expect( + (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId), + ).toBe(true); + }).pipe(Effect.provide(removedLayer), Effect.scoped); + }), + ); + + it.effect("keeps the reusable token active after normal sessions expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const normal = yield* sessions.issue({ subject: "normal-session" }); + + yield* TestClock.adjust(Duration.days(31)); + + expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe( + "SessionTokenExpiredError", + ); + }).pipe( + Effect.provide( + Layer.merge( + makeSessionStoreLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + TestClock.layer(), + ), + ), + ), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index be8e7627bb20..1526dd6705d7 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1,5 +1,6 @@ import { AuthSessionId, + AuthAdministrativeScopes, AuthStandardClientScopes, AuthEnvironmentScopes, type AuthClientMetadata, @@ -24,6 +25,11 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { + REUSABLE_DEV_SESSION_EXPIRES_AT, + REUSABLE_DEV_SESSION_PREFIX, + resolveReusableDevAuth, +} from "./ReusableDevAuth.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, @@ -416,7 +422,6 @@ export class SessionStore extends Context.Service< const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); - const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), @@ -492,6 +497,31 @@ export const make = Effect.gen(function* () { } as const; const cookieName = resolveSessionCookieName(cookieInput); const legacyCookieName = resolveLegacySessionCookieName(cookieInput); + const devAuth = resolveReusableDevAuth(serverConfig); + if (devAuth) { + yield* authSessions + .createIfAbsent({ + sessionId: devAuth.sessionId, + subject: "reusable-dev-token", + scopes: AuthAdministrativeScopes, + method: "browser-session-cookie", + client: { + label: "Reusable dev token", + ipAddress: null, + userAgent: null, + deviceType: "unknown", + os: null, + browser: null, + }, + issuedAt: yield* DateTime.now, + expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT, + }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }), + ), + ); + } const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -717,6 +747,42 @@ export const make = Effect.gen(function* () { const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( function* (token) { + if (devAuth?.matches(token)) { + const row = yield* authSessions + .getById({ sessionId: devAuth.sessionId }) + .pipe( + Effect.mapError( + (cause) => + new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }), + ), + ); + if (Option.isNone(row)) { + return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionTokenRevokedError({ + sessionId: devAuth.sessionId, + revokedAt: row.value.revokedAt, + }); + } + const observedAt = yield* DateTime.now; + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: devAuth.sessionId, + expiresAt: row.value.expiresAt, + observedAt, + }); + } + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + } const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new MalformedSessionTokenError({}); @@ -829,6 +895,9 @@ export const make = Effect.gen(function* () { const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); + if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } const observedAt = yield* DateTime.now; const expiresAt = DateTime.make(claims.exp); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts new file mode 100644 index 000000000000..3d53ee088376 --- /dev/null +++ b/apps/server/src/auth/http.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentHttpApi } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Etag from "effect/unstable/http/Etag"; +import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./http.ts"; + +const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough"; +class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} + +const configLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { + ...config, + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make(DEV_TOKEN), + } satisfies ServerConfig.ServerConfig["Service"]; + }), +).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" }))); + +const environmentAuthLayer = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(configLayer), +); +const routesLayer = HttpApiBuilder.layer(AuthTestApi).pipe( + Layer.provide(authHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + Layer.provideMerge(environmentAuthLayer), + Layer.provide(configLayer), + Layer.provideMerge( + HttpPlatform.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Etag.layerWeak), + ), + ), + Layer.provide(NodeServices.layer), +); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const postJson = (path: string, body: unknown, headers?: Readonly>) => + new Request(`http://127.0.0.1${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: encodeJson(body), + }); + +it.effect("sets the selected browser session cookies through the HTTP route", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.die("Not used by these routes."), + remove: () => Effect.void, + }); + const requestContext = Context.make(Crypto.Crypto, crypto).pipe( + Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore), + ); + return yield* Effect.acquireUseRelease( + Effect.sync( + () => + [ + HttpRouter.toWebHandler(routesLayer, { disableLogger: true }), + HttpRouter.toWebHandler(routesLayer, { disableLogger: true }), + ] as const, + ), + ([environmentA, environmentB]) => + Effect.tryPromise(async () => { + const devResponse = await environmentA.handler( + postJson("/api/auth/browser-session", { credential: DEV_TOKEN }), + requestContext, + ); + expect(devResponse.status).toBe(200); + const devCookies = devResponse.headers.getSetCookie(); + const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_")); + expect(devCookie).toContain("HttpOnly"); + expect(devCookie).toContain(`=${DEV_TOKEN};`); + expect(devCookies).toContainEqual( + expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/), + ); + const devCookieHeader = devCookie?.split(";", 1)[0] ?? ""; + const environmentBSession = await environmentB.handler( + new Request("http://127.0.0.1/api/auth/session", { + headers: { cookie: devCookieHeader }, + }), + requestContext, + ); + expect(environmentBSession.status).toBe(200); + expect(await environmentBSession.json()).toMatchObject({ authenticated: true }); + + const pairingResponse = await environmentA.handler( + postJson( + "/api/auth/pairing-token", + { scopes: ["orchestration:read"] }, + { cookie: devCookieHeader }, + ), + requestContext, + ); + expect(pairingResponse.status).toBe(200); + const pairing = (await pairingResponse.json()) as { credential: string }; + const restrictedResponse = await environmentA.handler( + postJson("/api/auth/browser-session", { credential: pairing.credential }), + requestContext, + ); + expect(restrictedResponse.status).toBe(200); + const restrictedCookies = restrictedResponse.headers.getSetCookie(); + expect(restrictedCookies).toHaveLength(1); + expect(restrictedCookies[0]).toMatch(/^t3_session_/); + expect(restrictedCookies[0]).not.toContain("t3_dev_session_"); + }), + ([environmentA, environmentB]) => + Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index b50d6eae9a18..0f927580367d 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -273,10 +273,27 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - yield* appendSessionCookie( - sessions.cookieName, - result.sessionToken, - result.response.expiresAt, + const cookieName = result.cookieName ?? sessions.cookieName; + const selectedCookie = yield* Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); + const sessionCookies = result.expireNormalCookie + ? yield* Effect.fromResult( + Cookies.expireCookie(selectedCookie, sessions.cookieName, { + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))) + : selectedCookie; + + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 2267a5cb1cc9..0c28bce28ae5 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -9,6 +9,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { @@ -24,6 +25,7 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const makeDesktopBootstrap = ( overrides: Partial = {}, @@ -73,6 +75,93 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); }); + it.effect("enables a trimmed reusable auth token only for web dev mode", () => + Effect.gen(function* () { + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-" })), + ); + const flags = { + mode: Option.some("web" as const), + port: Option.some(8788), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DEV_AUTH_TOKEN: " reusable-dev-auth-token-that-is-long-enough ", + }, + }), + ); + const web = yield* resolveServerConfig(flags, Option.none()).pipe( + Effect.provide(Layer.mergeAll(configLayer, NetService.layer)), + ); + const desktop = yield* resolveServerConfig( + { ...flags, mode: Option.some("desktop" as const) }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + + expect(web.devAuthToken).toBeDefined(); + if (web.devAuthToken === undefined) { + return yield* Effect.die("Expected reusable dev auth token."); + } + expect(Redacted.value(web.devAuthToken)).toBe("reusable-dev-auth-token-that-is-long-enough"); + expect(desktop.devAuthToken).toBeUndefined(); + }), + ); + + it.effect("does not expose an invalid reusable auth token", () => + Effect.gen(function* () { + const secret = "short-secret"; + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-invalid-" })), + ); + const flags = { + mode: Option.some("web" as const), + port: Option.some(8788), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }), + ); + const error = yield* resolveServerConfig(flags, Option.none()).pipe( + Effect.provide(Layer.mergeAll(configLayer, NetService.layer)), + Effect.flip, + ); + const desktop = yield* resolveServerConfig( + { ...flags, mode: Option.some("desktop" as const) }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + const staticWeb = yield* resolveServerConfig( + { ...flags, devUrl: Option.none() }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + + expect(String(error)).not.toContain(secret); + const serialized = yield* encodeUnknownJson(error); + expect(serialized).not.toContain(secret); + expect(desktop.devAuthToken).toBeUndefined(); + expect(staticWeb.devAuthToken).toBeUndefined(); + }), + ); + it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 847edbbc4fe0..1759b4b03830 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as LogLevel from "effect/LogLevel"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -141,6 +142,26 @@ const EnvServerConfig = Config.all({ ), }); +const DevAuthTokenConfig = Config.redacted("T3CODE_DEV_AUTH_TOKEN").pipe( + Config.map((token) => Redacted.make(Redacted.value(token).trim())), + Config.mapOrFail((token) => + Redacted.value(token).length === 0 || Redacted.value(token).length >= 32 + ? Effect.succeed(token) + : Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_DEV_AUTH_TOKEN must contain at least 32 characters.", + }), + ), + ), + ), + ), + Config.option, + Config.map(Option.filter((token) => Redacted.value(token).length > 0)), + Config.map(Option.getOrUndefined), +); + export interface CliServerFlags { readonly mode: Option.Option; readonly port: Option.Option; @@ -268,6 +289,8 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); + const devAuthToken = + mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined; const explicitBaseDir = resolveOptionPrecedence( normalizedFlags.baseDir, Option.fromUndefinedOr(env.t3Home), @@ -373,6 +396,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + ...(devAuthToken === undefined ? {} : { devAuthToken }), devAllowedOrigins: env.devAllowedOrigins, noBrowser, startupPresentation, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1486f6b40a2a..b0544ef30aeb 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -13,6 +13,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; +import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; @@ -79,6 +80,7 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly devAuthToken?: Redacted.Redacted | undefined; readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 37db33556e23..836b1f8f2e30 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -107,6 +107,9 @@ export class AuthSessionRepository extends Context.Service< readonly createReplacingActive: ( input: CreateReplacingActiveAuthSessionInput, ) => Effect.Effect, AuthSessionRepositoryError>; + readonly createIfAbsent: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; readonly getById: ( input: GetAuthSessionByIdInput, ) => Effect.Effect, AuthSessionRepositoryError>; @@ -204,10 +207,11 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const createSessionRow = SqlSchema.void({ - Request: CreateAuthSessionInput, - execute: (input) => - sql` + const insertSessionRow = (ignoreExisting: boolean) => + SqlSchema.void({ + Request: CreateAuthSessionInput, + execute: (input) => + sql` INSERT INTO auth_sessions ( session_id, subject, @@ -238,8 +242,11 @@ export const make = Effect.gen(function* () { ${input.expiresAt}, NULL ) + ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``} `, - }); + }); + const createSessionRow = insertSessionRow(false); + const createSessionRowIfAbsent = insertSessionRow(true); const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, @@ -393,6 +400,17 @@ export const make = Effect.gen(function* () { ), ); + const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) => + createSessionRowIfAbsent(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.createIfAbsent:query", + "AuthSessionRepository.createIfAbsent:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( @@ -493,6 +511,7 @@ export const make = Effect.gen(function* () { return { create, createReplacingActive, + createIfAbsent, getById, listActive, revoke, diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index dfe2b51d0400..cac54fe1ec84 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -506,6 +506,145 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(1); }); + it("exchanges a URL token when the browser already has a session", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const testWindow = installTestBrowser("http://localhost/#token=reusable-token"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testWindow.location.hash).toBe(""); + }); + + it("exchanges an explicit pair link after caching an authenticated state", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const testWindow = installTestBrowser("http://localhost/"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testApi.calls.session).toBe(3); + }); + + it("makes later callers wait for a URL token that arrives during bootstrap", async () => { + let releaseExchange!: () => void; + const exchangeRelease = new Promise((resolve) => { + releaseExchange = resolve; + }); + let markExchangeStarted!: () => void; + const exchangeStarted = new Promise((resolve) => { + markExchangeStarted = resolve; + }); + const nextSession = sequence( + authenticatedSession(LOOPBACK_AUTH), + authenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => { + markExchangeStarted(); + return Effect.promise(() => exchangeRelease).pipe( + Effect.andThen( + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-rejected-queued-credential", + }), + ), + ), + ); + }, + }); + const testWindow = installTestBrowser("http://localhost/"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const initialBootstrap = resolveInitialServerAuthGateState(); + testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + const explicitPairing = resolveInitialServerAuthGateState(); + const laterCaller = resolveInitialServerAuthGateState(); + let laterCallerSettled = false; + void laterCaller.then(() => { + laterCallerSettled = true; + }); + + try { + await expect(initialBootstrap).resolves.toEqual({ status: "authenticated" }); + await exchangeStarted; + expect(laterCallerSettled).toBe(false); + } finally { + releaseExchange(); + } + + const rejectedState = { + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Invalid pairing token. Check the token and try again.", + } as const; + await expect(explicitPairing).resolves.toEqual(rejectedState); + await expect(laterCaller).resolves.toEqual(rejectedState); + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testApi.calls.session).toBe(2); + }); + + it("does not exchange a token during an ordinary authenticated load", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + + expect(testApi.calls.browserSession).toEqual([]); + }); + + it("reports a rejected URL token without caching false success", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-url-credential", + }); + const nextSession = sequence( + authenticatedSession(LOOPBACK_AUTH), + unauthenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.fail(cause), + }); + installTestBrowser("http://localhost/#token=rejected-token"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Invalid pairing token. Check the token and try again.", + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]); + }); + it("creates a pairing credential from the authenticated auth endpoint", async () => { const testApi = await installAuthApi({ pairingCredential: (payload) => diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 2ceacd1a2cb0..ce06fbf5da05 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -307,13 +307,13 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } -async function bootstrapServerAuth(): Promise { - const bootstrapCredential = getDesktopBootstrapCredential(); +async function bootstrapServerAuth(urlCredential: string | null): Promise { const currentSession = await fetchSessionState(); - if (currentSession.authenticated) { + if (currentSession.authenticated && !urlCredential) { return { status: "authenticated" }; } + const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential(); if (!bootstrapCredential) { return { status: "requires-auth", @@ -428,19 +428,32 @@ export async function revokeOtherServerClientSessions(): Promise { } export async function resolveInitialServerAuthGateState(): Promise { - if (resolvedAuthenticatedGateState?.status === "authenticated") { - return resolvedAuthenticatedGateState; - } + const urlCredential = takePairingTokenFromUrl(); + const previousPromise = bootstrapPromise; + if (urlCredential) { + resolvedAuthenticatedGateState = null; + } else { + if (previousPromise) { + return previousPromise; + } - if (bootstrapPromise) { - return bootstrapPromise; + if (resolvedAuthenticatedGateState?.status === "authenticated") { + return resolvedAuthenticatedGateState; + } } - const nextPromise = bootstrapServerAuth(); + const nextPromise = previousPromise + ? previousPromise + .catch(() => undefined) + .then(() => { + resolvedAuthenticatedGateState = null; + return bootstrapServerAuth(urlCredential); + }) + : bootstrapServerAuth(urlCredential); bootstrapPromise = nextPromise; return nextPromise .then((result) => { - if (result.status === "authenticated") { + if (bootstrapPromise === nextPromise && result.status === "authenticated") { resolvedAuthenticatedGateState = result; } return result; diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index dc43b4d45c03..2428783ccc56 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -37,6 +37,25 @@ transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed replacement must leave the old credential usable. Pairing and browser sessions do not follow this replacement rule. +### Reusable dev credential + +Web development environments can accept one `T3CODE_DEV_AUTH_TOKEN` across +worktrees and ports on one hostname. The token and startup URLs that contain it +grant administrative access. Desktop and non-development servers ignore it. See +the [development runbook](../operations/development.md#reusable-dev-credential) +for setup. + +Each environment hashes the value and seeds its own database record at startup. +Environments do not share SQLite data, signing keys, environment IDs, session +records, pairing grants, or revocation state. Local revocation persists after +restart and does not affect another worktree. Removing or rotating the value +and restarting invalidates the old credential and its WebSocket tickets. + +Normal credentials keep precedence. A rejected normal credential never falls +back to the reusable credential. OAuth exchanges create ordinary local bearer +or DPoP children with normal expiry and revocation. The reusable cookie expires +after 30 days. + ## The environment is the filesystem boundary Projects are organizational boundaries, not filesystem sandboxes. diff --git a/docs/operations/development.md b/docs/operations/development.md index f913a8100e75..505a59e19818 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -10,7 +10,7 @@ vp i vp run dev ``` -Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate +Open the pairing URL printed by the dev runner. The bare origin does not authenticate a new browser. Prefer a container? See [Dev container](../internals/devcontainer.md) for VS Code and Codespaces setup. @@ -56,6 +56,47 @@ when changing this setup: The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and [Tailwind plugin](../../apps/web/vite/tailwind.ts). +#### Reusable dev credential + +Use this only on a hostname where you trust every service. Browsers send cookies to all ports +on that hostname. Any service you visit there can receive the reusable admin credential, +including services unrelated to T3 Code. If you run untrusted services on that hostname, keep +normal per-environment pairing instead. + +To use one browser profile across web dev worktrees on the same hostname, generate one fixed +value once: + +```sh +openssl rand -hex 32 +``` + +Put that value in the main checkout's gitignored `.env`: + +```dotenv +T3CODE_DEV_AUTH_TOKEN= +``` + +The `t3.json` Setup Worktree commands on Unix and Windows link that file to each worktree's +`.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process +environment values override `.env`, so no per-worktree export is needed after setup. + +For a manual worktree or launcher without that link, export the same fixed value instead: + +```sh +export T3CODE_DEV_AUTH_TOKEN="" +``` + +Do not generate a new value at startup. Start or restart `vp run dev --share` after configuration, +then open its printed startup pairing URL once per browser profile on that hostname. Later web dev +servers on the same hostname accept the shared cookie across ports. The cookie expires after 30 +days. Reload an old tab if its URL now serves a replacement environment. + +The token and startup pairing URLs are reusable administrative secrets. Never put them in a +commit, pull request, or public output. Every server still seeds its own auth database record at +startup and keeps its own SQLite data, signing key, and revocation state. Desktop and non-dev +servers ignore the value. See [environment authentication](../internals/environment-auth.md#reusable-dev-credential) +for the security model. + ## Checks Run checks for the files and packages you changed: diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 8fe76e6181b8..4687b84c359f 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1057,6 +1057,37 @@ describe("RpcSessionFactory", () => { ), ); + it.effect("rejects a server config for a different environment", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + const configFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.flip, Effect.forkChild); + const customConfigFiber = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.runHead, Effect.flip, Effect.forkChild); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, { + ...ENCODED_SERVER_CONFIG, + environment: { + ...ENCODED_SERVER_CONFIG.environment, + environmentId: "environment-2", + }, + }); + + const error = yield* Fiber.join(readyFiber); + expect(error).toMatchObject({ + reason: "configuration", + message: "Connected environment environment-2 does not match environment-1.", + }); + expect((yield* Fiber.join(configFiber))._tag).toBe("RpcClientError"); + expect((yield* Fiber.join(customConfigFiber))._tag).toBe("RpcClientError"); + }), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 3e353f6be4df..252bf0242236 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -40,6 +40,7 @@ import { type ServerConfigProjection, withoutEnvironmentThemes, } from "../state/serverConfigProjection.ts"; +import { environmentMismatchError } from "../connection/errors.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; @@ -289,14 +290,20 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( Effect.mapError(mapRpcError), Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), - ).pipe(Effect.withSpan("environment.initialSync")); + ).pipe( + Effect.flatMap((config) => + config.environment.environmentId === connection.environmentId + ? Effect.succeed(config) + : environmentMismatchError({ + expected: connection.environmentId, + actual: config.environment.environmentId, + }), + ), + Effect.withSpan("environment.initialSync"), + ); const serverConfigEvents = Stream.unwrap( Effect.gen(function* () { const subscription = yield* PubSub.subscribe(serverConfigUpdates); - yield* Effect.raceFirst( - Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), - Deferred.await(serverConfigExit), - ); const snapshot = yield* Ref.get(serverConfigState); if (Option.isNone(snapshot)) { return Stream.empty; @@ -336,10 +343,27 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( ); }), ); + const validatedInitialConfig = initialConfig.pipe( + Effect.mapError( + (cause) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ), + ); const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => - Equal.equals(input, serverConfigInput) - ? serverConfigEvents - : protocolClient[WS_METHODS.subscribeServerConfig](input); + Stream.unwrap( + validatedInitialConfig.pipe( + Effect.as( + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input), + ), + ), + ); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 1a63d8d74edf..5a270386a30d 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -152,6 +152,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); describe("createDevRunnerEnv", () => { + it.effect("forwards the reusable auth token to web dev and removes it for desktop", () => + Effect.gen(function* () { + const input = { + baseEnv: { T3CODE_DEV_AUTH_TOKEN: "reusable-dev-auth-token-that-is-long-enough" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + } as const; + const web = yield* createDevRunnerEnv({ ...input, mode: "dev" }); + const desktop = yield* createDevRunnerEnv({ ...input, mode: "dev:desktop" }); + + assert.equal(web.T3CODE_DEV_AUTH_TOKEN, input.baseEnv.T3CODE_DEV_AUTH_TOKEN); + assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined); + }), + ); it.effect("leaves the shared home implicit and disables browser auto-open", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index c4062955e9d5..5b16e9f90079 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -388,6 +388,7 @@ export function createDevRunnerEnv({ delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; + delete output.T3CODE_DEV_AUTH_TOKEN; } if (!isDesktopMode && host !== undefined) { From 1bbca0e78202c8ece73351fccd29f3c99070b82e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 20:49:13 -0700 Subject: [PATCH 06/27] feat(settings): choose how responses stream, with a warning on legacy token mode (#11678) Co-authored-by: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 72 +++++++- .../Layers/ProviderRuntimeIngestion.ts | 48 ++--- .../components/settings/SettingsPanels.tsx | 171 ++++++++++++++---- .../settings/settingsSearch.test.ts | 2 +- .../src/components/settings/settingsSearch.ts | 14 +- packages/contracts/src/orchestration.ts | 2 - packages/contracts/src/settings.ts | 24 ++- 7 files changed, 252 insertions(+), 81 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c22cf3e53bea..c4f57726f20f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -465,11 +465,11 @@ describe("ProviderRuntimeIngestion", () => { }); it.each([ - { delivery: "buffered", enableLegacyTokenStreaming: false }, - { delivery: "streamed", enableLegacyTokenStreaming: true }, + { delivery: "buffered", responseStreamingMode: "paragraph" as const }, + { delivery: "streamed", responseStreamingMode: "token" as const }, ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { const harness = await createHarness({ - serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming }, + serverSettings: { responseStreamingMode: settings.responseStreamingMode }, }); const threadId = asThreadId("thread-1"); const turnId = asTurnId("opencode-aborted-turn"); @@ -521,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => { "finalizes old buffered text on late %s without stopping the newer turn", async (terminalType) => { const harness = await createHarness({ - serverSettings: { enableLegacyTokenStreaming: false }, + serverSettings: { responseStreamingMode: "paragraph" }, }); const threadId = asThreadId("thread-1"); const oldTurnId = asTurnId("old-buffered-turn"); @@ -605,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => { { source: "an unspecified turn", turnId: undefined }, ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { const harness = await createHarness({ - serverSettings: { enableLegacyTokenStreaming: true }, + serverSettings: { responseStreamingMode: "token" }, }); const threadId = asThreadId("thread-1"); const stoppedTurnId = asTurnId("opencode-stopped-turn"); @@ -2712,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("keeps streaming while an async question is pending", async () => { - const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); const base = { provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:00:00.000Z", @@ -2954,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("starts a new streaming assistant message segment after approval", async () => { - const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); const startedAt = "2026-03-28T07:00:00.000Z"; const pausedAt = "2026-03-28T07:00:01.000Z"; const resumedAt = "2026-03-28T07:00:02.000Z"; @@ -3061,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( @@ -3235,6 +3235,62 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("holds every paragraph until completion in turn mode", async () => { + const harness = await createHarness({ serverSettings: { responseStreamingMode: "turn" } }); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-wait-mode"); + const itemId = asItemId("item-wait-mode"); + + await harness.emitAndDrain([ + { + type: "turn.started", + eventId: asEventId("evt-wait-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }, + ]); + harness.advanceClock(1_000); + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId("evt-wait-delta"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "First paragraph.\n\nSecond paragraph.\n\n", + }, + }, + ]); + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + // Paragraph mode would have delivered both paragraphs by now. + expect(await messageText()).toBeUndefined(); + + await harness.emitAndDrain([ + { + type: "item.completed", + eventId: asEventId("evt-wait-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }, + ]); + expect(await messageText()).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + }); + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { const harness = await createHarness(); const codex = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8126537bd9f7..d7ae589047bf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,6 +1,5 @@ import { ApprovalRequestId, - type AssistantDeliveryMode, CommandId, MessageId, type OrchestrationEvent, @@ -14,7 +13,9 @@ import { TurnId, type OrchestrationCheckpointSummary, type OrchestrationThreadActivity, + type ProjectId, type ProviderRuntimeEvent, + type ResponseStreamingMode, RuntimeRequestId, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -1167,7 +1168,19 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => + const resolveResponseStreamingMode = (projectId: ProjectId) => + Effect.map( + serverSettingsService.getSettings, + (settings) => resolveProjectSettings(settings, projectId).settings.responseStreamingMode, + ); + + // `mode` is "turn" or "paragraph"; token mode never buffers. + const appendBufferedAssistantText = ( + messageId: MessageId, + delta: string, + mode: Exclude, + atMillis: number, + ) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1176,9 +1189,13 @@ const make = Effect.gen(function* () { onSome: (text) => `${text}${delta}`, }); - // Deliver finished paragraphs and closed code blocks early so the - // user sees progress without token-by-token repaints. - const { ready, rest } = splitBufferedAssistantText(nextText); + // Paragraph mode delivers finished paragraphs and closed code blocks + // early so the user sees progress without token-by-token repaints. + // Turn mode holds everything until the turn finishes or pauses. + const { ready, rest } = + mode === "paragraph" + ? splitBufferedAssistantText(nextText) + : { ready: "", rest: nextText }; const lastDeliveredAt = Option.getOrUndefined( yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), ); @@ -1757,19 +1774,14 @@ const make = Effect.gen(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => - resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming - ? "streaming" - : "buffered", - ); - if (assistantDeliveryMode === "buffered") { + const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); + if (streamingMode !== "token") { // Pace on the server clock. OpenCode stamps every delta of a part // with the part's start time, so the event time cannot measure gaps. const spillChunk = yield* appendBufferedAssistantText( assistantMessageId, assistantDelta, + streamingMode, yield* Clock.currentTimeMillis, ); if (spillChunk.length > 0) { @@ -1807,15 +1819,9 @@ const make = Effect.gen(function* () { turnId: pauseForUserTurnId, streamingOnly: true, }); - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => - resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming - ? "streaming" - : "buffered", - ); + const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); const flushedMessageIds = - assistantDeliveryMode === "buffered" + streamingMode !== "token" ? yield* flushBufferedAssistantMessagesForTurn({ event, threadId: thread.id, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 67a20b31ecd2..a0d713f58bee 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -38,6 +38,7 @@ import { MIN_PANEL_ANIMATION_DURATION_MS, MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + type ResponseStreamingMode, MIN_TERMINAL_FONT_SIZE, type QuitConfirmationMode, } from "@t3tools/contracts/settings"; @@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { @@ -168,6 +178,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { + turn: "Wait for the full response", + paragraph: "Show finished paragraphs", + token: "Token by token (legacy)", +}; + +const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record = { + turn: "Text appears once the agent finishes its turn.", + paragraph: "Each paragraph or code block appears as soon as it is complete.", + token: "Every token repaints the message as it arrives. Slower and harder to read.", +}; + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -564,9 +586,8 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), - ...(settings.enableLegacyTokenStreaming !== - DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming - ? ["Stream token by token"] + ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode + ? ["Response streaming"] : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks @@ -640,7 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizeTerminal, settings.glassOpacity, settings.panelAnimationDurationMs, - settings.enableLegacyTokenStreaming, + settings.responseStreamingMode, settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, @@ -744,7 +765,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, + responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -797,6 +818,44 @@ export function useSettingsRestore(onRestored?: () => void) { }; } +/** + * Gate in front of the legacy token-by-token mode. The primary action steers + * the user to paragraph streaming; the legacy path is the quiet option. + */ +function TokenStreamingWarningDialog({ + open, + onOpenChange, + onConfirm, + onUseParagraphs, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onUseParagraphs: () => void; +}) { + return ( + + + + Token by token is a worse experience + + Token streaming repaints the message on every delta. It is slower, harder to read, and + costs more CPU on every connected device. This mode stays only for backwards + compatibility. Use paragraph streaming instead. + + + + + }>Cancel + + + + + ); +} + function BackgroundActivityAdvancedDialog({ open, onOpenChange, @@ -2014,7 +2073,6 @@ function AutoSettleDaysInput({ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-context-window-indicator", - "legacy-token-streaming", "legacy-sidebar", ]); @@ -2082,35 +2140,6 @@ function LegacyFeaturesSection() { /> } /> - { - if (!checked) { - updateSettings({ enableLegacyTokenStreaming: false }); - return; - } - void (async () => { - const api = readLocalApi(); - const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( - [ - "Turn on token-by-token output?", - "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", - ].join("\n"), - ); - if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); - })(); - }} - aria-label="Stream token by token (legacy)" - /> - } - /> 0; const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); + const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false); + const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -2387,6 +2418,76 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, + }) + } + /> + ) : null + } + control={ + <> + + { + updateSettings({ responseStreamingMode: "token" }); + setTokenStreamingWarningOpen(false); + }} + onUseParagraphs={() => { + updateSettings({ responseStreamingMode: "paragraph" }); + setTokenStreamingWarningOpen(false); + }} + /> + + } + /> { expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true); expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true); expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false); - const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!; + const streaming = getSettingsSearchTargetScope("response-streaming")!; expect(streaming.scope).toBe("project-defaults"); expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e6961badcfc5..1fe96d2b4df5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -261,6 +261,13 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"], }, + { + id: "response-streaming", + title: "Response streaming", + to: "/settings/general", + scope: "project-defaults", + searchTerms: ["output token paragraph buffered wait turn legacy"], + }, { id: "hide-whitespace-changes", title: "Hide whitespace changes", @@ -398,13 +405,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["composer meter usage tokens circle old"], }, - { - id: "legacy-token-streaming", - title: "Stream token by token (legacy)", - to: "/settings/general", - scope: "project-defaults", - searchTerms: ["response output old compatibility"], - }, { id: "legacy-sidebar", title: "Sidebar (legacy)", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 774a630ad44b..7b1fd2f96597 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -143,8 +143,6 @@ export const ProviderRequestKind = Schema.Literals([ "mcp-elicitation", ]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; -export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); -export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; export const ProviderApprovalDecision = Schema.Literals([ "accept", "acceptForSession", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9d3eaf877af8..1262a303ba41 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -952,6 +952,15 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; * background activity, theme. UI, search and the write planner derive * eligibility from this list, so adding a key here is the whole opt-in. */ +/** + * How assistant text reaches clients while a turn runs. + * - `turn`: hold the whole message until the turn finishes or pauses. + * - `paragraph`: deliver each finished paragraph or closed code block. + * - `token`: forward every provider delta. Legacy, kept for compatibility. + */ +export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]); +export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; + export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultModelSelection", "defaultRuntimeMode", @@ -968,7 +977,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", - "enableLegacyTokenStreaming", + "responseStreamingMode", ] as const; export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; @@ -993,16 +1002,17 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), - enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), + responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; export const ServerSettings = Schema.Struct({ - // Legacy token-by-token assistant output. Deliberately a fresh key (was + // How assistant text reaches clients during a turn. Deliberately a fresh + // key (was `enableLegacyTokenStreaming`, before that // `enableAssistantStreaming`): decoding drops the old key, so everyone, - // including prior opt-ins, resets to the buffered default. - enableLegacyTokenStreaming: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + // including prior token-streaming opt-ins, resets to the paragraph default. + responseStreamingMode: ResponseStreamingMode.pipe( + Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), ), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Retain the update-era key; recovery now needs an environment-owned opt-in. @@ -1337,7 +1347,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings - enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), + responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), From d81278aa6b272bb3d3314266c6d17f22504d19d6 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 14 Sep 2026 01:21:16 -0300 Subject: [PATCH 07/27] revert(web): remove the compact sidebar (#11685) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 - apps/web/src/components/AppSidebarLayout.tsx | 9 +- apps/web/src/components/LegacySidebar.tsx | 77 +- apps/web/src/components/Sidebar.drag.ts | 20 +- apps/web/src/components/Sidebar.tsx | 742 ++++-------------- .../src/components/ThreadStatusIndicators.tsx | 16 +- .../settings/CompactSidebarPreview.tsx | 82 -- .../components/settings/SettingsPanels.tsx | 80 -- .../settings/SettingsSidebarNav.tsx | 33 +- .../src/components/settings/settingsSearch.ts | 8 - .../src/components/sidebar/SidebarChrome.tsx | 14 +- .../sidebar/SidebarCompletedTime.test.tsx | 56 -- .../sidebar/SidebarCompletedTime.tsx | 16 - .../sidebar/SidebarThreadHeader.tsx | 31 +- .../components/sidebar/SidebarUpdatePill.tsx | 2 +- apps/web/src/components/ui/sidebar.tsx | 3 - apps/web/src/hooks/useSettings.ts | 7 - apps/web/src/timestampFormat.test.ts | 12 - apps/web/src/timestampFormat.ts | 8 +- apps/web/src/workspaceTitlebar.ts | 2 +- packages/contracts/src/settings.test.ts | 21 +- packages/contracts/src/settings.ts | 4 - 22 files changed, 208 insertions(+), 1036 deletions(-) delete mode 100644 apps/web/src/components/settings/CompactSidebarPreview.tsx delete mode 100644 apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx delete mode 100644 apps/web/src/components/sidebar/SidebarCompletedTime.tsx diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5ad23ee70582..53044dcf7e5d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -57,7 +57,6 @@ const clientSettings: ClientSettings = { proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarCompactThreadRows: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6497df68c8bd..6769586f7fa8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,11 +14,7 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { - useCompactSidebarEnabled, - useEnvironmentIdentificationMode, - useLegacySidebarEnabled, -} from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -148,7 +144,6 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); - const compactSidebarEnabled = useCompactSidebarEnabled(); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); // Settings routes show the settings nav in place of whichever thread @@ -234,7 +229,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); - const { isMobile, setOpenMobile, state, setOpen } = useSidebar(); - const compactSidebarEnabled = useCompactSidebarEnabled(); - const isCompact = compactSidebarEnabled && !isMobile && state === "collapsed"; + const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -1451,13 +1445,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); - if (isCompact) setOpen(true); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, [ clearSelection, - isCompact, - setOpen, dragInProgressRef, projectExpanded, projectPreferenceKeys, @@ -1474,17 +1465,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); - if (isCompact) setOpen(true); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, - [ - dragInProgressRef, - isCompact, - projectExpanded, - projectPreferenceKeys, - setOpen, - setProjectExpanded, - ], + [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -2376,10 +2359,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec <>
- {isCompact ? null : !projectExpanded && projectStatus ? ( + {!projectExpanded && projectStatus ? ( - + {project.displayName} @@ -2434,7 +2415,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {/* Environment badge – visible by default, crossfades with the "new thread" button on hover using the same pointer-events + opacity pattern as the thread row archive/timestamp swap. */} - {!isCompact && project.environmentPresence === "remote-only" && ( + {project.environmentPresence === "remote-only" && ( +
); } @@ -754,9 +705,6 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -795,34 +743,6 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }, [draftId, onDiscard], ); - if (compact) { - return ( -
  • - - - } - > - - - -
    {props.projectDisplayName}
    -
    {preview}
    -
    -
    -
  • - ); - } return (
  • = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; - compact: boolean; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle" | "unsnooze"; @@ -1113,9 +1032,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variant, variantAction, } = props; - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -1293,16 +1209,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={terminalProcessCount} - compactStatus={ - compact || (props.compact && variant === "card") - ? (topStatus?.label ?? - (variantAction === "unsnooze" - ? "Snoozed" - : variantAction === "unsettle" - ? "Settled" - : "Ready")) - : undefined - } /> ); @@ -1349,7 +1255,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [isRenaming, onStartRename, thread.title, threadRef], ); const [isFileDragOver, setIsFileDragOver] = useState(false); - const [tooltipOpen, setTooltipOpen] = useState(false); const fileDropHandlers = useMemo( () => onFileDropThreads @@ -1512,7 +1417,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: - sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0) + !sortable.isDragging && sortable.transform?.scaleY === 0 ? ("hidden" as const) : undefined, }, @@ -1584,21 +1489,18 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); - const renderPrBadge = (iconOnly: boolean, variant: "underline" | "badge" = "underline") => + const prBadge = prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( ) : null; - const hasPrBadge = prBadgeShape?.kind === "stack" || pr !== null || currentLinkedPr !== null; - const prBadge = renderPrBadge(false); const terminalStatusIcon = terminalStatus ? ( - - - } - > - - {props.project ? ( - - ) : driverKind ? ( - - ) : ( - - )} - {isRemote ? ( - - - } - > - - - - {props.environmentLabel ?? "Remote environment"} - - - ) : null} - {hasPrBadge ? ( - - {renderPrBadge(true, "badge")} - - ) : null} - - {topStatus ? ( - - ) : hasUnsentDraft ? ( - - ) : null} - {props.jumpLabel ? : null} - - {sortable?.isDragging ? ( - {dragDestination} - ) : ( - detailsTooltip - )} - -
  • - ); - } - if (variant === "slim") { return (
  • - + - + } > -
    -
    +
    +
    {draftIndicator} {props.project ? ( - - - {compactRows && isRemote ? ( - - - } - > - - - - {props.environmentLabel ?? "Remote environment"} - - - ) : null} - - ) : compactRows && isRemote ? ( - - - } - > - - - - {props.environmentLabel ?? "Remote environment"} - - + ) : null} - {compactRows ? ( - title - ) : props.projectDisplayName ? ( + {props.projectDisplayName ? ( )} {pinIndicator} - {compactRows ? ( - <> - {terminalStatusIcon} - {topStatus && CompactStatusIcon ? ( - isWokeStatus ? ( - - ) : ( - - - {topStatus.label} - - ) - ) : null} - {renderPrBadge(true)} - - ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -2067,35 +1766,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {sortable?.isDragging ? ( dragDestination ) : ( - + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} - {compactRows ? ( - status === "working" ? ( - - - - ) : compactCompletedAt ? ( - - ) : ( - threadTimeLabel(thread) - ) - ) : topStatus ? ( + {topStatus ? ( isWokeStatus ? ( - {compactRows ? null : "Settle"} + Settle Settle thread @@ -2211,70 +1895,68 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
    - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {compactRows ? null : ( - <> -
    {title}
    -
    - {/* Always the branch. The plan step used to take this slot while +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    +
    + {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - {thread.branch} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - - - ) : null} + {thread.branch ? ( + <> + + + {thread.branch} -
    - - )} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + +
    {props.jumpLabel ? : null} @@ -2308,9 +1990,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2396,7 +2075,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onClick={props.onSelect} className={cn( "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", - compact && "justify-center px-0", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2408,15 +2086,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { > {props.project ? ( - ) : compact ? ( - ) : null} - {thread.title} - + {thread.title} + {threadTimeLabel(thread)} @@ -2444,12 +2116,8 @@ export default function Sidebar() { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); - const { isMobile, setOpenMobile, setOpen, state: sidebarState } = useSidebar(); - const compactEnabled = useCompactSidebarEnabled(); - const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; - const [snoozedFooter, setSnoozedFooter] = useState(null); + const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -3020,7 +2688,6 @@ export default function Sidebar() { [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { - if (compact) return EMPTY_THREADS; if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( @@ -3028,7 +2695,7 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [routeThread]; - }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -3256,14 +2923,10 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback( - (threadRef: ScopedThreadRef, title: string) => { - if (compact) setOpen(true); - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, - [compact, setOpen], - ); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { @@ -3425,18 +3088,8 @@ export default function Sidebar() { const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback( - (args) => - restrictBelowSidebarLabel( - { - ...args, - // The fixed snoozed shelf shares the main list's drag boundary. - containerNodeRect: compact - ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect) - : args.containerNodeRect, - }, - dragLabelOffsetRef.current, - ), - [compact], + (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), + [], ); const listMotionRef = useRef | null>(null); const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { @@ -3658,7 +3311,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - (compact ? 0 : settledThreads.length) === + settledThreads.length === 0 ) { return []; @@ -3674,15 +3327,13 @@ export default function Sidebar() { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); } - if (!compact) { - items.push({ kind: "marker", marker: "settled-header" }); - items.push({ kind: "marker", marker: "settled-placeholder" }); - items.push(...rowsOf(renderedSettledThreads, "settled")); - } + items.push({ kind: "marker", marker: "settled-header" }); + const settledRows = rowsOf(renderedSettledThreads, "settled"); + items.push({ kind: "marker", marker: "settled-placeholder" }); + items.push(...settledRows); return items; }, [ activeThreads, - compact, pinnedThreads, renderedSettledThreads, settledThreads.length, @@ -3752,7 +3403,6 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, - compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3761,7 +3411,6 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ - compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3770,22 +3419,6 @@ export default function Sidebar() { snoozedThreads.length, ], ); - const draggingCompactSnoozed = compact && dragState?.activeSection === "snoozed"; - const compactSnoozedDragThread = - draggingCompactSnoozed && dragState ? threadByKey.get(dragState.activeKey) : undefined; - const compactSidebarSortingStrategy = useCallback( - (args) => { - const item = sidebarListItems[args.index]; - // Footer rows stay anchored while the main list previews a reorder. - if ( - item?.kind === "thread" ? item.section === "snoozed" : item?.marker === "snoozed-header" - ) { - return null; - } - return sidebarSortingStrategy(args); - }, - [sidebarListItems, sidebarSortingStrategy], - ); // Hidden and filtered threads keep their keys. Reserve those slots without // including the rows in the visible drop order or writing to them. const { pinnedKeysById, activeKeysById } = useMemo( @@ -4689,19 +4322,7 @@ export default function Sidebar() { <> 0 ? ( -
      - ) : null - } + className="gap-0 min-h-full" fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. @@ -4761,12 +4382,8 @@ export default function Sidebar() { // popup opens under the field, is at least as wide as it, // and grows to fit project names up to a cap, past which // the rows truncate. - anchor={compact ? undefined : headerSearchRef} - side={compact ? "right" : "bottom"} - className={cn( - "max-w-[min(18rem,var(--available-width))] overflow-hidden", - compact && "min-w-56", - )} + anchor={headerSearchRef} + className="max-w-[min(18rem,var(--available-width))] overflow-hidden" > } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( No threads found

      @@ -4948,23 +4557,20 @@ export default function Sidebar() { modifiers={[ restrictToVerticalAxis, restrictBelowPins, - ...(compact ? [] : [restrictToFirstScrollableAncestor]), + restrictToFirstScrollableAncestor, ]} onDragStart={handleThreadDragStart} onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > - +
        0 && "flex-1", + sidebarListItems.length > 0 && "flex-1", )} > {(() => { @@ -4976,9 +4582,10 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - // Settled and snoozed always use slim rows. Active and - // pinned threads use cards unless the user has explicitly - // enabled the compact thread-list preference. + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( @@ -4988,7 +4595,6 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} - compact={compactThreadRows} // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" @@ -5091,25 +4697,11 @@ export default function Sidebar() { !draggableThreadKeys.has(threadKey) || optimisticDrop !== null } > - {(bag) => - renderThreadRowInner( - thread, - section, - draggingCompactSnoozed && bag.isDragging - ? { ...bag, hidden: true } - : bag, - ) - } + {(bag) => renderThreadRowInner(thread, section, bag)} ); }; const from = dragState?.activeSection ?? null; - const showDragLabels = - from !== null && - (!compact || - dragTargetSection === "active" || - dragTargetSection === "pinned"); - const snoozedItems: ReactNode[] = []; const items: ReactNode[] = [ , ]; for (const item of sidebarListItems) { - const destination = - compact && - (item.kind === "thread" - ? item.section === "snoozed" - : item.marker === "snoozed-header") - ? snoozedItems - : items; if (item.kind === "thread") { - destination.push( - renderThreadRow(threadByKey.get(item.key)!, item.section), - ); + items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); continue; } switch (item.marker) { @@ -5141,7 +4724,7 @@ export default function Sidebar() { key="pinned-header" marker="pinned-header" label="Pinned" - visible={showDragLabels} + visible={from !== null} isDropTarget={dragTargetSection === "pinned"} />, ); @@ -5152,7 +4735,7 @@ export default function Sidebar() { key="pinned-divider" marker="pinned-divider" label="Active" - visible={showDragLabels} + visible={from !== null} isDropTarget={dragTargetSection === "active"} />, ); @@ -5176,11 +4759,11 @@ export default function Sidebar() { ); break; case "snoozed-header": - destination.push( + items.push( -
          - {renderThreadRowInner(compactSnoozedDragThread, "snoozed", { - isDragging: true, - listeners: undefined, - setNodeRef: () => {}, - transform: null, - transition: undefined, - })} -
        - , - document.body, - "compact-snoozed-drag", - ) - : null, - ]; + return items; })()} - {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? ( + {settledShelfExpanded && hiddenSettledCount > 0 ? (
      • - - - } - > - - - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - - - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - +
      • ) : null}
      @@ -5297,29 +4842,20 @@ export default function Sidebar() { snoozedThreads.length + settledThreads.length === 0 ? ( -
      +
      {projects.length === 0 ? ( <> - No projects yet + No projects yet - ) : compact ? null : scopedProjectGroup ? ( + ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 4b26d0442021..7201779f13f1 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -144,17 +144,14 @@ export function ThreadPullRequestBadgeControl({ number, url, status, - iconOnly = false, onOpenStack, onOpenPullRequest, }: { - variant: "underline" | "ghost" | "badge"; + variant: "underline" | "ghost"; badge: ThreadPullRequestBadge | null; number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; - /** Dense rows drop the number/layer count and keep only the state glyph. */ - iconOnly?: boolean; onOpenStack: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { @@ -171,9 +168,7 @@ export function ThreadPullRequestBadgeControl({ const className = cn( variant === "ghost" ? buttonVariants({ variant: "ghost", size: "xs" }) - : variant === "badge" - ? "inline-flex size-3 shrink-0 cursor-pointer items-center justify-center rounded-full bg-sidebar ring-1 ring-sidebar outline-none focus-visible:ring-2 focus-visible:ring-ring" - : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", + : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", "text-xs tabular-nums", variant === "ghost" && "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", @@ -183,11 +178,8 @@ export function ThreadPullRequestBadgeControl({ ); const content = ( <> - - {iconOnly ? null : isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} + + {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} ); return ( diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx deleted file mode 100644 index 4d1e861d14e7..000000000000 --- a/apps/web/src/components/settings/CompactSidebarPreview.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -import { cn } from "~/lib/utils"; - -export function CompactSidebarPreview({ - railEnabled, - compactRows, -}: { - railEnabled: boolean; - compactRows: boolean; -}) { - const [collapsed, setCollapsed] = useState(false); - const sidebarRef = useRef(null); - - useEffect(() => { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - const animation = sidebarRef.current?.animate( - [ - { width: "36px", offset: 0 }, - { width: railEnabled ? "12px" : "0px", offset: 0.45 }, - { width: railEnabled ? "12px" : "0px", offset: 0.6 }, - { width: "36px", offset: 1 }, - ], - { duration: 800, easing: "ease-in-out" }, - ); - return () => animation?.cancel(); - }, [railEnabled, compactRows]); - - return ( - - ); -} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a0d713f58bee..6baa561a91fe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -170,7 +170,6 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; -import { CompactSidebarPreview } from "./CompactSidebarPreview"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -524,10 +523,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(theme !== "system" ? ["Theme"] : []), ...(!followSystem ? ["Follow system"] : []), ...(themeHalves !== null ? ["Theme mix"] : []), - ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled || - settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows - ? ["Compact sidebar"] - : []), ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? ["Contrast"] : []), @@ -634,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, - settings.compactSidebarEnabled, settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, @@ -666,7 +660,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, - settings.sidebarCompactThreadRows, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -744,7 +737,6 @@ export function useSettingsRestore(onRestored?: () => void) { } updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, - compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, @@ -762,7 +754,6 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, @@ -1143,19 +1134,6 @@ export function AppearanceSettingsPanel() { const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); - const compactSidebarMode = settings.compactSidebarEnabled - ? settings.sidebarCompactThreadRows - ? "both" - : "rail" - : settings.sidebarCompactThreadRows - ? "threads" - : "off"; - const compactSidebarModes = { - off: "Off", - rail: "Rail only", - threads: "Threads only", - both: "Both", - }; const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1442,64 +1420,6 @@ export function AppearanceSettingsPanel() { /> - - - updateSettings({ - compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, - sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, - }) - } - /> - ) : null - } - control={ -
      - - -
      - } - /> -
      - ); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index c206174a397a..c29068934efa 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -24,7 +24,6 @@ import { XIcon, } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useCompactSidebarEnabled } from "../../hooks/useSettings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -112,13 +111,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), ); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); - const compactSidebarEnabled = useCompactSidebarEnabled(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); - const isSearching = query.trim().length > 0 && !(compactSidebarEnabled && !isMobile && !open); + const isSearching = query.trim().length > 0; const hasResults = results.length > 0; useEffect(() => { @@ -235,18 +233,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { <> - { - setOpen(true); - requestAnimationFrame(() => searchInputRef.current?.focus()); - }} - > - - -
      +
      handleSectionClick(item.to)} > - - {item.label} - + {item.label} ); @@ -360,12 +343,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { -
      - - - -
      -
      + + + +
      diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1fe96d2b4df5..837aa7b47ce3 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -159,14 +159,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Panel animations", to: "/settings/appearance", }, - { - id: "compact-sidebar", - title: "Compact sidebar", - to: "/settings/appearance", - searchTerms: [ - "collapsed icons rail hover navigation preview expanded dense density one line rows chats threads compact thread list", - ], - }, { id: "environment-identification", title: "Environment identification", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 2f65cf8c3697..afbbf7671dfc 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + {currentFooterPage ? ( - + - Back + Back ) : ( @@ -224,10 +224,8 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( -
      - - -
      + +
      ); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx deleted file mode 100644 index 7327af79e794..000000000000 --- a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { act, memo } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { SidebarCompletedTime } from "./SidebarCompletedTime"; - -let renderer: ReactTestRenderer | undefined; - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-07T01:01:00Z")); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", { - setTimeout, - clearTimeout, - setInterval, - clearInterval, - }); -}); - -afterEach(async () => { - await act(() => renderer?.unmount()); - renderer = undefined; - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); - -it("advances visible and accessible completion times without rerendering its memoized row", async () => { - const rowRender = vi.fn(); - const Row = memo(function Row() { - rowRender(); - return ; - }); - await act(() => { - renderer = create(); - }); - expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z"); - expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); - expect( - renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]), - ).toHaveLength(0); - expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ - "1m", - ]); - - await act(() => vi.advanceTimersByTime(60_000)); - - expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); - expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ - "2m", - ]); - expect(rowRender).toHaveBeenCalledTimes(1); - await act(() => renderer!.unmount()); - renderer = undefined; - expect(vi.getTimerCount()).toBe(0); -}); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx deleted file mode 100644 index 785b8d9459d2..000000000000 --- a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useNowMinute } from "../../hooks/useNowMinute"; -import { formatRelativeTimeLabel } from "../../timestampFormat"; - -export function SidebarCompletedTime({ completedAt }: { completedAt: string }) { - // Subscribe inside the label so time advances even when the row is memoized. - const nowMinute = useNowMinute(); - const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`)); - const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, ""); - - return ( - - ); -} diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index d0718d9856f8..878235615b39 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,10 +20,9 @@ import { } from "react"; import { cn } from "~/lib/utils"; -import { useCompactSidebarEnabled } from "../../hooks/useSettings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SidebarMenuButton, useSidebar } from "../ui/sidebar"; +import { SidebarMenuButton } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface SidebarThreadHeaderProps { @@ -70,9 +69,6 @@ export function SidebarThreadHeader({ activeSearchResultIndex, onClearSearch, }: SidebarThreadHeaderProps) { - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile, setOpen } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const resultsVisible = isSearching && searchResultCount > 0; // Results shrink as the query narrows, so the active index can outrun the // list; pointing aria-activedescendant at a removed option strands the @@ -83,24 +79,10 @@ export function SidebarThreadHeader({ : "New thread"; return ( -
      - {compact ? ( - { - setOpen(true); - requestAnimationFrame(() => searchInputRef.current?.focus()); - }} - > - - - ) : null} +
      {/* Segmented well: the icons read as one control instead of three loose buttons competing with the search field beside them. */} -
      +
      {hasProjects ? ( <> {projectScope} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 8c04eec6fe7d..a94b7801ecfd 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -348,7 +348,7 @@ function SidebarUpdateControl() { ); return ( - + { diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 404295f5f5c1..307feda7abfc 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -591,11 +591,9 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; - fixedFooter?: React.ReactNode; }) { return ( <> @@ -619,7 +617,6 @@ function SidebarContent({ {...props} /> - {fixedFooter ?
      {fixedFooter}
      : null} ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fa4b8bc8fd31..194cc36c55f4 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -379,13 +379,6 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } -/** Keep the default collapsed sidebar until persisted client settings hydrate. */ -export function useCompactSidebarEnabled(): boolean { - const settingsHydrated = useClientSettingsHydrated(); - const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled; - return settingsHydrated && compactSidebarEnabled; -} - /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 5169e438bb2d..8c6287010d8f 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -228,15 +228,3 @@ describe("formatElapsedDurationLabel", () => { expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d"); }); }); - -describe("explicit relative-time clock", () => { - it("uses the supplied minute instead of the wall clock", () => { - const completedAt = "2026-09-07T01:00:00Z"; - expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:01:00Z"))).toBe("1m ago"); - expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toBe("2m ago"); - expect(formatRelativeTime(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toEqual({ - value: "2m", - suffix: "ago", - }); - }); -}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 983a9bb8b232..9dd463bb50fa 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -196,10 +196,10 @@ export type RelativeTimeState = | { status: "invalid" } | { status: "relative"; value: string; suffix: string | null }; -export function formatRelativeTime(isoDate: string, nowMs = Date.now()): RelativeTimeParts | null { +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); if (!date) return null; - const diffMs = nowMs - date.getTime(); + const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; @@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string, nowMs = Date.now()): Relativ return { value: `${days}d`, suffix: "ago" }; } -export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) { - const relative = formatRelativeTime(isoDate, nowMs); +export function formatRelativeTimeLabel(isoDate: string) { + const relative = formatRelativeTime(isoDate); if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts index aed95897cc55..b481221e63aa 100644 --- a/apps/web/src/workspaceTitlebar.ts +++ b/apps/web/src/workspaceTitlebar.ts @@ -1,2 +1,2 @@ export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = - "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)] [[data-sidebar-state=collapsed]:has([data-side=left][data-collapsible=icon])_&]:pl-[max(calc(env(safe-area-inset-left)+1.25rem),calc(var(--workspace-titlebar-content-left)-var(--sidebar-width-icon)))]"; + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c8c6922ec020..b090b47fba5b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -482,18 +482,7 @@ describe("ClientSettings environment identification", () => { describe("ClientSettings sidebar", () => { it("defaults to the current sidebar", () => { - const settings = decodeClientSettings({}); - expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarCompactThreadRows).toBe(false); - }); - - it("preserves an explicit compact thread row preference", () => { - expect(decodeClientSettings({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows).toBe( - true, - ); - expect( - decodeClientSettingsPatch({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows, - ).toBe(true); + expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -506,6 +495,14 @@ describe("ClientSettings sidebar", () => { expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser"); }); + it("drops the retired compact sidebar keys for users who opted in", () => { + const stored = { compactSidebarEnabled: true, sidebarCompactThreadRows: true }; + const decoded = decodeClientSettings(stored); + expect(decoded).not.toHaveProperty("compactSidebarEnabled"); + expect(decoded).not.toHaveProperty("sidebarCompactThreadRows"); + expect(decodeClientSettingsPatch(stored)).toEqual({}); + }); + it("preserves an explicit legacy sidebar opt-in", () => { expect(decodeClientSettings({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(true); expect(decodeClientSettingsPatch({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1262a303ba41..7b3715d704be 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -437,7 +437,6 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - compactSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -451,7 +450,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), - sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), @@ -1504,14 +1502,12 @@ export const ClientSettingsPatch = Schema.Struct({ proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), - compactSidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), - sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), From 683aa87096899aa191e150429415ecc78966f538 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:20 -0700 Subject: [PATCH 08/27] build(desktop): bundle the main process and stage only its native externals (#11410) Co-authored-by: Claude Fable 5 --- apps/desktop/vite.config.ts | 13 ++++- scripts/build-desktop-artifact.test.ts | 65 +++++++++++++++++----- scripts/build-desktop-artifact.ts | 71 ++++++++++++++---------- scripts/lib/desktop-external-packages.ts | 38 +++++++++++++ 4 files changed, 142 insertions(+), 45 deletions(-) create mode 100644 scripts/lib/desktop-external-packages.ts diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 89c11fe6e18c..f3ec31ed34d9 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,9 +1,18 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); + +// The main process is bundled the same way the server CLI is: every JS +// dependency is inlined and only packages Node must load from disk stay +// external. The packaged app then installs just those externals, instead of a +// full production install of apps/desktop's dependency tree next to a server +// bundle that already carries its own copy of the same libraries. +const isMainProcessExternal = (id: string) => + id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( @@ -55,7 +64,9 @@ export default defineConfig({ ], clean: true, deps: { - alwaysBundle: (id) => id.startsWith("@t3tools/"), + alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), + neverBundle: isMainProcessExternal, + onlyBundle: false, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), }, diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b601d3997fd7..04be06755750 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -28,6 +28,7 @@ import { DESKTOP_EXTRA_RESOURCES, LINUX_CAPTURE_EXTRA_RESOURCES, LINUX_BROWSER_SECRET_EXTRA_RESOURCES, + LINUX_FILE_EXCLUSIONS, MAC_FILE_EXCLUSIONS, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, @@ -47,7 +48,7 @@ import { resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, - resolveMacStageDependencies, + resolveMergedStageDependencies, resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, @@ -367,26 +368,37 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); - it("omits bundled workspace packages from staged desktop dependencies", () => { + it("stages only the desktop main-process externals", () => { assert.deepStrictEqual( resolveDesktopRuntimeDependencies( { + "@clerk/electron": "catalog:", + "@clerk/electron-passkeys": "catalog:", + "@crowecawcaw/xa11y": "0.13.0", "@effect/platform-node": "catalog:", + "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "@t3tools/ssh": "workspace:*", - "@t3tools/tailscale": "workspace:*", + "dbus-next": "0.10.2", effect: "catalog:", electron: "41.5.0", + "electron-updater": "^6.6.2", + "ffi-rs": "1.3.2", + "playwright-core": "1.60.0", }, { + "@clerk/electron": "0.0.37", + "@clerk/electron-passkeys": "0.0.3", "@effect/platform-node": "4.0.0-beta.59", effect: "4.0.0-beta.59", }, ), { - "@effect/platform-node": "4.0.0-beta.59", - effect: "4.0.0-beta.59", + "@clerk/electron-passkeys": "0.0.3", + "@crowecawcaw/xa11y": "0.13.0", + "@napi-rs/keyring": "^1.3.0", + "ffi-rs": "1.3.2", + "playwright-core": "1.60.0", }, ); }); @@ -559,6 +571,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + "!**/*.map", + "!**/*.d.cts", "!apps/desktop/resources/browser-secret", "!apps/desktop/resources/browser-secret/**/*", "!apps/desktop/prod-resources/browser-secret", @@ -661,6 +675,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", "**/node_modules/.bin", "**/node_modules/.bin/**", + "**/*.map", ]); assert.deepStrictEqual(mac.dmg, { title: "T3 Code (Alpha) 1.2.3 Installer", @@ -679,7 +694,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); - assert.deepStrictEqual(linux.files, DESKTOP_FILE_EXCLUSIONS); + assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files); assert.notProperty(mac.mac as Record, "sign"); @@ -690,11 +705,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); - it("excludes Windows terminal binaries only from macOS packages", () => { + it("excludes foreign node-pty prebuilds from macOS and Linux packages", () => { assert.deepStrictEqual(MAC_FILE_EXCLUSIONS, [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ]); + assert.deepStrictEqual(LINUX_FILE_EXCLUSIONS, [ + ...MAC_FILE_EXCLUSIONS, + "!**/node_modules/node-pty/prebuilds/darwin-*/**/*", + ]); }); it("unpacks native binaries while keeping their JavaScript and metadata archived", () => { @@ -725,9 +744,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { } }); - it("stages only server runtime externals in macOS packages", () => { + it("stages only the externals of both bundles in merged packages", () => { assert.deepStrictEqual( - resolveMacStageDependencies({ + resolveMergedStageDependencies({ + platform: "mac", serverDependencies: { "@anthropic-ai/claude-agent-sdk": "^0.3.170", "@ff-labs/fff-node": "0.9.4", @@ -737,8 +757,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "node-pty": "1.1.0", }, desktopDependencies: { - "@clerk/electron": "0.0.34", - effect: "4.0.0-beta.103", + "@napi-rs/keyring": "1.3.0", + "playwright-core": "1.60.0", }, arch: "arm64", fffNodeVersion: "0.9.4", @@ -747,11 +767,28 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "@ff-labs/fff-node": "0.9.4", "msgpackr-extract": "3.0.4", "node-pty": "1.1.0", - "@clerk/electron": "0.0.34", - effect: "4.0.0-beta.103", + "@napi-rs/keyring": "1.3.0", + "playwright-core": "1.60.0", "@ff-labs/fff-bin-darwin-arm64": "0.9.4", }, ); + + assert.deepStrictEqual( + resolveMergedStageDependencies({ + platform: "linux", + serverDependencies: { "@ff-labs/fff-node": "0.9.4", "node-pty": "1.1.0", effect: "4.0.0" }, + desktopDependencies: { "@crowecawcaw/xa11y": "0.13.0" }, + arch: "x64", + fffNodeVersion: "0.9.4", + }), + { + "@ff-labs/fff-node": "0.9.4", + "node-pty": "1.1.0", + "@crowecawcaw/xa11y": "0.13.0", + "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", + "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", + }, + ); }); it("excludes node-pty binaries for the other Windows architecture", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 44d5f0ab12f4..2069b0c836ad 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -34,6 +34,7 @@ import { selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; +import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -959,6 +960,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // so the SDK's optional platform packages (each a ~200MB bundled executable) // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + // Nothing in the packaged app enables source maps or serves them: the web + // client's maps alone were 50 MB of app.asar that no request ever read. + "!**/*.map", + "!**/*.d.cts", "!apps/desktop/resources/browser-secret", "!apps/desktop/resources/browser-secret/**/*", "!apps/desktop/prod-resources/browser-secret", @@ -978,6 +983,12 @@ export const MAC_FILE_EXCLUSIONS = [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ] as const; +// Linux builds node-pty from source, so every prebuild in the package is for +// another platform (58 MB of it Windows debug symbols). +export const LINUX_FILE_EXCLUSIONS = [ + ...MAC_FILE_EXCLUSIONS, + "!**/node_modules/node-pty/prebuilds/darwin-*/**/*", +] as const; // node-pty publishes both Darwin prebuilds in one package. Single-architecture // apps only need the native target; universal apps need both. An omitted arch @@ -1013,6 +1024,7 @@ export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", "**/node_modules/.bin", "**/node_modules/.bin/**", + "**/*.map", ] as const; export function resolveWindowsServerAsarIgnoreGlobs(arch: typeof BuildArch.Type) { @@ -1357,7 +1369,10 @@ export function resolveFffNativeDependencies( ); } -export function resolveMacStageDependencies(input: { +// macOS and Linux run both processes from one app.asar, so the stage installs +// the union of what each bundle leaves external and nothing else. +export function resolveMergedStageDependencies(input: { + readonly platform: "mac" | "linux"; readonly serverDependencies: Record; readonly desktopDependencies: Record; readonly arch: typeof BuildArch.Type; @@ -1366,7 +1381,7 @@ export function resolveMacStageDependencies(input: { return { ...selectCliRuntimeExternalDependencies(input.serverDependencies), ...input.desktopDependencies, - ...resolveFffNativeDependencies("mac", input.arch, input.fffNodeVersion), + ...resolveFffNativeDependencies(input.platform, input.arch, input.fffNodeVersion), }; } @@ -2551,6 +2566,11 @@ function validateBundledClientAssets(clientDir: string) { }); } +// The main-process bundle inlines every JS dependency (see +// apps/desktop/vite.config.ts), so the packaged app only installs the packages +// that bundle leaves external: native addons and playwright-core. Everything +// else already lives inside dist-electron and would only duplicate what the +// server bundle carries too. export function resolveDesktopRuntimeDependencies( dependencies: Record | undefined, catalog: Record, @@ -2559,14 +2579,11 @@ export function resolveDesktopRuntimeDependencies( return {}; } - const runtimeDependencies = Object.fromEntries( - Object.entries(dependencies).filter( - ([dependencyName, dependencySpec]) => - dependencyName !== "electron" && !dependencySpec.startsWith("workspace:"), - ), + return resolveCatalogDependencies( + selectDesktopRuntimeExternalDependencies(dependencies), + catalog, + "apps/desktop", ); - - return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); } export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( @@ -2672,7 +2689,11 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], files: [ ...DESKTOP_FILE_EXCLUSIONS, - ...(platform === "mac" ? resolveMacFileExclusions(arch) : []), + ...(platform === "mac" + ? resolveMacFileExclusions(arch) + : platform === "linux" + ? LINUX_FILE_EXCLUSIONS + : []), ], directories: { buildResources: "apps/desktop/resources", @@ -3735,29 +3756,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } // Windows splits dependencies per process: app.asar carries only the - // desktop main-process runtime deps, while the server bundle's deps live in - // the server.asar sidecar (see stageWindowsServerSidecar). macOS adds only - // server packages that remain external to its merged app.asar. Linux retains - // its existing full dependency tree. + // desktop main-process externals, while the server bundle's externals live + // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and + // Linux merge both sets into one app.asar. const stageDependencies = options.platform === "win" ? { ...resolvedDesktopRuntimeDependencies } - : options.platform === "mac" - ? resolveMacStageDependencies({ - serverDependencies: resolvedServerDependencies, - desktopDependencies: resolvedDesktopRuntimeDependencies, - arch: options.arch, - fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], - }) - : { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), - }; + : resolveMergedStageDependencies({ + platform: options.platform, + serverDependencies: resolvedServerDependencies, + desktopDependencies: resolvedDesktopRuntimeDependencies, + arch: options.arch, + fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], + }); const stagePatchedDependencies = createStagePatchedDependencies( workspacePatchedDependencies, stageDependencies, diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts new file mode 100644 index 000000000000..919397a684d1 --- /dev/null +++ b/scripts/lib/desktop-external-packages.ts @@ -0,0 +1,38 @@ +/** + * Packages the desktop main-process bundle must NOT inline. + * + * The desktop bundle follows the same policy as the server CLI bundle (see + * cli-external-packages.ts): everything is inlined except what Node has to + * load from the real filesystem. Both `apps/desktop/vite.config.ts` and the + * artifact stage in scripts/build-desktop-artifact.ts derive from this list, + * so a package that is external is also the only kind of package the staged + * production install carries. Anything not listed here ships inside + * `dist-electron/*.cjs` and has no `node_modules` presence at all. + * + * Entries are matched as prefixes so platform-specific siblings are covered. + */ +export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ + // Native addons and the wrappers that dlopen them by real path. + "@napi-rs/keyring", + "@crowecawcaw/xa11y", + "@clerk/electron-passkeys", + "ffi-rs", + "@yuuang/", + // Reads its own bundle from disk by resolving `playwright-core/package.json` + // at runtime and ships the browser driver alongside; there is nothing to + // gain from inlining a 10 MB file the code re-reads as text. + "playwright-core", +] as const; + +export function isDesktopRuntimeExternalDependency(id: string): boolean { + return DESKTOP_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + +/** Select the desktop dependency roots whose runtime closure the stage must install. */ +export function selectDesktopRuntimeExternalDependencies( + dependencies: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(dependencies).filter(([name]) => isDesktopRuntimeExternalDependency(name)), + ); +} From 06de59b3d67e5be427ce4c7c8d41e91265709aec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:20 -0700 Subject: [PATCH 09/27] build(server): make the CLI bundle loadable as a Node single-executable (#11316) Co-authored-by: Claude Fable 5 --- .gitignore | 1 + apps/server/package.json | 4 +- apps/server/scripts/cli.ts | 50 +++++- apps/server/scripts/cliErrors.ts | 12 ++ apps/server/src/persistence/Errors.test.ts | 19 --- apps/server/src/persistence/Errors.ts | 11 +- apps/server/src/persistence/Layers/Sqlite.ts | 28 +--- apps/server/src/server.ts | 83 +++------- .../server/src/terminal/BunPtyAdapter.test.ts | 44 ----- apps/server/src/terminal/BunPtyAdapter.ts | 155 ------------------ .../src/terminal/NodePtyAdapter.test.ts | 8 +- apps/server/src/terminal/NodePtyAdapter.ts | 22 ++- .../src/workspace/WorkspaceSearchIndex.ts | 31 ++-- apps/server/vite.config.ts | 27 ++- packages/shared/src/nodeSqliteClient.ts | 12 -- patches/@ff-labs__fff-node@0.9.4.patch | 14 ++ pnpm-lock.yaml | 33 +--- pnpm-workspace.yaml | 6 - scripts/lib/cli-external-packages.test.ts | 45 ++++- scripts/lib/cli-external-packages.ts | 57 ++++--- 20 files changed, 255 insertions(+), 407 deletions(-) delete mode 100644 apps/server/src/terminal/BunPtyAdapter.test.ts delete mode 100644 apps/server/src/terminal/BunPtyAdapter.ts diff --git a/.gitignore b/.gitignore index 79f8735b25e5..8482c5a290e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.log *.tsbuildinfo apps/*/dist +apps/*/dist-exe infra/*/dist .astro packages/*/dist diff --git a/apps/server/package.json b/apps/server/package.json index 9b5f2fb2d7d7..1892f7f3d6d7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,16 +17,15 @@ "scripts": { "dev": "node --watch src/bin.ts", "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", + "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", "typecheck": "tsc --noEmit", "test": "vp test run" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.260", - "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", - "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "effect": "catalog:", @@ -44,7 +43,6 @@ "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", - "@types/bun": "1.3.14", "@types/node": "catalog:", "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 2de5b702a286..f77cff2c4c48 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -15,6 +15,7 @@ import { resolveWebAssetBrandForPackageVersion, resolveWebIconOverrides, } from "../../../scripts/lib/brand-assets.ts"; +import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; @@ -25,6 +26,7 @@ import { ServerCliCommandExitError, ServerCliDevelopmentIconSourceMissingError, ServerCliDevelopmentIconTargetMissingError, + ServerCliExecutableImportError, ServerCliPublishIconSourceMissingError, ServerCliPublishIconTargetMissingError, } from "./cliErrors.ts"; @@ -175,6 +177,52 @@ const buildCmd = Command.make( }), ).pipe(Command.withDescription("Build the server package (tsdown + bundle web client).")); +// --------------------------------------------------------------------------- +// build-exe subcommand +// --------------------------------------------------------------------------- + +const buildExeCmd = Command.make( + "build-exe", + { + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), + }, + (config) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repoRoot = yield* RepoRoot; + const serverDir = path.join(repoRoot, "apps/server"); + + yield* Effect.log("[cli] Building single-executable..."); + const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: serverDir, + env: { ...process.env, T3CODE_PACK_EXE: "1" }, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + shell: spawnCommand.shell, + }), + ); + + // The executable can only `import` built-ins. A file-backed import + // passes the bundler and `node dist/bin.mjs`, then throws inside the + // binary, so read the emitted module graph rather than trusting config. + const bundlePath = path.join(serverDir, "dist-exe/bin.mjs"); + const specifiers = findEsmImportsOfExternalPackages(yield* fs.readFileString(bundlePath)); + if (specifiers.length > 0) { + return yield* new ServerCliExecutableImportError({ bundlePath, specifiers }); + } + yield* Effect.log( + "[cli] Built dist-exe/t3 (expects client/, resource-monitor/, and the runtime-external node_modules beside it; scripts/build-cli-archive.ts assembles that tree)", + ); + }), +).pipe( + Command.withDescription( + "Build the server as a Node single-executable (needs a Node 25.7+ host for --build-sea). The binary still resolves native packages from a node_modules tree beside it.", + ), +); + // --------------------------------------------------------------------------- // publish subcommand // --------------------------------------------------------------------------- @@ -309,7 +357,7 @@ const publishCmd = Command.make( const cli = Command.make("cli").pipe( Command.withDescription("T3 server build & publish CLI."), - Command.withSubcommands([buildCmd, publishCmd]), + Command.withSubcommands([buildCmd, buildExeCmd, publishCmd]), ); Command.run(cli, { version: "0.0.0" }).pipe( diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index d2a410a6e0f3..ce4bb6c2f8eb 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedError()( + "ServerCliExecutableImportError", + { + bundlePath: Schema.String, + specifiers: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `${this.bundlePath} imports file-backed packages that a single-executable cannot resolve: ${this.specifiers.join(", ")}. Load them through createRequire instead.`; + } +} diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts index bd3e5128b1f1..dfa35020df33 100644 --- a/apps/server/src/persistence/Errors.test.ts +++ b/apps/server/src/persistence/Errors.test.ts @@ -3,7 +3,6 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts"; @@ -55,24 +54,6 @@ it("reads the condition through a wrapping driver error", () => { assert.equal(error.detail, "SQLITE(5) database is locked"); }); -it.each([{ errno: 1555, code: "SQLITE_CONSTRAINT_PRIMARYKEY" }, { errno: 1 }])( - "names Bun SQLite condition $errno through the SQL error wrapper", - (condition) => { - const driver = Object.assign(new Error("bun-sql-private-sentinel"), { - name: "SQLiteError", - ...condition, - }); - const cause = new SqlError({ reason: classifySqliteError(driver) }); - const error = toPersistenceSqlError("AuthSessionRepository.create:query")(cause); - - assert.equal( - error.message, - `SQL error in AuthSessionRepository.create:query: SQLITE(${condition.errno})`, - ); - assert.equal(error.cause, cause); - }, -); - it.each([ new Error("unhelpful"), Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }), diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 7d6cf1701951..2715bf65a7b2 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -75,7 +75,7 @@ const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); /** * Read a SQLite condition through SQL error wrappers. - * Use Node's fixed description or Bun's numeric code, never the driver message. + * Use node:sqlite's fixed description, never the driver message. */ function sqliteCondition(cause: unknown): string | undefined { let value = cause; @@ -88,15 +88,6 @@ function sqliteCondition(cause: unknown): string | undefined { ) { return `SQLITE(${value.errcode}) ${value.errstr}`; } - if ( - "name" in value && - value.name === "SQLiteError" && - "errno" in value && - typeof value.errno === "number" && - Number.isInteger(value.errno) - ) { - return `SQLITE(${value.errno})`; - } value = "cause" in value ? value.cause : undefined; } return undefined; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 41d8f5baf3fd..88342cbf1fad 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -3,33 +3,11 @@ import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import type { SqlError } from "effect/unstable/sql/SqlError"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; -type RuntimeSqliteLayerConfig = { - readonly filename: string; - readonly spanAttributes?: Record; -}; - -type Loader = { - layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; -}; -const defaultSqliteClientLoaders = { - bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), - node: () => import("@t3tools/shared/nodeSqliteClient"), -} satisfies Record Promise>; - -const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( - config: RuntimeSqliteLayerConfig, -) { - const runtime = process.versions.bun !== undefined ? "bun" : "node"; - const loader = defaultSqliteClientLoaders[runtime]; - const clientModule = yield* Effect.promise(loader); - return clientModule.layer(config); -}, Layer.unwrap); - const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -50,7 +28,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( return Layer.provideMerge( setup, - makeRuntimeSqliteLayer({ + NodeSqliteClient.layer({ filename: dbPath, spanAttributes: { "db.name": path.basename(dbPath), @@ -62,7 +40,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( export const SqlitePersistenceMemory = Layer.provideMerge( setup, - makeRuntimeSqliteLayer({ filename: ":memory:" }), + NodeSqliteClient.layer({ filename: ":memory:" }), ); export const layerConfig = Layer.unwrap( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b8c3ca6096e9..6fdbda5ce54c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, ProviderDriverKind, @@ -29,6 +34,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -163,17 +169,7 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe( Layer.provideMerge(ResourceAttributionLayerLive), ); -const PtyAdapterLive = Layer.unwrap( - Effect.gen(function* () { - if (typeof Bun !== "undefined") { - const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts")); - return BunPtyAdapter.layer; - } else { - const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts")); - return NodePtyAdapter.layer; - } - }), -); +const PtyAdapterLive = NodePtyAdapter.layer; const ServerSettingsLayerLive = ServerSettings.layer.pipe( Layer.provide(ServerSecretStore.layer), @@ -226,61 +222,22 @@ const RelayClientLive = Layer.unwrap( const HttpServerLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - if (typeof Bun !== "undefined") { - const BunHttpServer = yield* Effect.promise( - () => import("@effect/platform-bun/BunHttpServer"), - ); - return BunHttpServer.layer({ - port: config.port, - hostname: config.host ?? "127.0.0.1", - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - websocket: { - // Negotiate permessage-deflate with clients that offer it; clients - // that don't still get uncompressed frames on their connection. A - // dedicated compressor keeps a per-connection sliding window - // (context takeover) so the compression dictionary is shared across - // server-to-client frames. Decompression uses the shared - // decompressor: uWebSockets' dedicated decompressor path can abort - // connections (close 1006) on valid DEFLATE input — see - // https://github.com/uNetworking/uWebSockets.js/issues/633. - perMessageDeflate: { - compress: "dedicated", - decompress: "shared", - }, - }, - }); - } else { - const [NodeHttpServer, NodeHttp] = yield* Effect.all([ - Effect.promise(() => import("@effect/platform-node/NodeHttpServer")), - Effect.promise(() => import("node:http")), - ]); - return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { - host: config.host ?? "127.0.0.1", - port: config.port, - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - // Negotiate permessage-deflate with clients that offer it; clients - // that don't still get uncompressed frames on their connection. - // Context takeover stays enabled (ws default) so the compression - // window is shared across frames — that also makes small frames cheap - // to compress, so no size threshold is set (ws only honors - // `threshold` when context takeover is disabled). - websocket: { perMessageDeflate: true }, - }); - } + return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { + host: config.host ?? "127.0.0.1", + port: config.port, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + // Negotiate permessage-deflate with clients that offer it; clients + // that don't still get uncompressed frames on their connection. + // Context takeover stays enabled (ws default) so the compression + // window is shared across frames — that also makes small frames cheap + // to compress, so no size threshold is set (ws only honors + // `threshold` when context takeover is disabled). + websocket: { perMessageDeflate: true }, + }); }), ); -const PlatformServicesLive = Layer.unwrap( - Effect.gen(function* () { - if (typeof Bun !== "undefined") { - const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices")); - return layer; - } else { - const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices")); - return layer; - } - }), -); +const PlatformServicesLive = NodeServices.layer; const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts deleted file mode 100644 index e04a54e6d333..000000000000 --- a/apps/server/src/terminal/BunPtyAdapter.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { assert, expect, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; - -import * as BunPtyAdapter from "./BunPtyAdapter.ts"; - -it("describes unavailable Bun PTY operations structurally", () => { - const error = new BunPtyAdapter.BunPtyOperationUnavailableError({ - operation: "resize", - pid: 42, - }); - - expect(error).toMatchObject({ - _tag: "BunPtyOperationUnavailableError", - operation: "resize", - pid: 42, - }); - expect(error.message).toBe("Bun PTY resize is unavailable for process 42."); -}); - -it.effect("reports unsupported platforms with a structured startup defect", () => - Effect.gen(function* () { - const exit = yield* BunPtyAdapter.make().pipe( - Effect.provideService(HostProcessPlatform, "win32"), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasDies(exit.cause)).toBe(true); - const error = Cause.squash(exit.cause); - assert.instanceOf(error, BunPtyAdapter.BunPtyUnsupportedPlatformError); - expect(error).toMatchObject({ - _tag: "BunPtyUnsupportedPlatformError", - platform: "win32", - }); - expect(error.message).toBe( - "Bun PTY terminal support is unavailable on win32. Please use Node.js (e.g. by running `npx t3`) instead.", - ); - } - }), -); diff --git a/apps/server/src/terminal/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts deleted file mode 100644 index 1a3f26ceb670..000000000000 --- a/apps/server/src/terminal/BunPtyAdapter.ts +++ /dev/null @@ -1,155 +0,0 @@ -/// - -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -import * as PtyAdapter from "./PtyAdapter.ts"; - -export class BunPtyUnsupportedPlatformError extends Schema.TaggedError()( - "BunPtyUnsupportedPlatformError", - { - platform: Schema.Literal("win32"), - }, -) { - override get message(): string { - return `Bun PTY terminal support is unavailable on ${this.platform}. Please use Node.js (e.g. by running \`npx t3\`) instead.`; - } -} - -export class BunPtyOperationUnavailableError extends Schema.TaggedError()( - "BunPtyOperationUnavailableError", - { - operation: Schema.Literals(["write", "resize"]), - pid: Schema.Number, - }, -) { - override get message(): string { - return `Bun PTY ${this.operation} is unavailable for process ${this.pid}.`; - } -} - -class BunPtyProcess implements PtyAdapter.PtyProcess { - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); - private readonly decoder = new TextDecoder(); - private readonly process: Bun.Subprocess; - private didExit = false; - - constructor(process: Bun.Subprocess) { - this.process = process; - void this.process.exited - .then((exitCode) => { - this.emitExit({ - exitCode: Number.isInteger(exitCode) ? exitCode : 0, - signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, - }); - }) - .catch(() => { - this.emitExit({ exitCode: 1, signal: null }); - }); - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - if (!this.process.terminal) { - throw new BunPtyOperationUnavailableError({ operation: "write", pid: this.pid }); - } - this.process.terminal.write(data); - } - - resize(cols: number, rows: number): void { - if (!this.process.terminal?.resize) { - throw new BunPtyOperationUnavailableError({ operation: "resize", pid: this.pid }); - } - this.process.terminal.resize(cols, rows); - } - - kill(signal?: string): void { - if (!signal) { - this.process.kill(); - return; - } - this.process.kill(signal as NodeJS.Signals); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: Uint8Array): void { - if (this.didExit) return; - const text = this.decoder.decode(data, { stream: true }); - if (text.length === 0) return; - for (const listener of this.dataListeners) { - listener(text); - } - } - - private emitExit(event: PtyAdapter.PtyExitEvent): void { - if (this.didExit) return; - this.didExit = true; - - const remainder = this.decoder.decode(); - if (remainder.length > 0) { - for (const listener of this.dataListeners) { - listener(remainder); - } - } - - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -export const make = Effect.fn("BunPtyAdapter.make")(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "win32") { - return yield* Effect.die(new BunPtyUnsupportedPlatformError({ platform })); - } - return PtyAdapter.PtyAdapter.of({ - spawn: (input) => - Effect.try({ - try: () => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); - }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; - }, - catch: (cause) => - new PtyAdapter.PtySpawnError({ - adapter: "bun", - shell: input.shell, - cause, - }), - }), - }); -}); - -export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 066cf01f261a..e6650025f70f 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -19,7 +19,7 @@ const spawn = vi.fn(() => ({ onExit: vi.fn(() => ({ dispose: vi.fn() })), })); -vi.mock("node-pty", () => ({ spawn })); +const fakeNodePty = { spawn } as unknown as typeof import("node-pty"); const makeTestLayer = (platform: NodeJS.Platform = "win32") => NodePtyAdapter.layer.pipe( @@ -28,6 +28,7 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") => NodeServices.layer, Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.resolve(fakeNodePty)), ), ), ); @@ -125,7 +126,10 @@ it.effect("preserves a caller-provided TERM in the spawn env on win32", () => it.effect("reports native module load failures as structured startup defects", () => Effect.gen(function* () { const cause = new Error("native binding could not be loaded"); - const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit); + const exit = yield* NodePtyAdapter.make().pipe( + Effect.provideService(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.reject(cause)), + Effect.exit, + ); assert.isTrue(Exit.isFailure(exit)); if (Exit.isFailure(exit)) { diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 8f238ac60c34..67cdcecdd53a 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,5 +1,6 @@ import * as NodeModule from "node:module"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -24,10 +25,24 @@ export class NodePtyModuleLoadError extends Schema.TaggedError Promise; +// node-pty stays external to the CLI bundle because it dlopens a native +// addon. Inside a Node single-executable, `import()` cannot load files from +// disk (only built-ins resolve), while `require` always reads the real +// filesystem, so both the module and its spawn-helper resolve through it. +const requireForNodePty = NodeModule.createRequire(import.meta.url); + +const loadNodePty: NodePtyModuleLoader = () => + Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty")); + +/** Injectable so tests can substitute a fake module; `require` bypasses module mocks. */ +export const NodePtyModuleLoaderRef = Context.Reference( + "server/terminal/NodePtyModuleLoader", + { defaultValue: () => loadNodePty }, +); + let didEnsureSpawnHelperExecutable = false; const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = NodeModule.createRequire(import.meta.url); const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; @@ -113,9 +128,8 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } } -export const make = Effect.fn("NodePtyAdapter.make")(function* ( - loadNodePtyModule: NodePtyModuleLoader = () => import("node-pty"), -) { +export const make = Effect.fn("NodePtyAdapter.make")(function* () { + const loadNodePtyModule = yield* NodePtyModuleLoaderRef; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 44a9c3397c6f..cac501aede0c 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,13 +1,15 @@ -import { - type DirItem, - type DirSearchResult, - type FileItem, - FileFinder, - type GrepCursor, - type MixedItem, - type MixedSearchResult, - type Result, - type SearchResult, +import * as NodeModule from "node:module"; + +import type { + DirItem, + DirSearchResult, + FileItem, + FileFinder as FileFinderType, + GrepCursor, + MixedItem, + MixedSearchResult, + Result, + SearchResult, } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -25,6 +27,13 @@ import type { } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +// fff-node stays external to the CLI bundle because it dlopens a native +// library. A static `import` of an external package is a hard error inside a +// Node single-executable (only built-ins resolve there), so load it through +// `require`, which reads from the real filesystem in every runtime. +const requireForFff = NodeModule.createRequire(import.meta.url); +const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); + const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; @@ -330,7 +339,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( cwd: string, - finder: FileFinder, + finder: FileFinderType, onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, ): Effect.fn.Return { const result = yield* Effect.tryPromise({ diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 621a1f7bf66f..a6e356c6996a 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -23,6 +23,12 @@ export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; +// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe +// step refuses multi-chunk output and counts the sourcemap as a chunk, and the +// executable needs a host Node that supports `--build-sea` (25.7+), so this is +// a separate mode rather than a second entry in the default build. +const packExecutable = process.env.T3CODE_PACK_EXE === "1"; + export default mergeConfig( baseConfig, defineConfig({ @@ -36,10 +42,25 @@ export default mergeConfig( }, }, pack: { - entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"], - outDir: "dist", - sourcemap: true, + // The executable embeds one entry; the history worker becomes a hidden + // subcommand there instead of a sibling script. + entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claudeHistoryWorker.ts"], + outDir: packExecutable ? "dist-exe" : "dist", + sourcemap: !packExecutable, clean: true, + ...(packExecutable + ? { + exe: { + fileName: "t3", + outDir: "dist-exe", + // Node's SEA docs: `import()` does not work when useCodeCache is + // true, and the server reaches several modules that way. The + // cache is also platform-bound, so leaving it off keeps the + // build correct on any host. + seaConfig: { useCodeCache: false }, + }, + } + : {}), deps: { // Both halves are required. `alwaysBundle` forces the JS dependencies in // (declared deps are external by default, which is what this change is diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 3716f0ee4848..d156d4cc3e5f 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -7,7 +7,6 @@ import * as NodeSqlite from "node:sqlite"; import * as Cache from "effect/Cache"; -import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -26,10 +25,6 @@ import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; -export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; - -export type TypeId = "~local/sqlite-node/SqliteClient"; - export interface SqliteClientConfig { readonly filename: string; readonly readonly?: boolean | undefined; @@ -315,13 +310,6 @@ const makeMemory = ( }, ); -export const layerConfig = ( - config: Config.Wrap, -): Layer.Layer => - Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( - Layer.provide(Reactivity.layer), - ); - export const layer = (config: SqliteClientConfig): Layer.Layer => Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch index 74c132926d90..04c2a803056c 100644 --- a/patches/@ff-labs__fff-node@0.9.4.patch +++ b/patches/@ff-labs__fff-node@0.9.4.patch @@ -37,3 +37,17 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1 } } catch { +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -8,8 +8,9 @@ + "types": "dist/src/index.d.ts", + "exports": { + ".": { ++ "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", +- "types": "./dist/src/index.d.ts" ++ "require": "./dist/src/index.js" + } + }, + "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26f13005de4b..5a37d9bf6fa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,11 +59,9 @@ overrides: '@clerk/react': 6.14.7 '@clerk/shared': 4.30.1 '@effect/atom-react': 4.0.0-rc.112 - '@effect/platform-bun': 4.0.0-rc.112 '@effect/platform-node': 4.0.0-rc.112 '@effect/platform-node-shared': 4.0.0-rc.112 '@effect/sql-pg': 4.0.0-rc.112 - '@effect/sql-sqlite-bun': 4.0.0-rc.112 '@effect/vitest': 4.0.0-rc.112 '@effect/vitest>vitest': '-' '@expo/dom-webview': 57.0.1 @@ -90,7 +88,7 @@ patchedDependencies: '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 - '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 + '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d @@ -499,21 +497,15 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.260 version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) - '@effect/platform-bun': - specifier: 4.0.0-rc.112 - version: 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': specifier: 4.0.0-rc.112 version: 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) '@effect/platform-node-shared': specifier: 4.0.0-rc.112 version: 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/sql-sqlite-bun': - specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@ff-labs/fff-node': specifier: 0.9.4 - version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) + version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -557,9 +549,6 @@ importers: '@t3tools/web': specifier: workspace:* version: link:../web - '@types/bun': - specifier: 1.3.14 - version: 1.3.14 '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1069,7 +1058,7 @@ packages: resolution: {integrity: sha512-rH5BFh0Xq7aExpSpITyQbC+Jqfc9XgwJXidHVHTi1tNRC6/KAWg4tOzBfwiFIxljFLLYcApzlYFvLa/fTYcEjw==} peerDependencies: '@distilled.cloud/cloudflare': 1.0.0-rc.8 - '@effect/platform-bun': 4.0.0-rc.112 + '@effect/platform-bun': '>=4.0.0-rc.112 || >=4.0.0' '@effect/platform-node': 4.0.0-rc.112 effect: 4.0.0-rc.112 rolldown: 1.2.5 @@ -5353,9 +5342,6 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/bun@1.3.14': - resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} - '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -6093,7 +6079,7 @@ packages: peerDependencies: '@alchemy.run/frontend-frameworks': 2.0.0-beta.76 '@aws/durable-execution-sdk-js': ^2.1.0 - '@effect/platform-bun': 4.0.0-rc.112 + '@effect/platform-bun': '>=4.0.0-rc.112 || >=4.0.0' '@effect/platform-node': 4.0.0-rc.112 '@effect/sql-mysql2': '>=4.0.0-rc.112 || >=4.0.0' '@effect/sql-pg': 4.0.0-rc.112 @@ -6973,7 +6959,7 @@ packages: '@effect/sql-mysql2': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-pg': 4.0.0-rc.112 '@effect/sql-pglite': '>=4.0.0-beta.105 || >=4.0.0' - '@effect/sql-sqlite-bun': 4.0.0-rc.112 + '@effect/sql-sqlite-bun': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-sqlite-do': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-sqlite-node': '>=4.0.0-beta.105 || >=4.0.0' '@effect/sql-sqlite-wasm': '>=4.0.0-beta.105 || >=4.0.0' @@ -12675,6 +12661,7 @@ snapshots: transitivePeerDependencies: - bufferutil - utf-8-validate + optional: true '@effect/platform-node-shared@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6)': dependencies: @@ -12715,6 +12702,7 @@ snapshots: '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) + optional: true '@effect/sql-sqlite-do@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: @@ -13664,7 +13652,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)': + '@ff-labs/fff-node@0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)': dependencies: ffi-rs: 1.3.2 optionalDependencies: @@ -15848,10 +15836,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 24.12.4 - '@types/bun@1.3.14': - dependencies: - bun-types: 1.3.14 - '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -17009,6 +16993,7 @@ snapshots: bun-types@1.3.14: dependencies: '@types/node': 24.12.4 + optional: true bytes@3.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e2c683e9bab3..f121121251d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,11 +32,9 @@ catalog: "@clerk/shared": 4.30.1 "@effect/atom-react": 4.0.0-rc.112 "@effect/openapi-generator": 4.0.0-rc.112 - "@effect/platform-bun": 4.0.0-rc.112 "@effect/platform-node": 4.0.0-rc.112 "@effect/platform-node-shared": 4.0.0-rc.112 "@effect/sql-pg": 4.0.0-rc.112 - "@effect/sql-sqlite-bun": 4.0.0-rc.112 "@effect/tsgo": 0.41.0 "@effect/vitest": 4.0.0-rc.112 "@legendapp/list": 3.3.5 @@ -71,11 +69,9 @@ minimumReleaseAgeExclude: - "@distilled.cloud/planetscale@0.30.2" - "@effect/atom-react@4.0.0-rc.112" - "@effect/openapi-generator@4.0.0-rc.112" - - "@effect/platform-bun@4.0.0-rc.112" - "@effect/platform-node-shared@4.0.0-rc.112" - "@effect/platform-node@4.0.0-rc.112" - "@effect/sql-pg@4.0.0-rc.112" - - "@effect/sql-sqlite-bun@4.0.0-rc.112" - "@effect/vitest@4.0.0-rc.112" - alchemy@2.0.0-beta.76 - effect@4.0.0-rc.112 @@ -121,11 +117,9 @@ overrides: "@clerk/react": "catalog:" "@clerk/shared": "catalog:" "@effect/atom-react": "catalog:" - "@effect/platform-bun": "catalog:" "@effect/platform-node": "catalog:" "@effect/platform-node-shared": "catalog:" "@effect/sql-pg": "catalog:" - "@effect/sql-sqlite-bun": "catalog:" "@effect/vitest": "catalog:" "@effect/vitest>vitest": "-" "@expo/dom-webview": 57.0.1 diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index e894359eccb5..775c77fef1d8 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -11,6 +11,7 @@ import serverPackageJson from "../../apps/server/package.json" with { type: "jso import { CLI_RUNTIME_EXTERNAL_PREFIXES, + findEsmImportsOfExternalPackages, findInlinedExternalPackages, selectCliRuntimeExternalDependencies, shouldBundleCliDependency, @@ -55,11 +56,6 @@ describe("shouldBundleCliDependency", () => { } }); - it("leaves bun-only entry points external", () => { - assert.strictEqual(shouldBundleCliDependency("@effect/platform-bun"), false); - assert.strictEqual(shouldBundleCliDependency("@effect/sql-sqlite-bun"), false); - }); - // The real package is `node-gyp-build-optional-packages`, reached by prefix. // It is transitive to a selected dependency root, so the runtime closure test // below ensures it follows that root into the sidecar. @@ -72,7 +68,6 @@ describe("selectCliRuntimeExternalDependencies", () => { it("keeps only runtime-external dependency roots for the Windows sidecar", () => { assert.deepStrictEqual( selectCliRuntimeExternalDependencies({ - "@effect/platform-bun": "1.0.0", "@ff-labs/fff-node": "2.0.0", effect: "3.0.0", "node-pty": "4.0.0", @@ -281,3 +276,41 @@ var x = 1; assert.deepStrictEqual(result.inlined, []); }); }); + +// The single-executable build can only `import` built-ins. A file-backed +// import of an external package passes every bundler check and the regular +// `node dist/bin.mjs` path, then fails inside the executable, so the scan +// reads the emitted module graph instead. +describe("findEsmImportsOfExternalPackages", () => { + it("flags static and dynamic imports of file-backed packages", () => { + const source = [ + 'import { FileFinder } from "@ff-labs/fff-node";', + 'import * as fs from "fs";', + 'import { createRequire } from "node:module";', + 'const pty = () => import("node-pty");', + 'const data = () => import("@ff-labs/fff-bin-linux-x64-gnu", { with: { type: "json" } });', + 'const lazy = () => import(/* @vite-ignore */ "ffi-rs");', + 'const local = () => import("./chunk-abc.mjs");', + ].join("\n"); + + assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), [ + "@ff-labs/fff-bin-linux-x64-gnu", + "@ff-labs/fff-node", + "ffi-rs", + "node-pty", + ]); + }); + + it("flags side-effect imports and re-exports too", () => { + const source = ['import "msgpackr-extract";', 'export { load } from "ffi-rs";'].join("\n"); + assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), [ + "ffi-rs", + "msgpackr-extract", + ]); + }); + + it("does not mistake createRequire calls for imports", () => { + const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");'; + assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []); + }); +}); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index d7a89bc408a4..254ce3dc2719 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeModule from "node:module"; + /** * The single source of truth for packages the server CLI bundle must NOT inline. * @@ -50,24 +53,6 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "utf-8-validate", ] as const; -/** - * External only so the bundler never has to resolve them. - * - * These are reached through a runtime-conditional dynamic import that Node - * never takes, and they resolve `bun:*` specifiers that do not exist when - * bundling for Node. Because Node never loads them, their dependency closure - * does not need to be external — only the entry point must stay unbundled. - */ -export const CLI_BUILD_ONLY_EXTERNAL_PREFIXES = [ - "@effect/platform-bun", - "@effect/sql-sqlite-bun", -] as const; - -export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ - ...CLI_RUNTIME_EXTERNAL_PREFIXES, - ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, -] as const; - export function isRuntimeExternalCliDependency(id: string): boolean { return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); } @@ -83,7 +68,7 @@ export function isRuntimeExternalCliDependency(id: string): boolean { * inlined while node-pty (a declared dependency) stayed external. */ export function isExternalCliDependency(id: string): boolean { - return CLI_EXTERNAL_PACKAGE_PREFIXES.some((prefix) => id.startsWith(prefix)); + return isRuntimeExternalCliDependency(id); } /** True when the CLI bundle should inline `id` rather than leave it external. */ @@ -101,6 +86,40 @@ export function selectCliRuntimeExternalDependencies( ); } +/** + * Scan an emitted bundle chunk for ESM imports of packages that are not Node + * built-ins. + * + * Inside a Node single-executable, `import` statements and `import()` can only + * resolve built-in modules; any file-backed specifier throws at module + * evaluation (static) or at first use (dynamic). External packages therefore + * have to be reached through `createRequire`, which reads the real filesystem + * in every runtime. The bundler cannot enforce this, so the check reads what it + * produced. + */ +export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { + const specifiers = new Set(); + // `import x from`, `import "side-effect"`, `export ... from`, and `import()` + // all resolve through the module loader. + const patterns = [ + /^import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, + /^import\s+["']([^"']+)["']/gm, + /^export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, + // Rolldown may leave a `/* @vite-ignore */` style comment before the specifier. + /\bimport\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*[,)]/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + const specifier = match[1]; + if (specifier === undefined) continue; + if (NodeModule.isBuiltin(specifier)) continue; + if (specifier.startsWith("./") || specifier.startsWith("../")) continue; + specifiers.add(specifier); + } + } + return [...specifiers].sort(); +} + /** * Scan an emitted bundle chunk for runtime-external packages that were inlined. * From eb8f6f42a5cad1a5d0bc51eaa16cff13a9c69f45 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:20 -0700 Subject: [PATCH 10/27] ci(release): build, sign, and publish self-contained CLI archives (#11317) Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 268 ++++++++- .../src/updates/updateChannels.test.ts | 16 + apps/desktop/src/updates/updateChannels.ts | 12 +- apps/server/resources/cli-entitlements.plist | 18 + apps/server/scripts/cli.ts | 15 +- apps/server/src/cli/invocation.test.ts | 4 +- apps/server/src/cli/invocation.ts | 3 +- apps/server/src/cli/triage.ts | 4 +- apps/server/vite.config.ts | 36 +- apps/web/src/branding.logic.ts | 2 +- docs/operations/release.md | 7 + scripts/build-cli-archive.ts | 553 ++++++++++++++++++ scripts/build-desktop-artifact.test.ts | 11 + scripts/build-desktop-artifact.ts | 11 +- scripts/lib/brand-assets.test.ts | 1 + scripts/lib/brand-assets.ts | 2 +- scripts/resolve-nightly-release.test.ts | 13 + scripts/resolve-nightly-release.ts | 27 +- scripts/resolve-previous-release-tag.test.ts | 17 + scripts/resolve-previous-release-tag.ts | 25 +- scripts/smoke-cli-archive.ts | 198 +++++++ 21 files changed, 1188 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/updates/updateChannels.test.ts create mode 100644 apps/server/resources/cli-entitlements.plist create mode 100644 scripts/build-cli-archive.ts create mode 100644 scripts/smoke-cli-archive.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39e485d42a98..8f8ef7fbac78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" + - "!v*-preview.*" schedule: # Avoid minute zero, when GitHub scheduled jobs are busiest. - cron: "8,38 * * * *" @@ -18,6 +19,7 @@ on: options: - stable - nightly + - preview version: description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." required: false @@ -30,7 +32,7 @@ on: # newest-wins single slot, so a queued stable tag can never be silently # dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: - group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'preview') && 'nightly' || 'stable' }} cancel-in-progress: false queue: max @@ -72,7 +74,7 @@ jobs: if (context.eventName === 'schedule') { core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); core.setOutput('ref', context.sha); - } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') { + } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly' && process.env.DISPATCH_CHANNEL !== 'preview') { const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); core.setOutput('ref', sha); @@ -144,6 +146,26 @@ jobs: echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then + # Temporary channel for dogfooding the archive-based CLI runtime. + # Same versioning as nightly under its own prerelease identifier. + # A preview release is reachable only by downloading it by hand: + # it is never published to npm, its desktop builds carry no update + # feed, and no updater manifest is attached to the release, so + # neither stable nor nightly installs can ever be offered one. + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + + node scripts/resolve-nightly-release.ts \ + --channel preview \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=preview" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" else if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" @@ -403,6 +425,12 @@ jobs: fail-fast: false matrix: include: + # cli_archive: whether the job also builds the self-contained CLI + # archive. The executable is built on the runner's own Node, so only + # native runners qualify. macOS x64 has no native runner: a + # cross-built executable crashed under Rosetta in the smoke test and + # cannot be verified on real x64 hardware in CI, so it is skipped + # until it can be. - label: macOS arm64 runner: blacksmith-12vcpu-macos-26 platform: mac @@ -410,6 +438,7 @@ jobs: arch: arm64 rust_target: aarch64-apple-darwin resource_key: darwin-arm64 + cli_archive: true - label: macOS x64 runner: blacksmith-12vcpu-macos-26 platform: mac @@ -417,6 +446,7 @@ jobs: arch: x64 rust_target: x86_64-apple-darwin resource_key: darwin-x64 + cli_archive: false - label: Linux x64 runner: blacksmith-32vcpu-ubuntu-2404 platform: linux @@ -424,6 +454,7 @@ jobs: arch: x64 rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 + cli_archive: true - label: Windows x64 runner: blacksmith-32vcpu-windows-2025 platform: win @@ -431,6 +462,7 @@ jobs: arch: x64 rust_target: x86_64-pc-windows-msvc resource_key: win32-x64 + cli_archive: true # - label: Windows arm64 # runner: windows-11-arm # platform: win @@ -710,6 +742,105 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. It always + # injects into the runner's own Node: tsdown's cross-target download path + # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a + # remote host, and a cross-built macOS binary cannot be smoke-tested. + - name: Build CLI single-executable + if: matrix.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Import macOS signing certificate for the CLI archive + if: matrix.cli_archive && matrix.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: matrix.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ matrix.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ matrix.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: matrix.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + set -euo pipefail + if [[ "${{ matrix.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: matrix.cli_archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" + + - name: Upload CLI archive + if: matrix.cli_archive + uses: actions/upload-artifact@v7 + with: + name: cli-${{ matrix.platform }}-${{ matrix.arch }} + path: release-cli/* + if-no-files-found: error + - name: Collect release assets shell: bash run: | @@ -717,13 +848,19 @@ jobs: mkdir -p release-publish shopt -s nullglob - for pattern in \ - "release/*.dmg" \ - "release/*.zip" \ - "release/*.AppImage" \ - "release/*.exe" \ - "release/*.blockmap" \ - "release/*.yml"; do + patterns=( + "release/*.dmg" + "release/*.zip" + "release/*.AppImage" + "release/*.exe" + ) + # Preview builds have no publish config, so electron-builder writes + # no feed manifest for them, but it still emits blockmaps beside the + # installers. Neither belongs on a release no updater may follow. + if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then + patterns+=("release/*.blockmap" "release/*.yml") + fi + for pattern in "${patterns[@]}"; do for file in $pattern; do cp "$file" release-publish/ done @@ -775,10 +912,12 @@ jobs: path: resource-monitor-publish/${{ matrix.resource_key }}/* if-no-files-found: error + # Preview releases never reach npm: the archive on the GitHub Release is the + # only way to obtain one, so no dist-tag can ever resolve to a preview build. publish_cli: name: Publish CLI to npm needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -856,7 +995,7 @@ jobs: release: name: Publish GitHub Release needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -887,7 +1026,48 @@ jobs: merge-multiple: true path: release-assets + - name: Download all CLI archives + uses: actions/download-artifact@v8 + with: + pattern: cli-* + merge-multiple: true + path: release-assets + + # Installers verify archives against this file, so it is written from the + # signed bytes that get uploaded, never from an earlier stage. + - name: Write CLI archive checksums + shell: bash + run: | + set -euo pipefail + cd release-assets + shopt -s nullglob + archives=(t3-*.tar.gz t3-*.zip) + if [[ ${#archives[@]} -eq 0 ]]; then + echo "No CLI archives were produced." >&2 + exit 1 + fi + sha256sum "${archives[@]}" > SHA256SUMS + cat SHA256SUMS + + # The desktop build omits the publish config for preview versions, so + # electron-builder emits no updater manifests for them. Refuse to publish + # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview + # release is what would let a stable or nightly install update onto it. + - name: Refuse updater metadata on preview releases + if: needs.preflight.outputs.release_channel == 'preview' + shell: bash + run: | + set -euo pipefail + shopt -s nullglob extglob + # builder-debug.yml is electron-builder's config dump, not a feed. + updater_files=(release-assets/!(builder-debug).yml release-assets/*.blockmap) + if [[ ${#updater_files[@]} -ne 0 ]]; then + printf 'Preview releases must not carry updater metadata, found: %s\n' "${updater_files[*]}" >&2 + exit 1 + fi + - name: Merge macOS updater manifests + if: needs.preflight.outputs.release_channel != 'preview' run: | shopt -s nullglob for x64_manifest in release-assets/*-mac-x64.yml; do @@ -898,6 +1078,45 @@ jobs: fi done + # Updater manifests and blockmaps are what electron-updater consumes. + # They are only listed for channels an updater is meant to follow. + - id: release_files + name: Resolve release asset list + shell: bash + run: | + { + echo 'files<> "$GITHUB_OUTPUT" + + # A preview release gets a warning instead of generated notes. Generated + # notes would list every commit since the previous preview, which is + # unmerged branch history no one should read as a changelog, and would + # make the release look like any other build to someone browsing the + # releases page. + - name: Write preview release notes + if: needs.preflight.outputs.release_channel == 'preview' + shell: bash + run: | + cat > release-notes.md <<'EOF' + > [!WARNING] + > **This is a preview build. Do not install it unless you know exactly why you are here.** + > + > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/pingdotgg/t3code/releases/latest) or a nightly instead. + + Built from `${{ needs.preflight.outputs.ref }}`. + EOF + - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v3 @@ -905,17 +1124,12 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} + body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} @@ -926,23 +1140,18 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} + body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} publish_aur: name: Publish AUR package needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} uses: ./.github/workflows/publish-aur.yml with: release_tag: ${{ needs.preflight.outputs.tag }} @@ -952,7 +1161,7 @@ jobs: deploy_web: name: Deploy hosted web app needs: [preflight, relay_public_config, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 env: @@ -1206,6 +1415,7 @@ jobs: if: | always() && !cancelled() && needs.preflight.result == 'success' && + needs.preflight.outputs.release_channel != 'preview' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.deploy_web.result == 'success' && diff --git a/apps/desktop/src/updates/updateChannels.test.ts b/apps/desktop/src/updates/updateChannels.test.ts new file mode 100644 index 000000000000..acf4155fab8e --- /dev/null +++ b/apps/desktop/src/updates/updateChannels.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; + +describe("updateChannels", () => { + it("keeps preview builds branded as nightly but on the latest update channel", () => { + expect(isNightlyDesktopVersion("0.0.41-preview.20260911.7")).toBe(true); + expect(resolveDefaultDesktopUpdateChannel("0.0.41-preview.20260911.7")).toBe("latest"); + expect(resolveDefaultDesktopUpdateChannel("0.0.41-nightly.20260911.7")).toBe("nightly"); + }); + + it("only matches the first prerelease identifier", () => { + expect(isNightlyDesktopVersion("1.2.3-foo-preview.20260911.1")).toBe(false); + expect(isNightlyDesktopVersion("1.2.3")).toBe(false); + }); +}); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 731910e441fe..8611917e1b60 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,11 +1,17 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; -const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; +// Preview builds are a temporary dogfooding train cut from nightly. They share +// nightly's branding but are packaged without an update feed (see +// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the +// channel a preview install reports is cosmetic: it never checks for updates +// and no updater feed ever lists a preview release. +const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { - return NIGHTLY_VERSION_PATTERN.test(version); + return PRERELEASE_VERSION_PATTERN.test(version); } export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; + return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; } diff --git a/apps/server/resources/cli-entitlements.plist b/apps/server/resources/cli-entitlements.plist new file mode 100644 index 000000000000..3078fc969a73 --- /dev/null +++ b/apps/server/resources/cli-entitlements.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index f77cff2c4c48..09866c316eea 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -185,6 +185,12 @@ const buildExeCmd = Command.make( "build-exe", { verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), + target: Flag.string("target").pipe( + Flag.withDescription( + "Cross-build for - in nodejs.org naming (for example darwin-x64); defaults to the host.", + ), + Flag.optional, + ), }, (config) => Effect.gen(function* () { @@ -198,7 +204,14 @@ const buildExeCmd = Command.make( yield* runCommand( ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: serverDir, - env: { ...process.env, T3CODE_PACK_EXE: "1" }, + env: { + ...process.env, + T3CODE_PACK_EXE: "1", + ...Option.match(config.target, { + onNone: () => ({}), + onSome: (target) => ({ T3CODE_PACK_EXE_TARGET: target }), + }), + }, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", shell: spawnCommand.shell, diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 370a8977fc4c..067d9fa09d80 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -43,9 +43,11 @@ it("treats stable installs as direct invocations", () => { } }); -it("re-suggests the nightly channel only for nightly builds", () => { +it("re-suggests the prerelease channel only for prerelease builds", () => { for (const [version, expected] of [ ["0.0.31-nightly.20260729", "npx t3@nightly serve"], + ["0.0.31-preview.20260729.1", "npx t3@preview serve"], + ["0.0.31-foo-preview.20260729.1", "npx t3 serve"], ["0.0.31", "npx t3 serve"], ] as const) { assert.equal( diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 55f5b66ad9dd..1fc0e774129f 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -43,7 +43,8 @@ function detectCliRunner(entryPath: string): CliRunner | null { * anything else suggests the bare package. */ function suggestedPackageSpec(version: string): string { - return version.includes("-nightly.") ? "t3@nightly" : "t3"; + const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1]; + return channel === undefined ? "t3" : `t3@${channel}`; } /** diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index deb7823f2dab..7a468c5e13f7 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -189,8 +189,8 @@ export const triageCommand = Command.make("triage", { buildTriageContext({ generatedAt: DateTime.formatIso(now), version, - releaseTag: version.includes("-nightly.") - ? `v${version} (nightly build; if this tag does not exist, clone main)` + releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version) + ? `v${version} (prerelease build; if this tag does not exist, clone main)` : `v${version}`, os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`, nodeVersion: process.version, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index a6e356c6996a..2015ad4de8d6 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -21,13 +21,46 @@ import { export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); -const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; +const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version) + ? "nightly" + : "latest"; // `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe // step refuses multi-chunk output and counts the sourcemap as a chunk, and the // executable needs a host Node that supports `--build-sea` (25.7+), so this is // a separate mode rather than a second entry in the default build. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; +// `-` in nodejs.org naming (darwin-x64, linux-arm64, win-x64). +// When set, tsdown injects the bundle into a downloaded Node of that target +// instead of the host Node, which is how the arm64 macOS runner produces the +// x64 archive. Cross-building is safe because the code cache is off. +// +// The Node inside the executable is pinned here rather than taken from the +// build host, so every archive of a release embeds the same runtime no matter +// which Node happens to run the build. +const SEA_NODE_VERSION = "26.8.2"; +const SEA_TARGETS = { + "darwin-arm64": { platform: "darwin", arch: "arm64" }, + "darwin-x64": { platform: "darwin", arch: "x64" }, + "linux-arm64": { platform: "linux", arch: "arm64" }, + "linux-x64": { platform: "linux", arch: "x64" }, + "win-arm64": { platform: "win", arch: "arm64" }, + "win-x64": { platform: "win", arch: "x64" }, +} as const; +const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); +if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { + throw new Error( + `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, + ); +} +const packExecutableTargets = packExecutableTarget + ? [ + { + ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], + nodeVersion: SEA_NODE_VERSION, + }, + ] + : undefined; export default mergeConfig( baseConfig, @@ -53,6 +86,7 @@ export default mergeConfig( exe: { fileName: "t3", outDir: "dist-exe", + ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), // Node's SEA docs: `import()` does not work when useCodeCache is // true, and the server reaches several modules that way. The // cache is also platform-bound, so leaving it off keeps the diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6ab..126fb7706248 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -1,4 +1,4 @@ -const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_SERVER_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function formatAppDisplayName(input: { readonly baseName: string; diff --git a/docs/operations/release.md b/docs/operations/release.md index cb4ea155386a..b42c726ca33d 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -12,6 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` + - manual `workflow_dispatch` with `channel=preview`, a temporary train for dogfooding the archive-based CLI runtime. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes only a GitHub prerelease. A preview is reachable solely by downloading it from that release: it is not published to npm, its desktop builds are packaged without an update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so stable and nightly installs can never be offered one. The hosted web app, AUR, and Discord announcements are skipped. Remove the channel once archives are the default on nightly and stable. - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep landing while you verify and never leak into the stable build. @@ -33,9 +34,15 @@ This document covers the unified release workflow for stable and nightly desktop - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. + - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the download every runtime installer will verify against `SHA256SUMS`. + - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. + - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. + - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` + - preview releases are not published to npm - Deploys the hosted web app to Vercel only after a release is published: - stable releases are aliased to the `latest` hosted app channel - nightly releases are aliased to the `nightly` hosted app channel diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts new file mode 100644 index 000000000000..99bb7cf4396f --- /dev/null +++ b/scripts/build-cli-archive.ts @@ -0,0 +1,553 @@ +#!/usr/bin/env node +/** + * Packages the server single-executable into a self-contained per-platform + * archive: the `t3` binary, the web client, the resource monitor, and a + * production install of the native packages the bundle keeps external. The + * archive is the unit every runtime installer downloads, so nothing in it may + * require Node, npm, or a compiler on the machine that unpacks it. + * + * Layout inside the archive (a single top-level directory named after the + * archive stem): + * + * t3 | t3.exe the single-executable + * client/ web app served by the server + * resource-monitor/ per-platform Rust helper, same paths as the npm package + * node_modules/ runtime externals (node-pty, msgpackr-extract, fff) + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { fromYaml } from "@t3tools/shared/schemaYaml"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import rootPackageJson from "../package.json" with { type: "json" }; +import serverPackageJson from "../apps/server/package.json" with { type: "json" }; + +import { + createStagePatchedDependencies, + createStageWorkspaceConfig, + resolveFffNativeDependencies, + STAGE_INSTALL_ARGS, +} from "./build-desktop-artifact.ts"; +import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts"; +import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; + +const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); +const BuildArch = Schema.Literals(["arm64", "x64"]); +type BuildPlatform = typeof BuildPlatform.Type; +type BuildArch = typeof BuildArch.Type; + +const WorkspaceConfig = Schema.Struct({ + catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), + overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), + patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), +}); +const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const StageWorkspaceConfig = Schema.Struct({ + supportedArchitectures: Schema.Struct({ + os: Schema.Array(Schema.String), + cpu: Schema.Array(Schema.String), + libc: Schema.optional(Schema.Array(Schema.String)), + }), + allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), + overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), + nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), +}); +const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); + +const RepoRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), +); + +export class CliArchiveCommandFailedError extends Schema.TaggedError()( + "CliArchiveCommandFailedError", + { command: Schema.String, exitCode: Schema.Int }, +) { + override get message(): string { + return `${this.command} exited with code ${this.exitCode}.`; + } +} + +export class CliArchiveInputMissingError extends Schema.TaggedError()( + "CliArchiveInputMissingError", + { inputPath: Schema.String, hint: Schema.String }, +) { + override get message(): string { + return `Missing ${this.inputPath}. ${this.hint}`; + } +} + +/** Platform/arch pair as it appears in archive names and `process.platform`/`process.arch`. */ +export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string { + const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; + return `${nodePlatform}-${arch}`; +} + +export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string { + return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`; +} + +export function cliArchiveFileName(version: string, platform: BuildPlatform, arch: BuildArch) { + // gzip rather than xz: GNU tar needs an external xz binary for -J, which + // minimal hosts lack, while every tar (and Node's zlib) handles gzip alone. + return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`; +} + +/** The bsdtar Windows ships in System32; resolves regardless of which tar is first on PATH. */ +export function windowsSystemTar(): string { + const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows"; + return `${systemRoot}\\System32\\tar.exe`; +} + +const runCommand = Effect.fn("runCommand")(function* ( + command: ChildProcess.Command, + label: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // Output is inherited so the build log shows what each tool did; a failing + // signing or packaging step is otherwise a bare exit code. + const child = yield* spawner.spawn( + ChildProcess.isStandardCommand(command) + ? ChildProcess.make(command.command, command.args, { + ...command.options, + stdout: "inherit", + stderr: "inherit", + }) + : command, + ); + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new CliArchiveCommandFailedError({ command: label, exitCode }); + } +}); + +const requireInput = Effect.fn("requireInput")(function* (inputPath: string, hint: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(inputPath))) { + return yield* new CliArchiveInputMissingError({ inputPath, hint }); + } +}); + +/** + * Installs the runtime-external packages into `stageDir/node_modules` with a + * hoisted, symlink-free layout. The tree is archived and unpacked on machines + * without pnpm, so the store layout cannot be relied on to survive the trip. + */ +const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (input: { + readonly repoRoot: string; + readonly stageDir: string; + readonly platform: BuildPlatform; + readonly arch: BuildArch; + readonly version: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* decodeWorkspaceConfig( + yield* fs.readFileString(path.join(input.repoRoot, "pnpm-workspace.yaml")), + ); + const catalog = workspace.catalog ?? {}; + const serverDependencies = resolveCatalogDependencies( + serverPackageJson.dependencies, + catalog, + "apps/server", + ); + const fffNodeVersion = serverDependencies["@ff-labs/fff-node"]; + if (fffNodeVersion === undefined) { + return yield* new CliArchiveInputMissingError({ + inputPath: "apps/server/package.json#dependencies['@ff-labs/fff-node']", + hint: "The archive stages fff's platform binary from this version.", + }); + } + const dependencies = { + ...selectCliRuntimeExternalDependencies(serverDependencies), + ...resolveFffNativeDependencies(input.platform, input.arch, fffNodeVersion), + }; + const patchedDependencies = createStagePatchedDependencies( + workspace.patchedDependencies ?? {}, + dependencies, + ); + + yield* fs.writeFileString( + path.join(input.stageDir, "package.json"), + `${yield* encodeJsonString({ + name: "t3-runtime", + version: input.version, + private: true, + packageManager: rootPackageJson.packageManager, + dependencies, + })}\n`, + ); + yield* fs.writeFileString( + path.join(input.stageDir, "pnpm-workspace.yaml"), + yield* encodeStageWorkspaceConfig({ + ...createStageWorkspaceConfig({ + platform: input.platform, + arch: input.arch, + ...(workspace.allowBuilds ? { allowBuilds: workspace.allowBuilds } : {}), + patchedDependencies, + overrides: resolveCatalogDependencies(workspace.overrides ?? {}, catalog, "apps/server"), + }), + nodeLinker: "hoisted", + }), + ); + if (Object.keys(patchedDependencies).length > 0) { + yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(input.stageDir, "patches")); + } + + const install = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); + yield* runCommand( + ChildProcess.make(install.command, install.args, { + cwd: input.stageDir, + shell: install.shell, + stdout: "inherit", + stderr: "inherit", + }), + "vp install --prod (cli archive runtime externals)", + ); + + // pnpm's bookkeeping and the manifest only matter to pnpm; the runtime + // resolves packages by directory. node-pty ships every platform's prebuilds + // in one package (58 MB); only the archive's own platform loads. + const platformKey = cliArchivePlatformKey(input.platform, input.arch); + const prebuildsDir = path.join(input.stageDir, "node_modules/node-pty/prebuilds"); + const foreignPrebuilds = (yield* fs + .readDirectory(prebuildsDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).filter( + (entry) => entry !== platformKey, + ); + for (const entry of [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + "patches", + "node_modules/.pnpm", + "node_modules/.modules.yaml", + "node_modules/.pnpm-workspace-state-v1.json", + "node_modules/.bin", + ...foreignPrebuilds.map((entry) => `node_modules/node-pty/prebuilds/${entry}`), + ]) { + yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); + } +}); + +/** Copies the web client without its sourcemaps, which nothing serves. */ +const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.copy(source, target); + const maps = (yield* fs.readDirectory(target, { recursive: true })).filter((entry) => + entry.endsWith(".map"), + ); + for (const entry of maps) { + yield* fs.remove(path.join(target, entry), { force: true }); + } +}); + +const MacSigningConfig = Config.all({ + identity: Config.string("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option), + appleApiKey: Config.string("APPLE_API_KEY").pipe(Config.option), + appleApiKeyId: Config.string("APPLE_API_KEY_ID").pipe(Config.option), + appleApiIssuer: Config.string("APPLE_API_ISSUER").pipe(Config.option), +}); + +/** + * Apple Silicon refuses to run unsigned Mach-O binaries at all, so the + * executable is always signed: ad hoc when no identity is configured, or with + * the Developer ID plus notarization when it is. The hardened runtime that + * notarization requires only loads signed libraries, so every native addon in + * the archive is signed with the same identity. + */ +const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: { + readonly repoRoot: string; + readonly contentDir: string; + readonly executablePath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const signing = yield* MacSigningConfig; + const identity = Option.getOrUndefined(signing.identity)?.trim() || "-"; + + const entitlements = path.join(input.repoRoot, "apps/server/resources/cli-entitlements.plist"); + const libraries = (yield* fs.readDirectory(input.contentDir, { recursive: true })) + .filter( + (entry) => + entry.endsWith(".node") || + entry.endsWith(".dylib") || + entry.endsWith("spawn-helper") || + entry.endsWith("t3-resource-monitor"), + ) + .map((entry) => path.join(input.contentDir, entry)); + + for (const target of [...libraries, input.executablePath]) { + yield* runCommand( + ChildProcess.make("codesign", [ + "--force", + "--sign", + identity, + ...(identity === "-" ? [] : ["--options", "runtime", "--timestamp"]), + ...(target === input.executablePath ? ["--entitlements", entitlements] : []), + target, + ]), + `codesign ${path.relative(input.contentDir, target)}`, + ); + } + if (identity === "-") { + yield* Effect.log("[cli-archive] Signed ad hoc (no T3CODE_CLI_MAC_SIGN_IDENTITY)."); + return; + } + + const apiKey = Option.getOrUndefined(signing.appleApiKey); + const apiKeyId = Option.getOrUndefined(signing.appleApiKeyId); + const apiIssuer = Option.getOrUndefined(signing.appleApiIssuer); + if (!apiKey || !apiKeyId || !apiIssuer) { + yield* Effect.logWarning( + "[cli-archive] Developer ID signed but not notarized (missing APPLE_API_KEY*).", + ); + return; + } + // notarytool only accepts archives, and a bare executable cannot be stapled, + // so notarize a zip of the binary and rely on the online ticket lookup. + const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip"); + yield* runCommand( + ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]), + "ditto (notarization zip)", + ); + yield* runCommand( + ChildProcess.make("xcrun", [ + "notarytool", + "submit", + notarizeZip, + "--key", + apiKey, + "--key-id", + apiKeyId, + "--issuer", + apiIssuer, + "--wait", + ]), + "notarytool submit", + ).pipe(Effect.ensuring(fs.remove(notarizeZip, { force: true }).pipe(Effect.ignore))); + yield* Effect.log("[cli-archive] Notarized t3."); +}); + +const WindowsSigningConfig = Config.all({ + endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT").pipe(Config.option), + accountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME").pipe(Config.option), + certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME").pipe( + Config.option, + ), +}); + +/** + * Node's --build-sea injects the blob into a copy of node.exe by rebuilding + * its resource section but, unlike its Mach-O path, leaves node's original + * Authenticode data-directory entry in the PE header. The file grows, so the + * entry now points into the middle of the new section at bytes that are not a + * certificate table. signtool refuses to sign such an image (0x800700C1, "not + * a valid Win32 application") and cannot `remove /s` it either, since the SIP + * fails to parse the garbage. Clearing the entry is exactly what a signature + * strip does, without needing a parser that trusts the broken table. + */ +const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* ( + executablePath: string, +) { + const fs = yield* FileSystem.FileSystem; + const bytes = yield* fs.readFile(executablePath); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const peOffset = view.getUint32(0x3c, true); + if (view.getUint32(peOffset, true) !== 0x00004550) { + return yield* new CliArchiveInputMissingError({ + inputPath: executablePath, + hint: "Expected a PE executable to strip the stale signature entry from.", + }); + } + const optionalHeader = peOffset + 24; + const magic = view.getUint16(optionalHeader, true); + // Data directories start at +112 (PE32+) or +96 (PE32); the certificate + // table is directory index 4, eight bytes (file offset, size). + const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8; + const offset = view.getUint32(securityEntry, true); + const size = view.getUint32(securityEntry + 4, true); + if (offset === 0 && size === 0) return; + if (offset + size === bytes.byteLength) { + // A certificate table that still ends at EOF is intact; leave it for + // signtool to replace rather than second-guessing it here. + return; + } + view.setUint32(securityEntry, 0, true); + view.setUint32(securityEntry + 4, 0, true); + yield* fs.writeFile(executablePath, bytes); + yield* Effect.log( + `[cli-archive] Cleared the stale Authenticode entry (offset ${String(offset)}, size ${String(size)}) left by --build-sea.`, + ); +}); + +/** Signs t3.exe through the same Azure Trusted Signing setup the installer uses. */ +const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* ( + executablePath: string, +) { + const signing = yield* WindowsSigningConfig; + const endpoint = Option.getOrUndefined(signing.endpoint); + const accountName = Option.getOrUndefined(signing.accountName); + const profile = Option.getOrUndefined(signing.certificateProfileName); + if (!endpoint || !accountName || !profile) { + yield* Effect.log("[cli-archive] Windows signing disabled (missing Azure Trusted Signing)."); + return; + } + yield* stripStaleAuthenticodeEntry(executablePath); + // Mirrors electron-builder's invocation for the installer: every value + // single-quoted, the file path in Windows form. `$ErrorActionPreference` + // makes a signing failure inside the cmdlet surface as a non-zero exit. + const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; + const script = [ + "$ErrorActionPreference = 'Stop';", + "Invoke-TrustedSigning", + `-Endpoint ${quote(endpoint)}`, + `-CodeSigningAccountName ${quote(accountName)}`, + `-CertificateProfileName ${quote(profile)}`, + "-FileDigest 'SHA256'", + "-TimestampRfc3161 'http://timestamp.acs.microsoft.com'", + "-TimestampDigest 'SHA256'", + `-Files ${quote(executablePath)}`, + ].join(" "); + yield* runCommand( + ChildProcess.make("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script]), + "Invoke-TrustedSigning t3.exe", + ); + yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing)."); +}); + +const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { + readonly platform: BuildPlatform; + readonly arch: BuildArch; + readonly version: string; + readonly outputDir: string; + readonly resourceMonitorDir: Option.Option; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* RepoRoot; + const serverDir = path.join(repoRoot, "apps/server"); + const executableName = input.platform === "win" ? "t3.exe" : "t3"; + // tsdown suffixes cross-built executables with their target (t3-darwin-x64); + // a host build is plain t3. Prefer the exact target when both exist. + const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`; + const targetExecutable = path.join( + serverDir, + "dist-exe", + `t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`, + ); + // The unsuffixed host build is only a valid stand-in when it was built for + // this platform and architecture; otherwise a missing target must fail. + const hostPlatform = yield* HostProcessPlatform; + const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcessArchitecture}`; + const builtExecutable = (yield* fs.exists(targetExecutable)) + ? targetExecutable + : targetKey === hostKey + ? path.join(serverDir, "dist-exe", executableName) + : targetExecutable; + const webClient = path.join(serverDir, "dist/client"); + const resourceMonitorDir = Option.getOrElse(input.resourceMonitorDir, () => + path.join(serverDir, "dist/resource-monitor"), + ); + + yield* requireInput( + builtExecutable, + `Run \`node apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`, + ); + yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first."); + yield* requireInput( + resourceMonitorDir, + "Build the resource monitor or pass --resource-monitor-dir.", + ); + + const stem = cliArchiveStem(input.version, input.platform, input.arch); + const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-archive-" }); + const contentDir = path.join(stageRoot, stem); + yield* fs.makeDirectory(contentDir, { recursive: true }); + + yield* Effect.log(`[cli-archive] Staging ${stem}...`); + yield* fs.copyFile(builtExecutable, path.join(contentDir, executableName)); + yield* stageWebClient(webClient, path.join(contentDir, "client")); + yield* fs.copy(resourceMonitorDir, path.join(contentDir, "resource-monitor")); + yield* stageRuntimeExternals({ + repoRoot, + stageDir: contentDir, + platform: input.platform, + arch: input.arch, + version: input.version, + }); + + const executablePath = path.join(contentDir, executableName); + if (input.platform === "mac") { + yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); + } else if (input.platform === "win") { + yield* signWindowsExecutable(executablePath); + } + if (input.platform !== "win") { + yield* fs.chmod(executablePath, 0o755); + } + + yield* fs.makeDirectory(input.outputDir, { recursive: true }); + const archivePath = path.join( + input.outputDir, + cliArchiveFileName(input.version, input.platform, input.arch), + ); + yield* fs.remove(archivePath, { force: true }); + if (input.platform === "win") { + // Windows ships bsdtar, which writes zip natively. Name it by path: under + // the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither + // writes zip nor accepts a drive-letter path. + yield* runCommand( + ChildProcess.make(windowsSystemTar(), ["-a", "-c", "-f", archivePath, "-C", stageRoot, stem]), + "tar (zip)", + ); + } else { + yield* runCommand( + ChildProcess.make("tar", ["-czf", archivePath, "-C", stageRoot, stem]), + "tar (gzip)", + ); + } + const stat = yield* fs.stat(archivePath); + yield* Effect.log(`[cli-archive] Wrote ${archivePath} (${String(stat.size)} bytes).`); + return archivePath; +}); + +const command = Command.make( + "build-cli-archive", + { + platform: Flag.choice("platform", BuildPlatform.literals), + arch: Flag.choice("arch", BuildArch.literals), + version: Flag.string("version").pipe( + Flag.withDescription("Release version for the archive name."), + ), + outputDir: Flag.string("output-dir").pipe(Flag.withDefault("release-cli")), + resourceMonitorDir: Flag.string("resource-monitor-dir").pipe( + Flag.withDescription( + "Directory laid out like dist/resource-monitor (defaults to apps/server/dist/resource-monitor).", + ), + Flag.optional, + ), + }, + (input) => buildCliArchive(input).pipe(Effect.scoped), +).pipe(Command.withDescription("Package the t3 single-executable into a per-platform archive.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, + ); +} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 04be06755750..610df8013e1c 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -350,7 +350,18 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, ); + const previewChannel = yield* createBuildConfig( + "mac", + "dmg", + "0.0.41-preview.20260912.1589", + false, + false, + undefined, + undefined, + ); + assert.notProperty(preview, "publish"); + assert.notProperty(previewChannel, "publish"); assert.deepStrictEqual(release.publish, [ { provider: "github", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 2069b0c836ad..84e8f9f31494 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2616,8 +2616,15 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } -function isDesktopPreviewVersion(version: string): boolean { - return /-pr\./.test(version); +// Pull request builds (`-pr..`) and the temporary preview train +// (`-preview..`) are downloaded by hand and never through an +// updater. Building them without a publish config means electron-builder +// emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and +// the app ships without `app-update.yml`, so neither a stable nor a nightly +// install can be pointed at one of these releases, and the build itself +// reports that no update feed is configured instead of polling. +export function isDesktopPreviewVersion(version: string): boolean { + return /-pr\./.test(version) || /-preview\.\d{8}\.\d+$/.test(version); } export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts index 8265ad681802..5403fe30e2a4 100644 --- a/scripts/lib/brand-assets.test.ts +++ b/scripts/lib/brand-assets.test.ts @@ -81,6 +81,7 @@ describe("brand-assets", () => { it("maps package versions to web asset brands", () => { expect(resolveWebAssetBrandForPackageVersion("0.0.29")).toBe("production"); expect(resolveWebAssetBrandForPackageVersion("0.0.29-nightly.20260723.882")).toBe("nightly"); + expect(resolveWebAssetBrandForPackageVersion("0.0.29-preview.20260723.882")).toBe("nightly"); }); it("keeps development, nightly, and production icon families separate", () => { diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 2dcc6ccd6ce9..5bbcb0ef27bf 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -42,7 +42,7 @@ export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAss } export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand { - return version.includes("-nightly.") ? "nightly" : "production"; + return /^[^-+]+-(?:nightly|preview)\./.test(version) ? "nightly" : "production"; } export interface IconOverride { diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index dda1e7081be0..9eab67b28956 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -52,6 +52,19 @@ it("derives nightly metadata including the short commit sha in the release name" ); }); +it("derives preview metadata under its own prerelease identifier", () => { + assert.deepStrictEqual( + resolveNightlyReleaseMetadata("9.9.10", "20260413", 321, "abcdef1234567890", "preview"), + { + baseVersion: "9.9.10", + version: "9.9.10-preview.20260413.321", + tag: "v9.9.10-preview.20260413.321", + name: "T3 Code Preview (maintainer test build, do not install) 9.9.10-preview.20260413.321 (abcdef123456)", + shortSha: "abcdef123456", + }, + ); +}); + it.effect("preserves the GITHUB_OUTPUT configuration cause", () => { const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890"); const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" }); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index 672e61aedb42..08e48550e610 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -96,19 +96,32 @@ export const resolveNightlyTargetVersion = (version: string) => { return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`); }; +/** Prerelease trains that share nightly's date-and-run versioning. */ +export const PrereleaseChannel = Schema.Literals(["nightly", "preview"]); +export type PrereleaseChannel = typeof PrereleaseChannel.Type; + +// The preview label is deliberately loud: the releases page is the one place +// a preview build can be found, and its name is the first thing a visitor +// reads before the warning in the body. +const CHANNEL_RELEASE_LABELS: Record = { + nightly: "Nightly", + preview: "Preview (maintainer test build, do not install)", +}; + export const resolveNightlyReleaseMetadata = ( baseVersion: string, date: string, runNumber: number, sha: string, + channel: PrereleaseChannel = "nightly", ) => { const shortSha = sha.slice(0, 12); - const version = `${baseVersion}-nightly.${date}.${runNumber}`; + const version = `${baseVersion}-${channel}.${date}.${runNumber}`; return { baseVersion, version, tag: `v${version}`, - name: `T3 Code Nightly ${version} (${shortSha})`, + name: `T3 Code ${CHANNEL_RELEASE_LABELS[channel]} ${version} (${shortSha})`, shortSha, }; }; @@ -198,6 +211,10 @@ const command = Command.make( Flag.withSchema(ShaSchema), Flag.withDescription("Commit sha for the nightly build."), ), + channel: Flag.choice("channel", PrereleaseChannel.literals).pipe( + Flag.withDescription("Prerelease channel whose identifier the version carries."), + Flag.withDefault("nightly" as const), + ), githubOutput: Flag.boolean("github-output").pipe( Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."), Flag.withDefault(false), @@ -207,9 +224,11 @@ const command = Command.make( Flag.optional, ), }, - ({ date, runNumber, sha, githubOutput, root }) => + ({ date, runNumber, sha, channel, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( - Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), + Effect.map((baseVersion) => + resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha, channel), + ), Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), ).pipe(Command.withDescription("Resolve nightly release version metadata.")); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts index 5fe06d2af54b..1cffc47aac70 100644 --- a/scripts/resolve-previous-release-tag.test.ts +++ b/scripts/resolve-previous-release-tag.test.ts @@ -65,6 +65,23 @@ it.effect("accepts legacy nightly tags when selecting the previous nightly", () }), ); +it.effect("keeps preview tags in their own series", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("preview", "v1.2.0-preview.20260620.2", [ + "v1.2.0-nightly.20260620.3", + "v1.2.0-preview.20260620.1", + "v1.1.9", + ]); + assert.equal(previous, "v1.2.0-preview.20260620.1"); + + const stable = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [ + "v1.1.9", + "v1.2.0-preview.20260620.1", + ]); + assert.equal(stable, "v1.1.9"); + }), +); + it.effect("reports the invalid tag with its release channel", () => Effect.gen(function* () { const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 43b3901af810..080d93cf7d6a 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -11,7 +11,7 @@ import * as String from "effect/String"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -const ReleaseChannel = Schema.Literals(["stable", "nightly"]); +const ReleaseChannel = Schema.Literals(["stable", "nightly", "preview"]); type ReleaseChannel = typeof ReleaseChannel.Type; export class InvalidReleaseTagError extends Schema.TaggedError()( @@ -151,10 +151,12 @@ const parseStableTag = (tag: string): StableVersion | undefined => { if (!major || !minor || !patch) return undefined; const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; - // Nightly tags also start with `v` and carry a `nightly.*` prerelease - // identifier. They must not be considered stable candidates when resolving - // the previous stable tag. - if (prereleaseIdentifiers[0] === "nightly") return undefined; + // Nightly and preview tags also start with `v` and carry their channel as + // the prerelease identifier. They must not be considered stable candidates + // when resolving the previous stable tag. + if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") { + return undefined; + } return { major: Number(major), @@ -172,10 +174,15 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu return left.runNumber - right.runNumber; }; -const parseNightlyTag = (tag: string): NightlyVersion | undefined => { +const parseNightlyTag = ( + tag: string, + channel: "nightly" | "preview" = "nightly", +): NightlyVersion | undefined => { // Accept both the current `v` format and the legacy `nightly-v` // format so release note diffs keep working across the tag-format transition. - const match = /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); + const match = new RegExp( + `^(?:nightly-)?v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`, + ).exec(tag); if (!match) return undefined; const [, major, minor, patch, date, runNumber] = match; @@ -213,13 +220,13 @@ export const resolvePreviousReleaseTag = ( return candidates[0]?.tag; } - const current = parseNightlyTag(currentTag); + const current = parseNightlyTag(currentTag, channel); if (!current) { return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) + .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) })) .filter( (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, ) diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts new file mode 100644 index 000000000000..e24aded91314 --- /dev/null +++ b/scripts/smoke-cli-archive.ts @@ -0,0 +1,198 @@ +#!/usr/bin/env node +/** + * Unpacks a CLI archive into a scratch directory and runs the executable the + * way an installer would: no repo, no node_modules, no Node on PATH. Catches + * the failures that only show inside the single-executable, such as an + * external package reached through `import` or a native addon the hardened + * runtime refuses to load. + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { windowsSystemTar } from "./build-cli-archive.ts"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +export class CliArchiveSmokeError extends Schema.TaggedError()( + "CliArchiveSmokeError", + { step: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `CLI archive smoke test failed while ${this.step}: ${this.detail}`; + } +} + +const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const runExecutable = Effect.fn("runExecutable")(function* ( + executable: string, + args: ReadonlyArray, + cwd: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(executable, args, { + cwd, + // Empty PATH: the archive must not reach a system node, and the + // launcher context must not leak in from a developer shell. + env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd }, + extendEnv: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; +}); + +const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { + readonly archive: string; + readonly expectVersion: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-smoke-" }); + + // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU + // tar; use the bsdtar Windows ships, which reads both formats. + const tar = platform === "win32" ? windowsSystemTar() : "tar"; + const extract = yield* spawner + .spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch])) + .pipe(Effect.flatMap((child) => child.exitCode)); + if (Number(extract) !== 0) { + return yield* new CliArchiveSmokeError({ + step: "extracting the archive", + detail: `tar exited with ${String(extract)}`, + }); + } + const [root] = yield* fs.readDirectory(scratch); + if (root === undefined) { + return yield* new CliArchiveSmokeError({ + step: "extracting the archive", + detail: "the archive was empty", + }); + } + const contentDir = path.join(scratch, root); + const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3"); + for (const required of [executable, path.join(contentDir, "client/index.html")]) { + if (!(yield* fs.exists(required))) { + return yield* new CliArchiveSmokeError({ + step: "checking the archive layout", + detail: `missing ${path.relative(contentDir, required)}`, + }); + } + } + + const version = yield* runExecutable(executable, ["--version"], contentDir); + if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) { + return yield* new CliArchiveSmokeError({ + step: "running --version", + detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`, + }); + } + + // Starting the server is what actually opens sqlite, loads the terminal + // and search stacks (node-pty, fff, msgpackr-extract), and serves the + // client, so probe a real `serve` in a scratch home rather than a + // command that only reads package metadata. + const net = yield* NetService.NetService; + const port = yield* net.findAvailablePort(47700); + const home = path.join(scratch, "home"); + const server = yield* spawner.spawn( + ChildProcess.make( + executable, + ["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"], + { + cwd: contentDir, + env: { + PATH: "", + HOME: home, + USERPROFILE: home, + TMPDIR: scratch, + TEMP: scratch, + T3CODE_HOME: home, + }, + extendEnv: false, + }, + ), + ); + const output = yield* Effect.forkScoped( + Effect.all([collect(server.stdout), collect(server.stderr)]), + ); + const httpClient = yield* HttpClient.HttpClient; + // A request that connects while the server is still initializing can hang, + // so each probe gets its own deadline, like the SSH readiness probe. + const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe( + Effect.map((response) => response.status === 200), + Effect.timeout(Duration.seconds(2)), + Effect.orElseSucceed(() => false), + ); + const pollUntilReady = Effect.gen(function* () { + while (!(yield* probe)) { + yield* Effect.sleep(Duration.millis(250)); + } + return true; + }); + const ready = yield* pollUntilReady.pipe( + Effect.timeout(Duration.seconds(30)), + Effect.orElseSucceed(() => false), + ); + yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore); + yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore); + const [stdout, stderr] = yield* Fiber.join(output).pipe( + Effect.timeout(Duration.seconds(5)), + Effect.orElseSucceed(() => ["", ""] as const), + ); + if (!ready) { + return yield* new CliArchiveSmokeError({ + step: "serving from the extracted archive", + detail: `no 200 from / within 30s\n${stdout}${stderr}`, + }); + } + yield* Effect.log(`[cli-smoke] ${root}: --version passed and serve answered on ${String(port)}.`); +}); + +const command = Command.make( + "smoke-cli-archive", + { + archive: Flag.string("archive"), + expectVersion: Flag.string("expect-version"), + }, + (input) => smokeCliArchive(input).pipe(Effect.scoped), +).pipe(Command.withDescription("Extract a CLI archive and run its executable.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + NetService.layer, + FetchHttpClient.layer, + ), + ), + NodeRuntime.runMain, + ); +} From 8f90b380f676735f1e3026b5173c45c22d265583 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:21 -0700 Subject: [PATCH 11/27] feat(server): install preview runtimes from release archives (#11318) Co-authored-by: Claude Fable 5 --- apps/marketing/public/install.ps1 | 95 ++++++ apps/marketing/public/install.sh | 104 +++++++ apps/marketing/src/pages/download.astro | 9 + apps/marketing/vercel.ts | 18 ++ apps/server/src/bin.ts | 4 + apps/server/src/claude-history-worker.ts | 7 + apps/server/src/claudeHistoryWorker.ts | 40 ++- apps/server/src/cli/claudeHistory.ts | 27 ++ apps/server/src/cli/service.test.ts | 2 +- apps/server/src/cli/service.ts | 7 +- apps/server/src/cli/serviceLauncher.ts | 28 ++ apps/server/src/cloud/bootService.test.ts | 23 +- apps/server/src/cloud/bootService.ts | 57 +++- apps/server/src/cloud/pinnedRuntime.test.ts | 143 ++++++++- apps/server/src/cloud/pinnedRuntime.ts | 274 ++++++++++++++---- apps/server/src/cloud/selfUpdate.ts | 28 +- .../src/provider/Layers/ClaudeAdapter.ts | 31 +- apps/server/src/service-launcher.ts | 21 +- apps/server/src/serviceLauncher.ts | 52 ++-- apps/server/vite.config.ts | 2 +- docs/user/background-service.md | 15 + knip.jsonc | 2 +- packages/shared/package.json | 4 + packages/shared/src/cliRelease.test.ts | 69 +++++ packages/shared/src/cliRelease.ts | 76 +++++ 25 files changed, 1016 insertions(+), 122 deletions(-) create mode 100644 apps/marketing/public/install.ps1 create mode 100755 apps/marketing/public/install.sh create mode 100644 apps/server/src/claude-history-worker.ts create mode 100644 apps/server/src/cli/claudeHistory.ts create mode 100644 apps/server/src/cli/serviceLauncher.ts create mode 100644 packages/shared/src/cliRelease.test.ts create mode 100644 packages/shared/src/cliRelease.ts diff --git a/apps/marketing/public/install.ps1 b/apps/marketing/public/install.ps1 new file mode 100644 index 000000000000..0900bd01c1c9 --- /dev/null +++ b/apps/marketing/public/install.ps1 @@ -0,0 +1,95 @@ +# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs +# only PowerShell 5.1+; no Node, npm, or compiler. +# +# irm https://t3.codes/install.ps1 | iex +# +# Environment: +# T3CODE_VERSION exact version to install (default: latest preview release) +# T3CODE_HOME T3 home directory (default: ~\.t3) +# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) +# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# +# The archive is unpacked into $T3CODE_HOME\runtime\versions\, the +# same layout `t3 service install` uses, so the service reuses this download. +$ErrorActionPreference = "Stop" +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$repo = "pingdotgg/t3code" +$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } +$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } +$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } + +function Fail([string] $message) { + Write-Error "t3 install: $message" + exit 1 +} + +# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell +# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts +# may lack. +$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +$arch = switch ($rawArch) { + "AMD64" { "x64" } + "ARM64" { "arm64" } + default { Fail "unsupported architecture $rawArch" } +} + +$version = $env:T3CODE_VERSION +if (-not $version) { + # Preview is the only train shipping archives while they are being dogfooded. + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=50" -Headers @{ "User-Agent" = "t3-install" } + $tag = ($releases | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | Select-Object -First 1).tag_name + if (-not $tag) { Fail "could not find a preview release; set T3CODE_VERSION" } + $version = $tag.Substring(1) +} + +$stem = "t3-$version-win32-$arch" +$archive = "$stem.zip" +$versionsDir = Join-Path $t3Home "runtime\versions" +$targetDir = Join-Path $versionsDir $version +$marker = Join-Path $targetDir ".install-complete" + +if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { + Write-Host "t3 $version is already installed at $targetDir" +} else { + New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null + $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $staging | Out-Null + try { + Write-Host "Downloading $archive..." + Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing + Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing + + $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) + if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } + $expected = ($expected -split "\s+")[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } + + Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force + # The archive wraps everything in one directory named after its stem. + Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging + Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force + + & (Join-Path $staging "t3.exe") --version | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } + Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline + + if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } + Move-Item $staging $targetDir + } catch { + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } + throw + } +} + +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +$shim = Join-Path $binDir "t3.cmd" +# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt +# non-ASCII characters in the user's home path. +[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) +Write-Host "Installed t3 $version" +Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" +if (($env:PATH -split ";") -notcontains $binDir) { + Write-Host "Add $binDir to your PATH to run t3." +} diff --git a/apps/marketing/public/install.sh b/apps/marketing/public/install.sh new file mode 100755 index 000000000000..d7ddc0d1de46 --- /dev/null +++ b/apps/marketing/public/install.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, +# sha256sum or shasum, and curl or wget; no Node, npm, or compiler. +# +# curl -fsSL https://t3.codes/install.sh | sh +# +# Environment: +# T3CODE_VERSION exact version to install (default: latest preview release) +# T3CODE_HOME T3 home directory (default: ~/.t3) +# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) +# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# +# The archive is unpacked into $T3CODE_HOME/runtime/versions/, the +# same layout `t3 service install` uses, so the service reuses this download +# instead of fetching the release again. +set -eu + +repo="pingdotgg/t3code" +base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" +t3_home="${T3CODE_HOME:-$HOME/.t3}" +bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" + +fail() { + printf 't3 install: %s\n' "$1" >&2 + exit 1 +} + +fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -q "$1" -O "$2" + else + fail "curl or wget is required" + fi +} + +case "$(uname -s)" in + Darwin) platform="darwin" ;; + Linux) platform="linux" ;; + *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; +esac +case "$(uname -m)" in + arm64 | aarch64) arch="arm64" ;; + x86_64 | amd64) arch="x64" ;; + *) fail "unsupported architecture $(uname -m)" ;; +esac +command -v tar >/dev/null 2>&1 || fail "tar is required" +if command -v sha256sum >/dev/null 2>&1; then + checksum() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + fail "sha256sum or shasum is required" +fi + +version="${T3CODE_VERSION:-}" +if [ -z "$version" ]; then + # Preview is the only train shipping archives while they are being dogfooded. + tmp_index="$(mktemp)" + fetch "https://api.github.com/repos/${repo}/releases?per_page=50" "$tmp_index" + version="$(sed -n 's/.*"tag_name": *"v\([0-9][^"]*-preview\.[0-9]*\.[0-9]*\)".*/\1/p' "$tmp_index" | head -n 1)" + rm -f "$tmp_index" + [ -n "$version" ] || fail "could not find a preview release; set T3CODE_VERSION" +fi + +stem="t3-${version}-${platform}-${arch}" +archive="${stem}.tar.gz" +versions_dir="${t3_home}/runtime/versions" +target_dir="${versions_dir}/${version}" + +if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then + printf 't3 %s is already installed at %s\n' "$version" "$target_dir" +else + mkdir -p "$versions_dir" + staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" + trap 'rm -rf "$staging"' EXIT + + printf 'Downloading %s...\n' "$archive" + fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" + fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" + + expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" + [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" + actual="$(checksum "${staging}/${archive}")" + [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" + + tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 + rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" + "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" + printf '%s\n' "$version" > "${staging}/.install-complete" + + rm -rf "$target_dir" + mv "$staging" "$target_dir" + trap - EXIT +fi + +mkdir -p "$bin_dir" +ln -sfn "${target_dir}/t3" "${bin_dir}/t3" +printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" +case ":${PATH}:" in + *":${bin_dir}:"*) ;; + *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; +esac diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 08a1c008453e..95c6f513f665 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -132,6 +132,9 @@ const imageProps = {

      Terminal

      npx t3@nightly +

      No Node.js? The preview build installs as a single download:

      + curl -fsSL https://t3.codes/install.sh | sh + irm https://t3.codes/install.ps1 | iex
      @@ -432,6 +435,12 @@ const imageProps = { letter-spacing: -0.01em; } + .cli-note { + color: var(--fg-muted); + font-size: 0.85rem; + margin-top: 0.5rem; + } + .cli-line { align-self: flex-start; font-family: var(--font-mono); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index e37be215f1a0..1ec71b1c7115 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -7,6 +7,24 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", + // `curl … | sh` needs the scripts served as plain text, uncompressed by + // content negotiation, and never cached past a deploy. + headers: [ + { + source: "/install.sh", + headers: [ + { key: "Content-Type", value: "text/x-shellscript; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + { + source: "/install.ps1", + headers: [ + { key: "Content-Type", value: "text/plain; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + ], redirects: [ { source: "/app", diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 52cc363ed04f..9f9d4645c9d0 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,8 @@ import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; +import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -59,6 +61,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + serviceLauncherCommand, + claudeHistoryCommand, servicePreflightCommand, themeCommand, triageCommand, diff --git a/apps/server/src/claude-history-worker.ts b/apps/server/src/claude-history-worker.ts new file mode 100644 index 000000000000..2554401dbd64 --- /dev/null +++ b/apps/server/src/claude-history-worker.ts @@ -0,0 +1,7 @@ +// Standalone entry for npm-distributed runtimes: `node claudeHistoryWorker.mjs +// [options]`. The executable reaches the same worker +// through the `__claude-history` subcommand. +import { runClaudeHistoryWorker } from "./claudeHistoryWorker.ts"; + +const [method, sessionId, rawOptions] = process.argv.slice(2); +await runClaudeHistoryWorker(method, sessionId, rawOptions); diff --git a/apps/server/src/claudeHistoryWorker.ts b/apps/server/src/claudeHistoryWorker.ts index d00282bb77f0..f09e0dd78435 100644 --- a/apps/server/src/claudeHistoryWorker.ts +++ b/apps/server/src/claudeHistoryWorker.ts @@ -2,9 +2,13 @@ import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk" import * as Schema from "effect/Schema"; // A separate process gives SDK history helpers the provider's environment without -// mutating the server's environment. This entry is bundled alongside the server. -const [method, sessionId, rawOptions] = process.argv.slice(2); -const options = Schema.decodeSync( +// mutating the server's environment. `claude-history-worker.ts` is the +// standalone entry bundled beside the server for npm installs; the +// single-executable hosts the same function as its `__claude-history` +// subcommand, which has no Node to run a sibling script. Nothing here may run +// on import: inside the executable `import.meta.main` is true for the whole +// bundle. +const decodeHistoryOptions = Schema.decodeSync( Schema.fromJsonString( Schema.Struct({ dir: Schema.optionalKey(Schema.String), @@ -12,14 +16,22 @@ const options = Schema.decodeSync( upToMessageId: Schema.optionalKey(Schema.String), }), ), -)(rawOptions ?? "{}"); -if (!sessionId) throw new Error("Claude history session id is required."); -const result = - method === "getSessionMessages" - ? await getSessionMessages(sessionId, options) - : method === "forkSession" - ? await forkSession(sessionId, options) - : (() => { - throw new Error("Unknown Claude history operation."); - })(); -process.stdout.write(JSON.stringify(result)); +); + +export async function runClaudeHistoryWorker( + method: string | undefined, + sessionId: string | undefined, + rawOptions: string | undefined, +): Promise { + const options = decodeHistoryOptions(rawOptions ?? "{}"); + if (!sessionId) throw new Error("Claude history session id is required."); + const result = + method === "getSessionMessages" + ? await getSessionMessages(sessionId, options) + : method === "forkSession" + ? await forkSession(sessionId, options) + : (() => { + throw new Error("Unknown Claude history operation."); + })(); + process.stdout.write(JSON.stringify(result)); +} diff --git a/apps/server/src/cli/claudeHistory.ts b/apps/server/src/cli/claudeHistory.ts new file mode 100644 index 000000000000..81bef1359703 --- /dev/null +++ b/apps/server/src/cli/claudeHistory.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/unstable/cli"; + +import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts"; + +/** + * Hosts the Claude history worker inside the CLI executable. The npm bundle + * runs it as a sibling `claudeHistoryWorker.mjs` under the host Node; the + * single-executable has no Node to run a script with, so the adapter invokes + * this hidden subcommand on its own executable instead. + */ +export const claudeHistoryCommand = Command.make("__claude-history", { + method: Argument.string("method"), + sessionId: Argument.string("session-id"), + options: Argument.string("options").pipe(Argument.optional), +}).pipe( + Command.unlisted, + Command.withHandler(({ method, sessionId, options }) => + Effect.promise(() => + runClaudeHistoryWorker( + method, + sessionId, + options._tag === "Some" ? options.value : undefined, + ), + ), + ), +); diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 38732e42987a..b682c4ce44ce 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -105,7 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { Effect.sync(() => { installOptions.push(options); return { - nodePath: "/test/node", + program: ["/test/node", "/test/service-launcher.mjs"], launcherPath: "/test/service-launcher.mjs", baseDir: "/test/t3", unitPath: serviceStatus.unitPath, diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 0cea18ff4977..5b53e81769b8 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; @@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) = baseDir: config.baseDir, logsDir: config.logsDir, cliVersion: packageJson.version, - }).pipe(Layer.provide(ProcessRunner.layer)); + }).pipe( + Layer.provide(ProcessRunner.layer), + // Archive-distributed versions download the release archive here. + Layer.provide(FetchHttpClient.layer), + ); export type ServiceReconcileResult = | { diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts new file mode 100644 index 000000000000..696fc55c45f0 --- /dev/null +++ b/apps/server/src/cli/serviceLauncher.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import { Command } from "effect/unstable/cli"; + +import { main as runServiceLauncher } from "../serviceLauncher.ts"; + +/** + * Hosts the service launcher inside the CLI executable. Archive-distributed + * runtimes have no Node on the machine to run `service-launcher.mjs`, so the + * service manager runs `t3 __service-launcher` and the launcher spawns the + * server from the same executable. + * + * The launcher owns SIGTERM handling and the process lifetime: it must finish + * stopping its child before the process exits, so it runs detached from the + * CLI's fiber rather than under `runMain`, whose signal handler would + * interrupt the fiber and exit while the child is still being terminated. + */ +export const serviceLauncherCommand = Command.make("__service-launcher").pipe( + Command.unlisted, + Command.withHandler(() => + Effect.sync(() => { + runServiceLauncher().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); + }), + ), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 688617440500..d1e5011f2a3a 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -25,7 +25,7 @@ import { it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/bin/node", + program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", @@ -37,9 +37,24 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", expect(unit).not.toContain("versions/1.2.3"); }); +it("runs archive-distributed runtimes as their own executable", () => { + const unit = BootService.renderBootServiceUnit({ + program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], + launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + expect(unit).toContain( + "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher", + ); + expect(unit).not.toContain("node"); +}); + it("survives the kernel OOM-killing a greedy agent child", () => { const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/bin/node", + program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", @@ -50,7 +65,7 @@ it("survives the kernel OOM-killing a greedy agent child", () => { }); const macPlan = { - nodePath: "/opt/homebrew/bin/node", + program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", @@ -116,7 +131,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const sourceLauncher = path.join(home, "service-launcher.mjs"); const statePath = path.join(baseDir, "runtime", "service-state.json"); yield* fs.writeFileString(sourceLauncher, "export {};\n"); - const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); yield* fs.writeFileString(runtime.entryPath, "export {};\n"); yield* fs.writeFileString( diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 22f383701e8e..805ddb8ec320 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,4 +1,5 @@ import { + HostProcessArchitecture, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,11 +13,15 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; import * as Schema from "effect/Schema"; +import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; + import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; @@ -52,7 +57,13 @@ function quoteSystemdValue(value: string): string { } export interface BootServicePlan { - readonly nodePath: string; + /** + * What the service manager executes. npm-distributed runtimes run the + * standalone launcher script with the installing Node; archive-distributed + * runtimes run their own executable, which hosts the launcher as a hidden + * subcommand so the machine never needs Node. + */ + readonly program: ReadonlyArray; readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; @@ -73,7 +84,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, - `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, + `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", @@ -124,8 +135,7 @@ export function renderBootServicePlist( ` ${BOOT_SERVICE_LAUNCHD_LABEL}`, ` ProgramArguments`, ` `, - ` ${escapeXmlText(plan.nodePath)}`, - ` ${escapeXmlText(plan.launcherPath)}`, + ...plan.program.map((argument) => ` ${escapeXmlText(argument)}`), ` `, ` EnvironmentVariables`, ` `, @@ -505,7 +515,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }) { const hostExecPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; const uid = yield* HostProcessUserId; + // Archive-distributed versions download from GitHub Releases; npm versions + // never touch HTTP, so callers without a client still work. + const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const releaseBaseUrl = Option.getOrUndefined( + yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), + ); const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; @@ -544,7 +561,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); - const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); const launcherSourcePath = host.launcherSourcePath ?? path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); @@ -568,7 +585,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const plan: BootServicePlan = { - nodePath: host.execPath, + program: + runtimePaths.layout === "archive" + ? [runtimePaths.entryPath, "__service-launcher"] + : [host.execPath, launcherPath], launcherPath, baseDir: input.baseDir, logPath, @@ -708,11 +728,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs, path, runner, + httpClient, + platform, + arch, + releaseBaseUrl, validate: (runtime) => runner .run({ - command: host.execPath, - args: [runtime.entryPath, "--version"], + command: pinnedRuntimeCommand(runtime, host.execPath).command, + args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], timeout: Duration.seconds(30), }) .pipe( @@ -750,9 +774,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : new BootServiceInstallError({ cause: error }), ), ); - const launcherSource = yield* fs - .readFileString(launcherSourcePath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // Archive runtimes host the launcher in the executable itself; there is + // no standalone script to copy. + const launcherSource = + runtimePaths.layout === "archive" + ? undefined + : yield* fs + .readFileString(launcherSourcePath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const installed = yield* fs .exists(unitPath) @@ -786,7 +815,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - yield* writeDurably(launcherPath, launcherSource); + if (launcherSource !== undefined) { + yield* writeDurably(launcherPath, launcherSource); + } yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. @@ -836,7 +867,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ fs.readFileString(unitPath), - fs.exists(launcherPath), + runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), fs.exists(runtimePaths.entryPath), fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), fs.readFileString(statePath).pipe(Effect.option), diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index a0ca9e5f0fa0..b542907b4d01 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -6,11 +6,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; @@ -51,6 +53,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: ProcessRunner.ProcessRunner.of({ run: (input) => { commands.push(input); @@ -95,6 +99,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: ProcessRunner.ProcessRunner.of({ run: (input) => { commands.push(input.command); @@ -122,7 +128,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); let validatedDirectory = ""; const installed = yield* ensurePinnedRuntimeInstalled({ @@ -130,6 +136,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: (staging) => Effect.gen(function* () { @@ -151,13 +159,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* ensurePinnedRuntimeInstalled({ baseDir, version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: () => Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), @@ -178,7 +188,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); @@ -187,6 +197,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: () => Effect.void, }); @@ -201,7 +213,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: (paths) => Effect.gen(function* () { @@ -242,6 +256,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner, validate: () => Effect.void, }).pipe(Effect.forkScoped); @@ -252,4 +268,123 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(yield* fs.readDirectory(versionsDir), []); }), ); + + // Archive-distributed versions never touch npm: the release archive is + // fetched, checked against SHA256SUMS, and unpacked with tar. + const archiveVersion = "1.3.0-preview.20260911.7"; + const archiveName = `t3-${archiveVersion}-linux-x64.tar.gz`; + const archiveBytes = new TextEncoder().encode("not really a tarball"); + const archiveHex = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); + const releaseHttpClient = (checksums: string, requests: string[]) => + HttpClient.make((request) => { + requests.push(request.url); + const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); + }); + const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[]) => + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + commands.push(input.command); + const targetIndex = input.args.indexOf("-C"); + const stagingDir = input.args[targetIndex + 1]; + if (input.command !== "tar" || stagingDir === undefined) { + return yield* Effect.die(`unexpected command ${input.command}`); + } + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + + it.effect("installs archive-distributed versions from the verified release archive", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); + const requests: string[] = []; + const commands: string[] = []; + const checksums = `${yield* archiveHex(archiveBytes)} ${archiveName}\n`; + const paths = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: archiveVersion, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(checksums, requests), + releaseBaseUrl: "https://releases.example/download", + runner: extractingRunner(fs, path, commands), + validate: (staging) => + fs.exists(staging.entryPath).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), + Effect.orDie, + ), + }); + assert.equal(paths.layout, "archive"); + assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); + assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { + command: paths.entryPath, + args: [], + }); + assert.deepEqual(requests, [ + `https://releases.example/download/v${archiveVersion}/SHA256SUMS`, + `https://releases.example/download/v${archiveVersion}/${archiveName}`, + ]); + assert.deepEqual(commands, ["tar"]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), `${archiveVersion}\n`); + assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); + }), + ); + + it.effect("refuses an archive whose checksum does not match the release", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); + const commands: string[] = []; + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: archiveVersion, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`, []), + runner: extractingRunner(fs, path, commands), + validate: () => Effect.die("must not validate an unverified archive"), + }).pipe(Effect.flip); + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal(error.step, "verifying the t3 release archive checksum"); + assert.deepEqual(commands, []); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); + }), + ); + + it("runs npm layouts through the host Node", () => { + const paths = pinnedRuntimePaths( + { join: (...parts: string[]) => parts.join("/") } as Path.Path, + "/home/theo/.t3", + "1.2.3", + "linux", + ); + assert.equal(paths.layout, "npm"); + assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { + command: "/usr/bin/node", + args: ["/home/theo/.t3/runtime/versions/1.2.3/node_modules/t3/dist/bin.mjs"], + }); + }); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 534ed917218f..c4e02e41ddf7 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -1,44 +1,91 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + CLI_RELEASE_CHECKSUMS_FILE, + cliArchiveFileName, + cliArchivePlatformKey, + cliArchiveTarCommand, + cliReleaseDownloadBaseUrl, + isArchiveDistributedVersion, + parseChecksums, +} from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; /** - * A pinned runtime is an exact `t3@` npm-installed into + * A pinned runtime is an exact `t3@` installed into * /runtime/versions/. The boot service points its unit or * launch agent here, and server self-update installs the target version here before * switching over, never `npx t3`, whose cache is ephemeral and whose * registry fetch at boot would make startup depend on the network. + * + * Two layouts exist. npm-distributed versions are `npm install`ed and run as + * ` node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the + * self-contained release archive unpacked in place and run as `./t3`, which + * needs neither Node nor npm on the machine. The layout is decided by the + * version string alone so every consumer agrees without probing the disk. */ const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; // Boot-service setup and remote update can construct separate layers. Serialize // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); +export type PinnedRuntimeLayout = "npm" | "archive"; + export interface PinnedRuntimePaths { + readonly layout: PinnedRuntimeLayout; readonly versionDir: string; + /** + * `bin.mjs` for npm layouts, the executable itself for archives. Existence + * of this file is what marks a runtime as present. + */ readonly entryPath: string; readonly sentinelPath: string; } +/** The exact command that runs a pinned runtime, given the Node hosting the caller. */ +export function pinnedRuntimeCommand( + paths: PinnedRuntimePaths, + nodePath: string, +): { readonly command: string; readonly args: ReadonlyArray } { + return paths.layout === "archive" + ? { command: paths.entryPath, args: [] } + : { command: nodePath, args: [paths.entryPath] }; +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, version: string, + platform: NodeJS.Platform, ): PinnedRuntimePaths { const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + const sentinelPath = path.join(versionDir, ".install-complete"); + if (isArchiveDistributedVersion(version)) { + return { + layout: "archive", + versionDir, + entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), + sentinelPath, + }; + } return { + layout: "npm", versionDir, entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), - sentinelPath: path.join(versionDir, ".install-complete"), + sentinelPath, }; } @@ -74,9 +121,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError` into the pinned runtime directory unless a complete * install is already there, and returns its paths. The sentinel is written - * only after npm exits 0; checking the entry file alone is not enough. npm - * extracts files before running native builds (node-pty), so a killed - * install leaves a plausible-looking but broken tree behind. + * only after the install step exits 0; checking the entry file alone is not + * enough. npm extracts files before running native builds (node-pty), and tar + * writes the executable before the last native package, so a killed install + * leaves a plausible-looking but broken tree behind. */ interface PinnedRuntimeInstallInput { readonly baseDir: string; @@ -87,13 +135,175 @@ interface PinnedRuntimeInstallInput { readonly validate: ( paths: PinnedRuntimePaths, ) => Effect.Effect; + readonly platform: NodeJS.Platform; + readonly arch: string; + /** Archive-distributed versions download from here; npm versions never need it. */ + readonly httpClient?: HttpClient.HttpClient | undefined; + readonly releaseBaseUrl?: string | undefined; } +const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* ( + input: PinnedRuntimeInstallInput, + stagingDir: string, +) { + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ]; + yield* input.runner + .run({ + command: "npm", + args: installArgs, + // Native dependencies may compile from source on slower machines. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? // pnpm-managed Node installations do not include npm. Keep npm + // installation semantics for the pinned runtime and native builds. + input.runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); +}); + +const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( + httpClient: HttpClient.HttpClient, + url: string, + step: string, +) { + // The install lock is held for the whole transaction, so a stalled download + // must fail rather than block every other caller. + return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), + Effect.timeoutOrElse({ + duration: PINNED_RUNTIME_INSTALL_TIMEOUT, + orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })), + }), + ); +}); + +/** + * Downloads the release archive for this platform, verifies it against the + * release's checksum file, and unpacks it so the executable sits directly in + * the staging directory. Only `tar` is required on the host; every supported + * OS ships one that reads gzip and zip. + */ +const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* ( + input: PinnedRuntimeInstallInput, + stagingDir: string, +) { + const { fs, path } = input; + const platformKey = cliArchivePlatformKey(input.platform, input.arch); + if (platformKey === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `selecting a t3 release archive for ${input.platform}-${input.arch}`, + }); + } + const httpClient = input.httpClient; + if (httpClient === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: "downloading the t3 release archive (no HTTP client available)", + }); + } + const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); + const fileName = cliArchiveFileName(input.version, platformKey); + + const checksums = parseChecksums( + new TextDecoder().decode( + yield* fetchReleaseAsset( + httpClient, + `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`, + "downloading the t3 release checksums", + ), + ), + ); + const expected = checksums.get(fileName); + if (expected === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `finding ${fileName} in the t3 release checksums`, + }); + } + const archive = yield* fetchReleaseAsset( + httpClient, + `${baseUrl}/${fileName}`, + "downloading the t3 release archive", + ); + const digest = yield* Effect.tryPromise({ + try: () => crypto.subtle.digest("SHA-256", archive), + catch: (cause) => + new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }), + }); + if (Encoding.encodeHex(new Uint8Array(digest)) !== expected) { + return yield* new PinnedRuntimeInstallError({ + step: "verifying the t3 release archive checksum", + }); + } + + const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE); + yield* fs + .writeFile(archivePath, archive) + .pipe( + Effect.mapError( + (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), + ), + ); + const extractStep = "extracting the t3 release archive"; + // The archive wraps everything in one directory named after its stem; + // strip it so the executable lands at /t3. + yield* input.runner + .run({ + command: cliArchiveTarCommand(input.platform, process.env), + args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: extractStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); + yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); +}); + const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( input: PinnedRuntimeInstallInput, ) { - const { fs, runner } = input; - const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + const { fs } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), fs.exists(paths.entryPath), @@ -146,54 +356,18 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ); const stagingPaths: PinnedRuntimePaths = { + layout: paths.layout, versionDir: stagingDir, - entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ]; - yield* runner - .run({ - command: "npm", - args: installArgs, - // Native dependencies may compile from source on slower machines. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? // pnpm-managed Node installations do not include npm. Keep npm - // installation semantics for the pinned runtime and native builds. - runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - : Effect.fail(error), - }), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ); + if (paths.layout === "archive") { + yield* installFromArchive(input, stagingDir); + } else { + yield* installFromNpm(input, stagingDir); + } yield* input.validate(stagingPaths); yield* fs diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 3cea30790a4c..734c8ca732fd 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,22 +6,32 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; + +import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, PinnedRuntimeInstallError, PinnedRuntimePreflightBlockedError, } from "./pinnedRuntime.ts"; @@ -171,6 +181,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const execPath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + // Archive-distributed targets download from GitHub Releases. The client is + // optional so callers without one (tests, npm-only hosts) still construct. + const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const releaseBaseUrl = Option.getOrUndefined( + yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), + ); const inFlight = yield* Ref.make(false); const capability: ServerSelfUpdateCapability | null = @@ -216,12 +234,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { fs, path, runner, + httpClient, + platform, + arch, + releaseBaseUrl, validate: (runtime) => runner .run({ - command: execPath, + command: pinnedRuntimeCommand(runtime, execPath).command, args: [ - runtime.entryPath, + ...pinnedRuntimeCommand(runtime, execPath).args, "__service-preflight", "--database-path", serverConfig.dbPath, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index fc52fe38dc68..2a7be73838e3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off /** * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. * @@ -6,6 +7,8 @@ * * @module ClaudeAdapterLive */ +import * as NodeSea from "node:sea"; + import { type CanUseTool, query, @@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( detail: "Claude session id is unavailable.", }); } - const historyWorkerPath = yield* path - .fromFileUrl( - new URL( - import.meta.url.endsWith(".ts") - ? "../../claudeHistoryWorker.ts" - : "./claudeHistoryWorker.mjs", - import.meta.url, - ), - ) - .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))); + // The single-executable has no sibling script and no Node to run one + // with, so it hosts the worker as a hidden subcommand of itself. + const historyWorkerArguments = NodeSea.isSea() + ? ["__claude-history"] + : [ + yield* path + .fromFileUrl( + new URL( + import.meta.url.endsWith(".ts") + ? "../../claude-history-worker.ts" + : "./claude-history-worker.mjs", + import.meta.url, + ), + ) + .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))), + ]; const runScopedHistoryCommand = async ( method: "getSessionMessages" | "forkSession", args: object, @@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( process.execPath, ChildProcess.make( process.execPath, - [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], + [...historyWorkerArguments, method, historySessionId, encodeHistoryArgs(args)], { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, ), ).pipe( diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts index 105212451629..b76199833923 100644 --- a/apps/server/src/service-launcher.ts +++ b/apps/server/src/service-launcher.ts @@ -1 +1,20 @@ -import "./serviceLauncher.ts"; +// Standalone launcher entry for npm-distributed runtimes: `node +// service-launcher.mjs`. Archive runtimes reach the same code through the +// `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run +// anything on import. +import { isEntrypoint } from "./entrypoint.ts"; +import { main } from "./serviceLauncher.ts"; + +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { + main().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e912130c8939..fc3d8674e68e 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -7,6 +7,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeSea from "node:sea"; import type { PendingServiceUpdate, @@ -26,7 +27,6 @@ import { SERVICE_STATE_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; -import { isEntrypoint } from "./entrypoint.ts"; const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; @@ -41,15 +41,38 @@ interface ManagedChild { readonly process: NodeChildProcess.ChildProcess; } +// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked +// release archives whose executable runs on its own, npm versions are a +// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node +// built-ins only. const runtimePaths = (baseDir: string, version: string) => { const versionDir = NodePath.join(baseDir, "runtime", "versions", version); + const archive = /-preview\.\d{8}\.\d+$/.test(version); + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. + const executableName = process.platform === "win32" ? "t3.exe" : "t3"; return { versionDir, - entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: archive + ? NodePath.join(versionDir, executableName) + : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), sentinelPath: NodePath.join(versionDir, ".install-complete"), + executable: archive, }; }; +const runtimeSpawnArguments = (paths: ReturnType) => + paths.executable + ? { command: paths.entryPath, args: ["serve"] } + : { command: process.execPath, args: [paths.entryPath, "serve"] }; + +// An npm-layout runtime needs a Node interpreter. When the launcher itself is +// the single-executable, process.execPath is `t3`, which cannot run a +// bin.mjs, so the two layouts cannot be mixed within one service install. +const launcherIsExecutable = NodeSea.isSea(); + +const canLaunchRuntime = (paths: ReturnType) => + paths.executable || !launcherIsExecutable; + /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; const RESTORE_MARKER = ".restore-pending"; @@ -402,7 +425,8 @@ export class Launcher { childVersion: version, ...(update === undefined ? {} : { update }), }; - const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], { + const spawnArguments = runtimeSpawnArguments(paths); + const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, { env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, stdio: ["inherit", "inherit", "inherit", "ipc"], }); @@ -481,6 +505,12 @@ export class Launcher { await reject("The requested database path is not absolute."); return; } + if (!canLaunchRuntime(runtimePaths(this.#baseDir, message.targetVersion))) { + await reject( + "This service runs from a self-contained t3 executable and cannot switch to an npm-installed version. Reinstall the service with the target version instead.", + ); + return; + } if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { await reject("The requested target runtime is missing or incomplete."); return; @@ -602,7 +632,7 @@ export class Launcher { } } -async function main(): Promise { +export async function main(): Promise { const baseDir = process.env.T3CODE_HOME?.trim(); if (baseDir === undefined || baseDir === "") { throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); @@ -611,17 +641,3 @@ async function main(): Promise { const state = await readServiceState(statePath); await new Launcher(baseDir, state).run(); } - -if ( - isEntrypoint({ - moduleUrl: import.meta.url, - entryPath: process.argv[1], - runtimeMain: import.meta.main, - }) -) { - main().catch((cause: unknown) => { - const error = cause instanceof Error ? cause : new Error(String(cause)); - process.stderr.write(`[service-launcher] ${error.message}\n`); - process.exitCode = 1; - }); -} diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 2015ad4de8d6..32a8be7e6daa 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -77,7 +77,7 @@ export default mergeConfig( pack: { // The executable embeds one entry; the history worker becomes a hidden // subcommand there instead of a sibling script. - entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claudeHistoryWorker.ts"], + entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"], outDir: packExecutable ? "dist-exe" : "dist", sourcemap: !packExecutable, clean: true, diff --git a/docs/user/background-service.md b/docs/user/background-service.md index eecd6fa77b3a..4bf3ae45f6a0 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -25,6 +25,21 @@ Updating restarts the server. Finish active work first, and wait for any remote update already in progress. To match a remote client's version, follow [Updating T3 Code](./updating.md). +Preview builds (`t3@preview`) install as a self-contained download from the +T3 Code GitHub release instead of through npm, so the machine running the +service does not need Node.js or npm once the CLI is on it. To get the CLI +onto a machine without Node, run the install script: + +```sh +curl -fsSL https://t3.codes/install.sh | sh +``` + +On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. + +It places `t3` in `~/.local/bin` and reuses the same download when you later +run `t3 service install`. Set `T3CODE_VERSION` to pin an exact version, or +`T3CODE_RELEASE_BASE_URL` to download from a mirror. + ## Platform support Linux needs systemd user services. Setup enables lingering so T3 Code starts at diff --git a/knip.jsonc b/knip.jsonc index 9b8712a07872..e225298fabc0 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -21,7 +21,7 @@ "entry": [ "src/bin.ts!", "src/service-launcher.ts!", - "src/claudeHistoryWorker.ts!", + "src/claude-history-worker.ts!", "scripts/cli.ts", "src/provider/testFixtures/*.mjs", ], diff --git a/packages/shared/package.json b/packages/shared/package.json index faf0d6f2a0f5..d30c26c2de79 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -322,6 +322,10 @@ "./dateTime": { "types": "./src/dateTime.ts", "import": "./src/dateTime.ts" + }, + "./cliRelease": { + "types": "./src/cliRelease.ts", + "import": "./src/cliRelease.ts" } }, "scripts": { diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts new file mode 100644 index 000000000000..2d9b2446b779 --- /dev/null +++ b/packages/shared/src/cliRelease.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + cliArchiveFileName, + cliArchivePlatformKey, + cliArchiveTarCommand, + cliReleaseDownloadBaseUrl, + isArchiveDistributedVersion, + parseChecksums, +} from "./cliRelease.ts"; + +describe("cliRelease", () => { + it("names archives by version and platform, zip only on Windows", () => { + expect(cliArchiveFileName("1.2.3-preview.20260911.4", "linux-x64")).toBe( + "t3-1.2.3-preview.20260911.4-linux-x64.tar.gz", + ); + expect(cliArchiveFileName("1.2.3", "win32-x64")).toBe("t3-1.2.3-win32-x64.zip"); + }); + + it("only maps platforms and architectures that have a release archive", () => { + expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64"); + expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64"); + expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64"); + // Built but not published (macOS x64 segfaults under Rosetta when + // cross-injected; the arm64 Linux and Windows runners do not exist yet). + expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined(); + expect(cliArchivePlatformKey("linux", "arm64")).toBeUndefined(); + expect(cliArchivePlatformKey("win32", "arm64")).toBeUndefined(); + expect(cliArchivePlatformKey("freebsd", "x64")).toBeUndefined(); + expect(cliArchivePlatformKey("linux", "ia32")).toBeUndefined(); + }); + + it("resolves download URLs under the tagged release, honoring a mirror", () => { + expect(cliReleaseDownloadBaseUrl("1.2.3")).toBe( + "https://github.com/pingdotgg/t3code/releases/download/v1.2.3", + ); + expect(cliReleaseDownloadBaseUrl("1.2.3", "https://mirror.example/t3/")).toBe( + "https://mirror.example/t3/v1.2.3", + ); + }); + + it("parses sha256sum output including binary-mode markers", () => { + const checksums = parseChecksums( + [ + `${"a".repeat(64)} t3-1.2.3-linux-x64.tar.gz`, + `${"B".repeat(64)} *t3-1.2.3-win32-x64.zip`, + "not a checksum line", + "", + ].join("\n"), + ); + expect(checksums.get("t3-1.2.3-linux-x64.tar.gz")).toBe("a".repeat(64)); + expect(checksums.get("t3-1.2.3-win32-x64.zip")).toBe("b".repeat(64)); + expect(checksums.size).toBe(2); + }); + + it("treats only preview builds as archive-distributed", () => { + expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true); + expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false); + expect(isArchiveDistributedVersion("1.2.3")).toBe(false); + }); + + it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { + expect(cliArchiveTarCommand("linux", {})).toBe("tar"); + expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( + "D:\\Win\\System32\\tar.exe", + ); + expect(cliArchiveTarCommand("win32", {})).toBe("C:\\Windows\\System32\\tar.exe"); + }); +}); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts new file mode 100644 index 000000000000..8863a5c1ebf7 --- /dev/null +++ b/packages/shared/src/cliRelease.ts @@ -0,0 +1,76 @@ +/** + * Naming shared by the release workflow, the runtime installers, and + * install scripts for the per-platform CLI archives attached to GitHub + * Releases. Every consumer derives the same file names from a version and a + * platform key, so a rename here is a release-breaking change. + */ + +const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; +/** Overrides the download origin for mirrors and air-gapped installs. */ +export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; + +/** + * The archives a release actually attaches. Kept in step with the + * `cli_archive` matrix flags in .github/workflows/release.yml: a key here + * without a build there produces download URLs that 404, and a build there + * without a key here is unreachable from every installer. + */ +const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-x64", "win32-x64"] as const; +export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number]; + +export function cliArchivePlatformKey( + platform: NodeJS.Platform, + arch: string, +): CliArchivePlatformKey | undefined { + const key = `${platform}-${arch}`; + return CLI_ARCHIVE_PLATFORM_KEYS.find((candidate) => candidate === key); +} + +/** + * The tar to extract a release archive with. Windows ships bsdtar in + * System32, which reads both formats; a Git-for-Windows GNU tar earlier on + * PATH cannot open the zip, so the system copy is named by absolute path. + */ +export function cliArchiveTarCommand( + platform: NodeJS.Platform, + env: Readonly>, +): string { + if (platform !== "win32") return "tar"; + const systemRoot = env["SystemRoot"] ?? env["windir"] ?? "C:\\Windows"; + return `${systemRoot}\\System32\\tar.exe`; +} + +export function cliArchiveFileName(version: string, platformKey: CliArchivePlatformKey): string { + return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`; +} + +const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; + +/** Directory that `releases/download//` lives under. */ +export function cliReleaseDownloadBaseUrl( + version: string, + baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, +): string { + return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; +} + +/** + * Parses the `sha256sum` style checksum file attached to each release. + * Lines are ` `; a leading `*` marks binary mode and is ignored. + */ +export function parseChecksums(text: string): ReadonlyMap { + const checksums = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = /^([0-9a-fA-F]{64})\s+\*?(\S.*)$/.exec(line.trim()); + if (match?.[1] !== undefined && match[2] !== undefined) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} + +/** Whether a version was published from a release train that ships archives. */ +export function isArchiveDistributedVersion(version: string): boolean { + return /-preview\.\d{8}\.\d+$/.test(version); +} From 13c134c10be163fa5345dd7c39371b6089e352bb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:21 -0700 Subject: [PATCH 12/27] feat(ssh): run preview builds on remotes from the release archive (#11319) Co-authored-by: Claude Fable 5 --- apps/desktop/src/main.ts | 6 + apps/server/src/bin.ts | 2 + apps/server/src/cli/sshHelper.ts | 126 ++++++++++++++++++ packages/ssh/src/tunnel.test.ts | 217 +++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 180 ++++++++++++++++++++++++- 5 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/cli/sshHelper.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 939b88c7d0d0..0b273fe7822a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,6 +17,7 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -97,6 +98,11 @@ const resolveDesktopSshCliRunner = ( nodeEngineRange: serverPackageJson.engines.node, }; } + // Preview builds ship as self-contained archives, so the remote runs the + // same version this app is on without Node or npm. + if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) { + return { archiveVersion: environment.appVersion }; + } return { packageSpec: resolveRemoteT3CliPackageSpec({ appVersion: environment.appVersion, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 9f9d4645c9d0..668723a79b2d 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -20,6 +20,7 @@ import { serviceCommand } from "./cli/service.ts"; import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; +import { sshHelperCommand } from "./cli/sshHelper.ts"; import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serviceLauncherCommand, claudeHistoryCommand, servicePreflightCommand, + sshHelperCommand, themeCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, diff --git a/apps/server/src/cli/sshHelper.ts b/apps/server/src/cli/sshHelper.ts new file mode 100644 index 000000000000..55428538bd8d --- /dev/null +++ b/apps/server/src/cli/sshHelper.ts @@ -0,0 +1,126 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalTimers:off +// @effect-diagnostics globalDateInEffect:off +// The helpers mirror the inline Node snippets the SSH launch script used to +// run, byte for byte in behaviour, so they stay on plain Node APIs. +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; + +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/unstable/cli"; + +/** + * Small helpers the SSH launch script needs on the remote host. The script + * used to run these as inline `node -` snippets; archive-distributed runtimes + * have no Node on the remote, so the executable provides them instead. Output + * and exit codes match the snippets exactly because the shell script parses + * them. + */ + +const tryPort = (port: number) => + new Promise((resolve) => { + const server = NodeNet.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close((error) => resolve(error ? false : port)); + }); + }); + +/** Prints the first free loopback port from the preferred one, scanning `window` ports. */ +const pickPort = Command.make("pick-port", { + portFile: Argument.string("port-file"), + defaultPort: Argument.integer("default-port"), + scanWindow: Argument.integer("scan-window"), +}).pipe( + Command.withHandler(({ portFile, defaultPort, scanWindow }) => + Effect.promise(async () => { + const raw = NodeFS.existsSync(portFile) ? NodeFS.readFileSync(portFile, "utf8").trim() : ""; + const preferred = Number.parseInt(raw, 10); + const start = Number.isInteger(preferred) ? preferred : defaultPort; + for (let port = start; port < start + scanWindow; port += 1) { + if (await tryPort(port)) { + process.stdout.write(String(port)); + return; + } + } + process.exitCode = 1; + }), + ), +); + +const probe = (port: number, probeTimeoutMs: number) => + new Promise((resolve) => { + const request = NodeHttp.get( + { hostname: "127.0.0.1", port, path: "/", timeout: probeTimeoutMs }, + (response) => { + response.resume(); + response.once("end", () => { + const status = response.statusCode ?? 0; + resolve(status >= 200 && status < 300); + }); + }, + ); + request.once("timeout", () => { + request.destroy(); + resolve(false); + }); + request.once("error", () => resolve(false)); + }); + +/** Exits 0 once the loopback server answers, 1 when the deadline passes first. */ +const waitReady = Command.make("wait-ready", { + port: Argument.integer("port"), + timeoutMs: Argument.integer("timeout-ms"), + probeTimeoutMs: Argument.integer("probe-timeout-ms"), +}).pipe( + Command.withHandler(({ port, timeoutMs, probeTimeoutMs }) => + Effect.promise(async () => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probe(port, probeTimeoutMs)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + process.exitCode = 1; + }), + ), +); + +/** Prints ` ` for a live default-home server, or exits 1. */ +const runtimePort = Command.make("runtime-port", { + runtimeFile: Argument.string("runtime-file"), +}).pipe( + Command.withHandler(({ runtimeFile }) => + Effect.sync(() => { + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the shell snippet's loose parse. + const runtime = JSON.parse(NodeFS.readFileSync(runtimeFile, "utf8")) as { + pid?: unknown; + port?: unknown; + origin?: unknown; + }; + const pid = Number(runtime.pid); + const port = Number(runtime.port); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) { + process.exitCode = 1; + return; + } + const origin = new URL(String(runtime.origin ?? "")); + if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { + process.exitCode = 1; + return; + } + process.kill(pid, 0); + process.stdout.write(`${pid} ${port}`); + } catch { + process.exitCode = 1; + } + }), + ), +); + +export const sshHelperCommand = Command.make("__ssh-helper").pipe( + Command.unlisted, + Command.withSubcommands([pickPort, waitReady, runtimePort]), +); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index e2536ba92017..182a8eb4a0ce 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,10 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Deferred from "effect/Deferred"; 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 Result from "effect/Result"; import * as Sink from "effect/Sink"; @@ -20,6 +22,7 @@ import { buildRemotePairingScript, buildRemoteStopScript, buildRemoteT3RunnerScript, + SshInvalidArchiveVersionError, describeReadinessCause, issueRemotePairingToken, launchOrReuseRemoteServer, @@ -130,6 +133,80 @@ describe("ssh tunnel scripts", () => { assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); }); + it("installs and runs the release archive when an archive version is set", () => { + const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }); + + assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); + assert.include( + script, + "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", + ); + assert.include(script, 'T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"'); + assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); + assert.include(script, "SHA256SUMS"); + assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); + // Concurrent launches serialize on a per-version mkdir lock and recheck + // the completion marker after acquiring it. + assert.include( + script, + 'T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"', + ); + // mkdir is the exclusive create; the pid follows atomically. A dead owner + // is reclaimed at once, a never-published owner after a short grace. + assert.include(script, 'while ! mkdir "$T3_LOCK" 2>/dev/null; do'); + assert.include(script, 'mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"'); + assert.include(script, 'if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then'); + assert.include(script, 'if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then'); + assert.include(script, 'if [ "$T3_LOCK_WAITED" -ge 360 ]; then'); + assert.include(script, '"$T3_STAGING/SHA256SUMS" 30'); + assert.include(script, '"$T3_STAGING/$T3_ARCHIVE" 240'); + assert.notInclude(script, "T3_LOCK_CANDIDATE"); + assert.notInclude(script, "-mmin"); + assert.equal(script.split("if ! t3_runtime_ready; then").length - 1, 2); + assert.isBelow( + script.indexOf('"$T3_STAGING/t3" --version'), + script.indexOf('> "$T3_STAGING/.install-complete"'), + ); + // The archive branch execs before any of the Node discovery runs. + assert.isBelow( + script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), + script.indexOf("prepend_path_if_dir()"), + ); + + const launch = buildRemoteLaunchScript({ + archiveVersion: "1.2.3-preview.20260911.4", + releaseBaseUrl: "https://mirror.example/t3/", + }); + assert.include(launch, "T3_ARCHIVE_MODE=1"); + assert.include(launch, "T3_RELEASE_BASE_URL='https://mirror.example/t3'"); + assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); + assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); + assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); + assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0"); + }); + + it("rejects archive versions that are not a single exact version segment", () => { + for (const archiveVersion of [ + "../other", + "1.2.3/evil", + "1.2.3\\evil", + "1.2.3-preview.1 x", + "1.2.3-preview.1\nrm -rf /", + "v1.2.3", + ]) { + assert.throws( + () => buildRemoteT3RunnerScript({ archiveVersion }), + SshInvalidArchiveVersionError, + undefined, + archiveVersion, + ); + } + assert.include( + buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), + "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", + ); + }); + it("does not hard-code a remote node engine range", () => { const script = buildRemoteT3RunnerScript(); @@ -282,6 +359,33 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("gives cold archive launches a larger budget than npm launches", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 800_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + launchOrReuseRemoteServer(target, undefined, { + archiveVersion: "1.2.3-preview.20260911.4", + }), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(800)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); @@ -588,3 +692,116 @@ describe("ssh tunnel scripts", () => { }), ); }); + +// The archive runner is generated shell; string assertions cannot prove the +// lock excludes concurrent installers. Run the real script against a tiny +// fake archive served from a file:// mirror. +describe("archive runner script", () => { + const hostPlatform = HostProcessPlatform.defaultValue(); + const hostArch = HostProcessArchitecture.defaultValue(); + const windowsHost = hostPlatform === "win32"; + const archiveVersion = "1.2.3-preview.20260911.4"; + + const runRunner = (home: string, runner: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("sh", [runner, "--version"], { + env: { PATH: process.env.PATH ?? "", HOME: home }, + extendEnv: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + child.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ), + child.stderr.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; + }); + + // A fake "executable" that answers --version, packed the way the release + // workflow packs the real archive: one top-level directory named after the + // stem, checksummed in SHA256SUMS. + const makeMirror = Effect.fn("makeMirror")(function* (root: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = hostPlatform === "darwin" ? "darwin" : "linux"; + const arch = hostArch === "arm64" ? "arm64" : "x64"; + const stem = `t3-${archiveVersion}-${platform}-${arch}`; + const stage = `${root}/stage/${stem}`; + const release = `${root}/mirror/v${archiveVersion}`; + const script = [ + "set -eu", + `mkdir -p '${stage}' '${release}'`, + `printf '#!/bin/sh\\necho t3 v${archiveVersion}\\n' > '${stage}/t3'`, + `chmod +x '${stage}/t3'`, + `tar -czf '${release}/${stem}.tar.gz' -C '${root}/stage' '${stem}'`, + `cd '${release}' && (sha256sum '${stem}.tar.gz' 2>/dev/null || shasum -a 256 '${stem}.tar.gz') > SHA256SUMS`, + ].join("\n"); + const child = yield* spawner.spawn(ChildProcess.make("sh", ["-c", script])); + assert.equal(Number(yield* child.exitCode), 0); + return `file://${root}/mirror`; + }); + + it.effect.skipIf(windowsHost)( + "installs once when several launches race, and reclaims stale locks", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-archive-runner-" }); + const releaseBaseUrl = yield* makeMirror(root); + const runner = `${root}/run-t3.sh`; + yield* fs.writeFileString( + runner, + buildRemoteT3RunnerScript({ archiveVersion, releaseBaseUrl }), + ); + const home = `${root}/home`; + yield* fs.makeDirectory(home, { recursive: true }); + + const results = yield* Effect.all( + [runRunner(home, runner), runRunner(home, runner), runRunner(home, runner)], + { concurrency: "unbounded" }, + ); + for (const result of results) { + assert.equal(result.exitCode, 0, result.stderr); + assert.include(result.stdout, `t3 v${archiveVersion}`); + } + const versionsDir = `${home}/.t3/runtime/versions`; + assert.deepEqual(yield* fs.readDirectory(versionsDir), [archiveVersion]); + assert.equal( + (yield* fs.readFileString(`${versionsDir}/${archiveVersion}/.install-complete`)).trim(), + archiveVersion, + ); + + // A lock left by a crashed installer (dead pid) must not block the + // next launch, and neither must one that never published a pid. + const lock = `${versionsDir}/.${archiveVersion}.install.lock`; + yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); + yield* fs.makeDirectory(lock); + yield* fs.writeFileString(`${lock}/pid`, "999999\n"); + const afterDead = yield* runRunner(home, runner); + assert.equal(afterDead.exitCode, 0, afterDead.stderr); + + yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); + yield* fs.makeDirectory(lock); + const afterUnowned = yield* runRunner(home, runner); + assert.equal(afterUnowned.exitCode, 0, afterUnowned.stderr); + assert.isFalse(yield* fs.exists(lock)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); +}); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 9cb6b25e4121..d3df7d39307f 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -6,6 +6,7 @@ import { describeReadinessCause, waitForHttpReady as waitForHttpReadyShared, } from "@t3tools/shared/httpReadiness"; +import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease"; import * as NetService from "@t3tools/shared/Net"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; @@ -56,12 +57,28 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const REMOTE_READY_TIMEOUT_MS = 60_000; const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; +// A cold archive launch also downloads and unpacks a ~70 MB release archive +// and may wait on another installer's lock. The budgets nest: the checksum +// file is tiny and the archive download is bounded; a waiter outlasts both +// downloads plus extraction so it can reuse the result; and the SSH command +// outlasts an install (own or waited-for) plus readiness, with slack for +// verification and extraction, which have no timeout of their own. +const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30; +const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240; +const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360; +const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { readonly packageSpec?: string; readonly nodeScriptPath?: string | null; readonly nodeEngineRange?: string | null; + /** + * Exact version whose release archive the remote installs and runs. Takes + * precedence over `packageSpec`; the remote then needs neither Node nor npm. + */ + readonly archiveVersion?: string | null; + readonly releaseBaseUrl?: string | null; } export interface SshEnvironmentManagerOptions { @@ -108,6 +125,9 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { } function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { + if (runner?.archiveVersion?.trim()) { + return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; + } if (runner?.nodeScriptPath?.trim()) { return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; } @@ -404,6 +424,103 @@ ensure_remote_node_path() { const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu +T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ +if [ -n "$T3_ARCHIVE_VERSION" ]; then + # Self-contained release archive: no Node, npm, or compiler on the remote. + # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. + T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ + T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" + t3_runtime_ready() { + [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] + } + if ! t3_runtime_ready; then + mkdir -p "$HOME/.t3/runtime/versions" + # Concurrent launches (two clients, a retry racing a slow first run) must + # not both install: mkdir is the atomic lock and the ready check repeats + # under it. + T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" + # mkdir is the only portable atomic exclusive create (mv would silently + # nest a candidate inside an existing lock). The owner publishes its pid + # right after, so a lock with a live owner is never reclaimed however + # slow its download is, and a lock whose owner is dead is reclaimed at + # once. A lock with no pid at all is a crash between mkdir and the pid + # write; it is reclaimed after a short grace so a live owner has time to + # publish. + T3_LOCK_WAITED=0 + T3_LOCK_UNOWNED=0 + while ! mkdir "$T3_LOCK" 2>/dev/null; do + T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" + if [ -n "$T3_LOCK_OWNER" ]; then + T3_LOCK_UNOWNED=0 + if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then + rm -rf "$T3_LOCK" + continue + fi + else + T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) + if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then + rm -rf "$T3_LOCK" + continue + fi + fi + if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then + printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 + exit 1 + fi + sleep 1 + T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) + done + printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" + trap 'rm -rf "$T3_LOCK"' EXIT + fi + if ! t3_runtime_ready; then + case "$(uname -s)" in + Darwin) T3_PLATFORM="darwin" ;; + Linux) T3_PLATFORM="linux" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) T3_ARCH="arm64" ;; + x86_64 | amd64) T3_ARCH="x64" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; + esac + T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" + T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" + trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT + t3_fetch() { + if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" + else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + } + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ + T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" + if command -v sha256sum >/dev/null 2>&1; then + T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + else + T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + fi + if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then + printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 + rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" + # Prove the binary runs here (libc, arch) before marking it ready, or every + # later launch would exec a broken install instead of retrying. + if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then + printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 + fi + printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" + rm -rf "$T3_RUNTIME_DIR" + mv "$T3_STAGING" "$T3_RUNTIME_DIR" + fi + if [ -n "\${T3_LOCK:-}" ]; then + rm -rf "$T3_LOCK" + trap - EXIT + fi + exec "$T3_RUNTIME_DIR/t3" "$@" +fi @@T3_NODE_ENV_SCRIPT@@ ensure_remote_node_path || true T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ @@ -473,16 +590,30 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then fi mv "$RUNNER_NEXT" "$RUNNER_FILE" chmod 700 "$RUNNER_FILE" -if ! ensure_remote_node_path; then +T3_ARCHIVE_MODE=@@T3_ARCHIVE_MODE@@ +if [ "$T3_ARCHIVE_MODE" = "1" ]; then + # The archive ships the helpers below inside the executable; the remote + # needs no Node at all. Resolving the runner once here also downloads the + # archive before the port and readiness probes rely on it. + "$RUNNER_FILE" --version >/dev/null +elif ! ensure_remote_node_path; then printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 exit 1 fi pick_port() { + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + "$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" + return + fi node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' @@T3_PICK_PORT_SCRIPT@@ NODE } wait_ready() { + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + "$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" + return + fi node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE' @@T3_WAIT_READY_SCRIPT@@ NODE @@ -496,6 +627,10 @@ wait_for_pid_exit() { done } resolve_default_runtime_port() { + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE" + return + fi node - "$DEFAULT_RUNTIME_FILE" <<'NODE' const fs = require("node:fs"); const runtimePath = process.argv[2] ?? ""; @@ -582,7 +717,11 @@ fi if [ -z "$REMOTE_PORT" ]; then REMOTE_PORT="$(pick_port)" || true if [ -z "$REMOTE_PORT" ]; then - printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + printf 'Failed to find an available port on the remote host.\\n' >&2 + else + printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 + fi exit 1 fi nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & @@ -650,13 +789,42 @@ if [ -f "$LOG_FILE" ]; then fi `; +export class SshInvalidArchiveVersionError extends Schema.TaggedError()( + "SshInvalidArchiveVersionError", + { archiveVersion: Schema.String }, +) { + override get message(): string { + return `'${this.archiveVersion}' is not an exact t3 version and cannot name a runtime directory.`; + } +} + +// The version becomes a directory name the runner removes and recreates, so +// it must be one exact SemVer segment: no separators, no `..`, no shell +// metacharacters beyond what SemVer allows. +const EXACT_ARCHIVE_VERSION = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; + export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; + const archiveVersion = input?.archiveVersion?.trim() || ""; + if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { + throw new SshInvalidArchiveVersionError({ archiveVersion }); + } + // Strip the `/v` the helper appends: the script builds URLs itself. + const releaseBaseUrl = cliReleaseDownloadBaseUrl("", input?.releaseBaseUrl ?? undefined).replace( + /\/v$/u, + "", + ); return stripTrailingNewlines( applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { T3_PACKAGE_SPEC: packageSpec, T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), + T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), + T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), + T3_ARCHIVE_LOCK_WAIT_SECONDS: String(REMOTE_ARCHIVE_LOCK_WAIT_SECONDS), + T3_ARCHIVE_DOWNLOAD_SECONDS: String(REMOTE_ARCHIVE_DOWNLOAD_SECONDS), + T3_ARCHIVE_CHECKSUMS_SECONDS: String(REMOTE_ARCHIVE_CHECKSUMS_SECONDS), T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), }), ); @@ -673,6 +841,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { + T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), @@ -725,7 +894,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), - timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, + timeoutMs: runner?.archiveVersion?.trim() + ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS + : REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -783,6 +954,9 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s"], stdin: buildRemotePairingScript(target, runner), + // Pairing may be the first command on a cold remote, so it can install + // the archive on the way. + ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), From c7f23c4668ce3ff2e1b2f63fe51759d1bcc159f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:22 -0700 Subject: [PATCH 13/27] feat(cli): add t3 update for self-contained installs (#11451) Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 3 +- apps/desktop/src/updates/updateChannels.ts | 5 +- apps/marketing/public/install.ps1 | 37 +- apps/marketing/public/install.sh | 52 +- apps/server/src/bin.ts | 2 + apps/server/src/cli/service.ts | 2 +- apps/server/src/cli/update.test.ts | 109 ++++ apps/server/src/cli/update.ts | 573 ++++++++++++++++++ apps/server/src/cloud/bootService.test.ts | 29 + apps/server/src/cloud/bootService.ts | 31 + .../src/provider/Layers/ClaudeAdapter.ts | 4 +- apps/server/src/server.ts | 4 +- apps/server/src/serverRuntimeState.test.ts | 19 + apps/server/src/serverRuntimeState.ts | 8 + docs/operations/release.md | 2 +- docs/user/background-service.md | 34 +- packages/shared/src/cliRelease.test.ts | 33 + packages/shared/src/cliRelease.ts | 44 ++ packages/shared/src/hostProcess.ts | 26 + scripts/build-desktop-artifact.ts | 2 +- 20 files changed, 990 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/cli/update.test.ts create mode 100644 apps/server/src/cli/update.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f8ef7fbac78..8e7305931421 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,7 +147,8 @@ jobs: echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then - # Temporary channel for dogfooding the archive-based CLI runtime. + # Manual-only test train: exercises the whole release flow for a + # commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. # A preview release is reachable only by downloading it by hand: # it is never published to npm, its desktop builds carry no update diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 8611917e1b60..e7f9a2f8547d 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,8 +1,9 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// Preview builds are a temporary dogfooding train cut from nightly. They share -// nightly's branding but are packaged without an update feed (see +// Preview builds are the maintainers' test train, cut by hand from unreleased +// branches to exercise the release flow. They share nightly's branding but +// are packaged without an update feed (see // isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the // channel a preview install reports is cosmetic: it never checks for updates // and no updater feed ever lists a preview release. diff --git a/apps/marketing/public/install.ps1 b/apps/marketing/public/install.ps1 index 0900bd01c1c9..4aa46d327b87 100644 --- a/apps/marketing/public/install.ps1 +++ b/apps/marketing/public/install.ps1 @@ -4,7 +4,9 @@ # irm https://t3.codes/install.ps1 | iex # # Environment: -# T3CODE_VERSION exact version to install (default: latest preview release) +# T3CODE_CHANNEL release train to follow: stable, nightly, or preview +# (default: stable; preview is a maintainers' test train) +# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) # T3CODE_HOME T3 home directory (default: ~\.t3) # T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) @@ -34,14 +36,29 @@ $arch = switch ($rawArch) { default { Fail "unsupported architecture $rawArch" } } +$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } $version = $env:T3CODE_VERSION if (-not $version) { - # Preview is the only train shipping archives while they are being dogfooded. - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=50" -Headers @{ "User-Agent" = "t3-install" } - $tag = ($releases | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | Select-Object -First 1).tag_name - if (-not $tag) { Fail "could not find a preview release; set T3CODE_VERSION" } + # Tags are v; the channel is the prerelease identifier, or none for + # stable. Only tags of the requested train are considered, so a stable + # install can never pick up a nightly or preview build by accident. + $tagPattern = switch ($channel) { + "stable" { '^v\d+\.\d+\.\d+$' } + "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } + "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } + default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } + } + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } + $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name + if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } $version = $tag.Substring(1) } +if ($version -match '-preview\.') { + Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build." + if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { + Fail "refusing a preview build that was not explicitly requested" + } +} $stem = "t3-$version-win32-$arch" $archive = "$stem.zip" @@ -57,7 +74,15 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { New-Item -ItemType Directory -Path $staging | Out-Null try { Write-Host "Downloading $archive..." - Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing + try { + Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing + } catch { + $status = $_.Exception.Response.StatusCode.value__ + if ($status -eq 404) { + Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead" + } + throw + } Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) diff --git a/apps/marketing/public/install.sh b/apps/marketing/public/install.sh index d7ddc0d1de46..19263563402a 100755 --- a/apps/marketing/public/install.sh +++ b/apps/marketing/public/install.sh @@ -5,7 +5,9 @@ # curl -fsSL https://t3.codes/install.sh | sh # # Environment: -# T3CODE_VERSION exact version to install (default: latest preview release) +# T3CODE_CHANNEL release train to follow: stable, nightly, or preview +# (default: stable; preview is a maintainers' test train) +# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) # T3CODE_HOME T3 home directory (default: ~/.t3) # T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) @@ -25,11 +27,19 @@ fail() { exit 1 } +# Exit 44 on a 404 so callers can tell "no such asset" from a network failure. fetch() { if command -v curl >/dev/null 2>&1; then - curl -fsSL "$1" -o "$2" + status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 + case "$status" in + 2??) return 0 ;; + 404) return 44 ;; + *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; + esac elif command -v wget >/dev/null 2>&1; then - wget -q "$1" -O "$2" + wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 + if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi + cat "$2.headers" >&2; rm -f "$2.headers"; return 1 else fail "curl or wget is required" fi @@ -54,15 +64,35 @@ else fail "sha256sum or shasum is required" fi +channel="${T3CODE_CHANNEL:-stable}" version="${T3CODE_VERSION:-}" if [ -z "$version" ]; then - # Preview is the only train shipping archives while they are being dogfooded. + # Tags are v; the channel is the prerelease identifier, or none for + # stable. Only tags of the requested train are considered, so a stable + # install can never pick up a nightly or preview build by accident. + case "$channel" in + stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; + nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; + *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; + esac tmp_index="$(mktemp)" - fetch "https://api.github.com/repos/${repo}/releases?per_page=50" "$tmp_index" - version="$(sed -n 's/.*"tag_name": *"v\([0-9][^"]*-preview\.[0-9]*\.[0-9]*\)".*/\1/p' "$tmp_index" | head -n 1)" + fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" + version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" rm -f "$tmp_index" - [ -n "$version" ] || fail "could not find a preview release; set T3CODE_VERSION" + [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" fi +case "$version" in + *-preview.*) + printf '%s\n' \ + "t3 ${version} is a preview build." \ + " Preview builds are cut by maintainers from unreleased branches to exercise the release" \ + " pipeline. They can be broken, receive no fixes, and are never offered as updates." \ + " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2 + if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then + fail "refusing a preview build that was not explicitly requested" + fi + ;; +esac stem="t3-${version}-${platform}-${arch}" archive="${stem}.tar.gz" @@ -77,7 +107,13 @@ else trap 'rm -rf "$staging"' EXIT printf 'Downloading %s...\n' "$archive" - fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" + fetch_status=0 + fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? + if [ "$fetch_status" -eq 44 ]; then + fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead" + elif [ "$fetch_status" -ne 0 ]; then + fail "could not download the release checksums" + fi fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 668723a79b2d..d037a6687738 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { updateCommand } from "./cli/update.ts"; import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; @@ -62,6 +63,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + updateCommand, serviceLauncherCommand, claudeHistoryCommand, servicePreflightCommand, diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 5b53e81769b8..0de1ce75799c 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -140,7 +140,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( Command.withDescription( - "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", + "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.", ), Command.withHandler((flags) => runServiceCommand( diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts new file mode 100644 index 000000000000..1a9c66428da9 --- /dev/null +++ b/apps/server/src/cli/update.test.ts @@ -0,0 +1,109 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { + HostProcessEnvironment, + HostProcessInvokedAs, + HostProcessPlatform, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; + +import { repointLauncher, resolveLauncherPath } from "./update.ts"; + +it.layer(NodeServices.layer)("t3 update launcher", (it) => { + it.effect("repoints a symlink that lives in a runtime versions tree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const oldExe = path.join(root, "runtime/versions/1.0.0/t3"); + const newExe = path.join(root, "runtime/versions/2.0.0/t3"); + const launcher = path.join(root, "bin/t3"); + for (const file of [oldExe, newExe]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.symlink(oldExe, launcher); + + const repointed = yield* repointLauncher({ + launchedAs: launcher, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newExe, + }); + + assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher); + assert.equal(yield* fs.readLink(launcher), newExe); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); + + it.effect("leaves a plain copy or a foreign symlink alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const newExe = path.join(root, "runtime/versions/2.0.0/t3"); + const copy = path.join(root, "copy/t3"); + const foreign = path.join(root, "foreign/t3"); + const elsewhere = path.join(root, "elsewhere/t3"); + // Another install's versions tree: same shape, different home. + const otherHome = path.join(root, "other/runtime/versions/1.0.0/t3"); + const otherLauncher = path.join(root, "other/bin/t3"); + for (const file of [newExe, copy, elsewhere, otherHome]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + yield* fs.makeDirectory(path.dirname(foreign), { recursive: true }); + yield* fs.symlink(elsewhere, foreign); + yield* fs.makeDirectory(path.dirname(otherLauncher), { recursive: true }); + yield* fs.symlink(otherHome, otherLauncher); + + for (const launchedAs of [copy, foreign, otherLauncher, undefined]) { + const repointed = yield* repointLauncher({ + launchedAs, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newExe, + }); + assert.equal(repointed._tag, "None", launchedAs ?? "undefined"); + } + assert.equal(yield* fs.readLink(foreign), elsewhere); + assert.equal(yield* fs.readLink(otherLauncher), otherHome); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); + + it.effect("finds the launcher a bare command name resolved to on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const launcher = path.join(root, "bin/t3"); + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.writeFileString(launcher, ""); + + const bare = yield* resolveLauncherPath.pipe( + Effect.provideService(HostProcessInvokedAs, "t3"), + Effect.provideService(HostProcessEnvironment, { + PATH: `${path.join(root, "missing")}:${path.join(root, "bin")}`, + }), + Effect.provideService(HostProcessWorkingDirectory, root), + ); + const relative = yield* resolveLauncherPath.pipe( + Effect.provideService(HostProcessInvokedAs, "./bin/t3"), + Effect.provideService(HostProcessEnvironment, { PATH: "" }), + Effect.provideService(HostProcessWorkingDirectory, root), + ); + const absent = yield* resolveLauncherPath.pipe( + Effect.provideService(HostProcessInvokedAs, "t3"), + Effect.provideService(HostProcessEnvironment, { PATH: path.join(root, "missing") }), + Effect.provideService(HostProcessWorkingDirectory, root), + ); + + assert.equal(bare, launcher); + assert.equal(relative, launcher); + assert.equal(absent, undefined); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts new file mode 100644 index 000000000000..dfcebf07e4ee --- /dev/null +++ b/apps/server/src/cli/update.ts @@ -0,0 +1,573 @@ +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessInvokedAs, + HostProcessIsExecutable, + HostProcessPlatform, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import { + CLI_RELEASE_BASE_URL_ENV, + CLI_RELEASE_CHANNELS, + cliReleaseIndexPageUrl, + cliReleaseChannelOf, + isArchiveDistributedVersion, + newestCliReleaseVersion, + type CliReleaseChannel, +} from "@t3tools/shared/cliRelease"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, + PinnedRuntimeInstallError, + pinnedRuntimePaths, +} from "../cloud/pinnedRuntime.ts"; +import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { bootServiceLayer } from "./service.ts"; + +export class CliUpdateError extends Schema.TaggedError()("CliUpdateError", { + reason: Schema.String, +}) { + override get message(): string { + return this.reason; + } +} + +const ReleaseIndex = Schema.Array( + Schema.Struct({ + tag_name: Schema.String, + draft: Schema.optional(Schema.Boolean), + }), +); +const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex)); + +const RELEASE_INDEX_TIMEOUT = Duration.seconds(30); +// Enough to walk past a long run of nightlies without hammering the API when +// a channel genuinely has nothing published. +const RELEASE_INDEX_MAX_PAGES = 10; + +/** Asks GitHub for the newest published version on a channel, page by page. */ +const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( + channel: CliReleaseChannel, +) { + const httpClient = yield* HttpClient.HttpClient; + for (let page = 1; page <= RELEASE_INDEX_MAX_PAGES; page += 1) { + const body = yield* httpClient + .execute( + HttpClientRequest.get(cliReleaseIndexPageUrl(page)).pipe( + HttpClientRequest.setHeader("Accept", "application/vnd.github+json"), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.mapError(() => new CliUpdateError({ reason: "Could not list t3 releases." })), + Effect.timeoutOrElse({ + duration: RELEASE_INDEX_TIMEOUT, + orElse: () => + Effect.fail(new CliUpdateError({ reason: "Timed out listing t3 releases." })), + }), + ); + const releases = yield* decodeReleaseIndex(body).pipe( + Effect.mapError( + () => new CliUpdateError({ reason: "The t3 release index had an unexpected shape." }), + ), + ); + const version = newestCliReleaseVersion(releases, channel); + if (version !== undefined) return version; + if (releases.length === 0) break; + } + return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); +}); + +/** + * The launcher the install scripts leave behind: a symlink at `/t3` on + * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3` + * invocation is the new version. Only a launcher that already points into + * this home's `runtime/versions` tree is touched; a plain copy of the + * executable, or a launcher for some other install, is left alone. + */ +export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: { + /** Path the current process was started through, if known. */ + readonly launchedAs: string | undefined; + /** `/runtime/versions` of the home being updated. */ + readonly versionsDir: string; + readonly targetEntryPath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (input.launchedAs === undefined) return Option.none(); + const ownsTarget = (candidate: string) => { + const relative = path.relative(input.versionsDir, path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); + }; + + if (platform === "win32") { + // The shim runs the executable by absolute path, so the executable sees + // itself as argv0; the shim is the `t3.cmd` next to it only when launched + // from an install script's bin directory. Find it by searching the + // directories that would resolve `t3` on this shell's PATH. + const shimPath = yield* findWindowsShim(input.launchedAs); + if (shimPath === undefined) return Option.none(); + const current = yield* fs.readFileString(shimPath).pipe(Effect.option); + const quoted = Option.isSome(current) ? /^"([^"]+)"/m.exec(current.value)?.[1] : undefined; + if (quoted === undefined || !ownsTarget(quoted)) return Option.none(); + yield* fs + .writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`) + .pipe( + Effect.mapError( + () => new CliUpdateError({ reason: `Could not rewrite the t3 launcher at ${shimPath}.` }), + ), + ); + return Option.some(shimPath); + } + + const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); + if (Option.isNone(linkTarget)) return Option.none(); + const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value); + if (!ownsTarget(resolvedTarget)) return Option.none(); + const tempLink = `${input.launchedAs}.${process.pid}.tmp`; + yield* fs.symlink(input.targetEntryPath, tempLink).pipe( + Effect.andThen(fs.rename(tempLink, input.launchedAs)), + Effect.mapError( + () => + new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), + ), + ); + return Option.some(input.launchedAs); +}); + +/** + * The path the executable was started through. Node keeps the shell's + * spelling in argv0: a launcher symlink or `./t3` resolves against the + * working directory, while a bare `t3` was found on PATH and has to be + * looked up there again, or the launcher symlink is never seen. + */ +export const resolveLauncherPath = Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const invokedAs = yield* HostProcessInvokedAs; + const cwd = yield* HostProcessWorkingDirectory; + const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; + if (invokedAs.includes("/") || invokedAs.includes("\\")) { + return path.resolve(cwd, invokedAs); + } + const delimiter = platform === "win32" ? ";" : ":"; + for (const directory of (environment["PATH"] ?? "").split(delimiter)) { + if (directory.length === 0) continue; + const candidate = path.join(directory, invokedAs); + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return candidate; + } + } + return undefined; +}); + +/** + * On Windows a `.cmd` shim is what PATH resolves, but the executable it runs + * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the + * running executable; that is the launcher the install script wrote. + */ +const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( + executablePath: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const candidates = [ + ...(environment["T3CODE_INSTALL_BIN_DIR"] ? [environment["T3CODE_INSTALL_BIN_DIR"]] : []), + ...(environment["PATH"] ?? environment["Path"] ?? "").split(";"), + ].filter((entry) => entry.trim().length > 0); + for (const directory of candidates) { + const shimPath = path.join(directory, "t3.cmd"); + const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); + if (Option.isNone(contents)) continue; + const target = /^"([^"]+)"/m.exec(contents.value)?.[1]; + if ( + target !== undefined && + path.resolve(target).toLowerCase() === path.resolve(executablePath).toLowerCase() + ) { + return shimPath; + } + } + return undefined; +}); + +const updateFlags = { + ...projectLocationFlags, + channel: Flag.choice("channel", CLI_RELEASE_CHANNELS).pipe( + Flag.withDescription( + "Release channel to follow. Defaults to the channel this t3 was published on.", + ), + Flag.optional, + ), + allowDowngrade: Flag.boolean("allow-downgrade").pipe( + Flag.withDescription("Allow moving to an older version than the one running."), + Flag.withDefault(false), + ), + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription( + "Restart the background service without asking. Required to restart it from a script, where there is no prompt.", + ), + Flag.withDefault(false), + ), +}; + +const versionArgument = Argument.string("version").pipe( + Argument.withDescription( + "Exact version to install. Defaults to the newest release on the channel.", + ), + Argument.optional, +); + +export const updateCommand = Command.make("update", { + ...updateFlags, + version: versionArgument, +}).pipe( + Command.withDescription( + "Download a newer t3 and switch this machine to it, including the background service when one is installed.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* runUpdate({ + baseDir: config.baseDir, + serverRuntimeStatePath: config.serverRuntimeStatePath, + channel: Option.getOrUndefined(flags.channel), + requestedVersion: Option.getOrUndefined(flags.version), + allowDowngrade: flags.allowDowngrade, + assumeYes: flags.yes, + }).pipe( + Effect.provide( + Layer.mergeAll(bootServiceLayer(config), ProcessRunner.layer, FetchHttpClient.layer), + ), + ); + }), + ), +); + +/** + * A `t3 serve` or `t3` someone started by hand, as opposed to the one the + * background service supervises. The server records its pid on startup; a + * stale file from a crashed server is ignored by checking the pid is alive. + * + * Servers from before `serviceManaged` was recorded cannot be told apart by + * the file alone, so the launcher-supervised case is also recognised by + * lineage: a service server's parent is the launcher, and on Linux that + * launcher runs inside the unit's cgroup. + */ +const findForegroundServer = Effect.fn("cli.update.find_foreground_server")(function* (input: { + readonly serverRuntimeStatePath: string; + readonly serviceInstalled: boolean; +}) { + const state = yield* readPersistedServerRuntimeState(input.serverRuntimeStatePath); + if (Option.isNone(state) || state.value.serviceManaged || !isProcessAlive(state.value.pid)) { + return undefined; + } + if (input.serviceInstalled && (yield* belongsToBootService(state.value.pid))) return undefined; + return state.value; +}); + +const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(function* ( + pid: number, +) { + const platform = yield* HostProcessPlatform; + const fs = yield* FileSystem.FileSystem; + const runner = yield* ProcessRunner.ProcessRunner; + if (platform === "linux") { + const cgroup = yield* fs.readFileString(`/proc/${pid}/cgroup`).pipe(Effect.option); + return Option.isSome(cgroup) && cgroup.value.includes("/t3code.service"); + } + if (platform === "darwin") { + // The service server's parent is the launcher process. + const parent = yield* runner + .run({ + command: "ps", + args: ["-o", "ppid=", "-p", String(pid)], + timeout: Duration.seconds(5), + }) + .pipe(Effect.option); + const ppid = Option.isSome(parent) && parent.value.code === 0 ? parent.value.stdout.trim() : ""; + if (!/^\d+$/.test(ppid)) return false; + const command = yield* runner + .run({ command: "ps", args: ["-o", "command=", "-p", ppid], timeout: Duration.seconds(5) }) + .pipe( + Effect.map((result) => (result.code === 0 ? result.stdout : "")), + Effect.orElseSucceed(() => ""), + ); + return /__service-launcher|service-launcher\.mjs/.test(command); + } + return false; +}); + +const runUpdate = Effect.fn("cli.update.run")(function* (input: { + readonly baseDir: string; + readonly serverRuntimeStatePath: string; + readonly channel: CliReleaseChannel | undefined; + readonly requestedVersion: string | undefined; + readonly allowDowngrade: boolean; + readonly assumeYes: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + const execPath = yield* HostProcessExecutablePath; + const environment = yield* HostProcessEnvironment; + const httpClient = yield* HttpClient.HttpClient; + const service = yield* BootService.BootService; + + const currentVersion = packageJson.version; + const channel = input.channel ?? cliReleaseChannelOf(currentVersion); + if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) { + return yield* new CliUpdateError({ + reason: `'${input.requestedVersion}' is not an exact t3 version.`, + }); + } + const targetVersion = input.requestedVersion ?? (yield* resolveNewestVersion(channel)); + const targetChannel = cliReleaseChannelOf(targetVersion); + + // Preview is a maintainers' dogfooding train: it is cut by hand from + // unmerged branches, receives no fixes, and is never offered to anyone. + // Reaching it from stable or nightly takes an explicit ask and an explicit + // acknowledgement; the flag alone is not enough from a script. + const currentChannel = cliReleaseChannelOf(currentVersion); + if (targetChannel === "preview" && currentChannel !== "preview") { + yield* Console.log( + [ + `t3@${targetVersion} is a preview build.`, + " Preview builds are cut by maintainers from unreleased branches to exercise the release", + " pipeline. They can be broken, receive no fixes, and are never offered as updates; you", + ` will have to switch back to ${currentChannel} yourself with \`t3 update --channel ${currentChannel} --allow-downgrade\`.`, + ].join("\n"), + ); + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return yield* new CliUpdateError({ + reason: + "Refusing to install a preview build without confirmation. Run this from a terminal to confirm, or pass --channel preview from an interactive shell.", + }); + } + const confirmed = yield* Prompt.run( + Prompt.confirm({ message: "Install the preview build anyway?", initial: false }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + if (!confirmed) { + yield* Console.log("Left as is."); + return; + } + } + + // Work out everything that will be touched before touching anything, so the + // user sees one plan and one question rather than a surprise restart. + const status = yield* service.status; + // The unit name is per user, not per T3 home. Only touch the service when it + // serves the home this update targets; otherwise it belongs to another + // install on this machine and restarting it would take that server down. + const servesThisHome = + status.installedBaseDir !== undefined && + path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); + const serviceInstalled = status.supported && status.installed && servesThisHome; + const foreground = yield* findForegroundServer({ + serverRuntimeStatePath: input.serverRuntimeStatePath, + serviceInstalled, + }); + // What this machine runs is the executable behind the launcher and, when a + // service is installed for this home, the version that service runs. Either + // being stale is an update to do, and the newest of the two is what the + // downgrade check protects. + const serviceVersion = serviceInstalled ? status.installedVersion : undefined; + const executableCurrent = targetVersion === currentVersion; + // A service whose recorded version is missing or unreadable is not known + // to be current, so it gets the update rather than being skipped. + const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion; + const newestInstalled = + serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 + ? serviceVersion + : currentVersion; + + // Only archive-distributed versions install without Node and npm on the + // machine. Until nightly and stable ship archives, updating onto them from + // here would reintroduce the dependency this command exists to remove. + if (!isArchiveDistributedVersion(targetVersion)) { + return yield* new CliUpdateError({ + reason: `t3@${targetVersion} is published on npm only. Install it with \`npm install -g t3@${targetVersion}\`, or pick a version from the preview channel.`, + }); + } + if (executableCurrent && serviceCurrent) { + yield* Console.log( + serviceVersion !== undefined + ? `t3 and its background service are already on ${targetVersion} (${targetChannel}).` + : `t3 is already on ${targetVersion} (${targetChannel}).`, + ); + return; + } + if (!input.allowDowngrade && compareExactServiceVersions(targetVersion, newestInstalled) < 0) { + return yield* new CliUpdateError({ + reason: `t3@${targetVersion} is older than the installed ${newestInstalled}. Pass --allow-downgrade to install it anyway.`, + }); + } + + const alreadyOnDisk = yield* fs + .readFileString(pinnedRuntimePaths(path, input.baseDir, targetVersion, platform).sentinelPath) + .pipe( + Effect.map((sentinel) => sentinel.trim() === targetVersion), + Effect.orElseSucceed(() => false), + ); + + yield* Console.log( + executableCurrent + ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` + : alreadyOnDisk + ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` + : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, + ); + let restartService = false; + if (serviceInstalled && !serviceCurrent) { + yield* Console.log( + " A background service is installed for this T3 home. Restarting it interrupts anything running in it: agent turns, terminals, remote clients.", + ); + if (input.assumeYes) { + restartService = true; + } else if (process.stdin.isTTY && process.stdout.isTTY) { + restartService = yield* Prompt.run( + Prompt.confirm({ + message: "Restart the background service once the download is verified?", + initial: true, + }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + } else { + yield* Console.log( + " Not a terminal, so the service is left on its current version. Rerun with --yes to restart it, or run `t3 service update` later.", + ); + } + } + + const runtime = yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: targetVersion, + fs, + path, + runner, + httpClient, + platform, + arch, + releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined, + validate: (paths) => + runner + .run({ + command: pinnedRuntimeCommand(paths, execPath).command, + args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"], + timeout: Duration.seconds(30), + }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "verifying the downloaded t3", cause }), + ), + Effect.flatMap((result) => + result.code === 0 && /\bv(\S+)\s*$/.exec(result.stdout)?.[1] === targetVersion + ? Effect.void + : Effect.fail( + new PinnedRuntimeInstallError({ + step: "verifying the downloaded t3", + exitCode: Number(result.code), + }), + ), + ), + ), + }).pipe( + Effect.catchIf( + (error): error is PinnedRuntimeInstallError => + error._tag === "PinnedRuntimeInstallError" && + error.step.startsWith("downloading the t3 release checksums") && + String(error.cause).includes("404"), + () => + Effect.fail( + new CliUpdateError({ + reason: `No release archive was published for t3@${targetVersion}.`, + }), + ), + ), + ); + + const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const repointed = yield* repointLauncher({ + launchedAs, + versionsDir: path.dirname(runtime.versionDir), + targetEntryPath: runtime.entryPath, + }); + + // The new executable owns the service switch: it verifies itself, writes its + // own version into the unit, and restarts the service on it. + let serviceUpdated = false; + if (restartService) { + const result = yield* runner.run({ + command: runtime.entryPath, + args: [ + "service", + "update", + "--base-dir", + input.baseDir, + ...(input.allowDowngrade ? ["--allow-downgrade"] : []), + ], + timeout: Duration.minutes(5), + }); + if (result.code !== 0) { + return yield* new CliUpdateError({ + reason: `t3@${targetVersion} is installed but the background service could not be updated (exit ${String(result.code)}).\n${result.stderr.trim() || result.stdout.trim()}`, + }); + } + serviceUpdated = true; + } + + yield* Console.log(""); + yield* Console.log(`t3 ${targetVersion} is installed at ${runtime.entryPath}`); + if (Option.isSome(repointed)) { + yield* Console.log(` ${repointed.value} now runs ${targetVersion}`); + } else { + yield* Console.log(` Run it as ${runtime.entryPath}, or point your \`t3\` launcher at it.`); + } + if (serviceUpdated) { + yield* Console.log(` Background service restarted on ${targetVersion}`); + } else if (serviceInstalled && serviceCurrent) { + yield* Console.log(` Background service already on ${targetVersion}`); + } else if (serviceInstalled) { + yield* Console.log( + ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service update\` when you are ready to restart it.`, + ); + } else if (status.installed && !servesThisHome) { + yield* Console.log( + ` The background service serves ${status.installedBaseDir ?? "another T3 home"} and was left unchanged.`, + ); + } + if (foreground !== undefined) { + yield* Console.log( + ` A server started by hand is still running at ${foreground.origin} (pid ${foreground.pid}). Stop it and start it again to pick up ${targetVersion}.`, + ); + } +}); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index d1e5011f2a3a..52a2fdbb9abe 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -37,6 +37,35 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", expect(unit).not.toContain("versions/1.2.3"); }); +it("reads the served T3 home back out of a rendered unit or plist", () => { + const plan = (baseDir: string) => ({ + program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`], + launcherPath: `${baseDir}/runtime/service-launcher.mjs`, + baseDir, + logPath: `${baseDir}/userdata/logs/boot-service.log`, + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + expect( + BootService.bootServiceBaseDirOf(BootService.renderBootServiceUnit(plan("/home/theo/.t3"))), + ).toBe("/home/theo/.t3"); + // Spaces and specifiers are quoted and escaped on the way in. + expect( + BootService.bootServiceBaseDirOf( + BootService.renderBootServiceUnit(plan("/home/theo/T3 Data/100%")), + ), + ).toBe("/home/theo/T3 Data/100%"); + expect( + BootService.bootServiceBaseDirOf( + BootService.renderBootServicePlist(plan("/Users/theo/a&b"), { + homeDir: "/Users/theo", + environmentPath: "/usr/bin", + }), + ), + ).toBe("/Users/theo/a&b"); + expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); +}); + it("runs archive-distributed runtimes as their own executable", () => { const unit = BootService.renderBootServiceUnit({ program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 805ddb8ec320..94046b2e07c1 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -56,6 +56,28 @@ function quoteSystemdValue(value: string): string { : escaped; } +/** + * Reads `T3CODE_HOME` back out of a rendered unit or plist. Only values this + * file writes are expected, so a quoted systemd value is unquoted and + * unescaped the same way `quoteSystemdValue` produced it. + */ +export function bootServiceBaseDirOf(contents: string): string | undefined { + const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; + if (systemd !== undefined) { + const raw = systemd.trim(); + const unquoted = + raw.startsWith('"') && raw.endsWith('"') + ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") + : raw; + return unquoted.replaceAll("%%", "%"); + } + const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; + if (plist !== undefined) { + return plist.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + } + return undefined; +} + export interface BootServicePlan { /** * What the service manager executes. npm-distributed runtimes run the @@ -486,6 +508,13 @@ export interface BootServiceStatus { readonly installed: boolean; readonly current: boolean; readonly installedVersion?: string; + /** + * The T3 home the installed unit serves. The unit name is fixed per user, + * so a caller working against another base dir must not treat this service + * as its own; `t3 update --base-dir` learned that by restarting the live + * server of the machine it ran on. + */ + readonly installedBaseDir?: string; readonly problems?: ReadonlyArray; readonly unitPath: string; readonly logPath: string; @@ -876,6 +905,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const installedVersion = Option.isSome(stateText) ? serviceStateActiveVersion(stateText.value) : undefined; + const installedBaseDir = bootServiceBaseDirOf(unit); const normalizeUnit = (contents: string) => detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") @@ -885,6 +915,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { supported: true, installed: true, ...(installedVersion === undefined ? {} : { installedVersion }), + ...(installedBaseDir === undefined ? {} : { installedBaseDir }), problems, current: problems.length === 0 && diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2a7be73838e3..3afde2b39a7a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -7,7 +7,6 @@ * * @module ClaudeAdapterLive */ -import * as NodeSea from "node:sea"; import { type CanUseTool, @@ -70,6 +69,7 @@ import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, formatClaudeResumeCompactionQuestion, } from "@t3tools/shared/claudeCompaction"; +import { HostProcessIsExecutable } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -5133,7 +5133,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } // The single-executable has no sibling script and no Node to run one // with, so it hosts the worker as a hidden subcommand of itself. - const historyWorkerArguments = NodeSea.isSea() + const historyWorkerArguments = (yield* HostProcessIsExecutable) ? ["__claude-history"] : [ yield* path diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6fdbda5ce54c..6148bed9bdd5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -613,9 +613,11 @@ const makeServerLayer = Layer.unwrap( return; } + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const state = yield* makePersistedServerRuntimeState({ config, port: address.port, + serviceManaged: launcher.managed, }); yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, @@ -775,7 +777,7 @@ const makeServerLayer = Layer.unwrap( const serverApplicationLayer = Layer.mergeAll( routesLayer, httpListeningLayer, - runtimeStateLayer, + runtimeStateLayer.pipe(Layer.provide(launcherLayer)), tailscaleServeLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 4c2375b29a74..6fbdab817b63 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -62,6 +62,25 @@ describe("serverRuntimeState", () => { }), ); + it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () => + Effect.gen(function* () { + const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + serviceManaged: true, + }); + const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + }); + + assert.isTrue(managed.serviceManaged); + // Older readers decode the file without the field, so it is omitted + // rather than written as false. + assert.isFalse("serviceManaged" in manual); + }), + ); + it.effect("treats a missing runtime state file as absent", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index ac81941bb6f6..4afe10bd5b65 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -18,6 +18,12 @@ export const PersistedServerRuntimeState = Schema.Struct({ // Dev is single-origin: browsers must pair through this URL, not `origin`. devUrl: Schema.optional(Schema.String), startedAt: Schema.String, + /** + * Set when the boot-service launcher supervises this server. Lets a CLI + * tell a service-managed server apart from one started by hand, which is + * the difference between "restart the service" and "stop your terminal". + */ + serviceManaged: Schema.optional(Schema.Boolean), }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -50,6 +56,7 @@ const runtimeOriginForConfig = ( export const makePersistedServerRuntimeState = (input: { readonly config: Pick; readonly port: number; + readonly serviceManaged?: boolean; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ version: 1, @@ -59,6 +66,7 @@ export const makePersistedServerRuntimeState = (input: { origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), + ...(input.serviceManaged ? { serviceManaged: true } : {}), })); export const persistServerRuntimeState = (input: { diff --git a/docs/operations/release.md b/docs/operations/release.md index b42c726ca33d..2641bccf3547 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` - - manual `workflow_dispatch` with `channel=preview`, a temporary train for dogfooding the archive-based CLI runtime. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes only a GitHub prerelease. A preview is reachable solely by downloading it from that release: it is not published to npm, its desktop builds are packaged without an update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so stable and nightly installs can never be offered one. The hosted web app, AUR, and Discord announcements are skipped. Remove the channel once archives are the default on nightly and stable. + - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes only a GitHub prerelease. Nothing ever selects preview on its own: it is not on the schedule, not published to npm, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle. - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep landing while you verify and never leak into the stable build. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 4bf3ae45f6a0..06a32e533029 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -25,10 +25,10 @@ Updating restarts the server. Finish active work first, and wait for any remote update already in progress. To match a remote client's version, follow [Updating T3 Code](./updating.md). -Preview builds (`t3@preview`) install as a self-contained download from the -T3 Code GitHub release instead of through npm, so the machine running the -service does not need Node.js or npm once the CLI is on it. To get the CLI -onto a machine without Node, run the install script: +Self-contained builds install as a download from the T3 Code GitHub release +instead of through npm, so the machine running the service does not need +Node.js or npm once the CLI is on it. To get the CLI onto a machine without +Node, run the install script: ```sh curl -fsSL https://t3.codes/install.sh | sh @@ -37,8 +37,30 @@ curl -fsSL https://t3.codes/install.sh | sh On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. It places `t3` in `~/.local/bin` and reuses the same download when you later -run `t3 service install`. Set `T3CODE_VERSION` to pin an exact version, or -`T3CODE_RELEASE_BASE_URL` to download from a mirror. +run `t3 service install`. It follows the stable train by default; set +`T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact +version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that +were only published to npm are refused with the `npm install` to run instead. + +`preview` is a third train that maintainers cut from unreleased branches to +exercise the release pipeline. Those builds can be broken, receive no fixes, +and are never offered as updates; the installer and `t3 update` only take you +there when you ask for the channel explicitly, and warn you when they do. + +Once a self-contained `t3` is installed, `t3 update` moves the machine to a +newer one without npm: it downloads the newest release on the channel the +running `t3` came from, verifies it, and points the `t3` launcher at it. When +a background service is installed for the same T3 home it asks before +restarting it, since a restart interrupts running agent turns, terminals, and +remote clients; answer no and the service keeps the old version until you run +`t3 service update`. From a script there is no prompt, so pass `--yes` to +restart the service. A server you started by hand is never touched; the +command tells you it is still on the old version so you can restart it +yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to +pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or +`--allow-downgrade` to move backwards. Versions published only to npm cannot +be installed this way; the command says so and names the `npm install` to run +instead. ## Platform support diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index 2d9b2446b779..e988dabc799b 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -5,7 +5,10 @@ import { cliArchivePlatformKey, cliArchiveTarCommand, cliReleaseDownloadBaseUrl, + cliReleaseChannelOf, + cliReleaseIndexPageUrl, isArchiveDistributedVersion, + newestCliReleaseVersion, parseChecksums, } from "./cliRelease.ts"; @@ -66,4 +69,34 @@ describe("cliRelease", () => { ); expect(cliArchiveTarCommand("win32", {})).toBe("C:\\Windows\\System32\\tar.exe"); }); + + it("derives the release channel from the version alone", () => { + expect(cliReleaseChannelOf("1.2.3")).toBe("stable"); + expect(cliReleaseChannelOf("1.2.3-nightly.20260911.4")).toBe("nightly"); + expect(cliReleaseChannelOf("1.2.3-preview.20260911.4")).toBe("preview"); + // A prerelease that is not one of our trains is not silently a nightly. + expect(cliReleaseChannelOf("1.2.3-rc.1")).toBe("stable"); + }); + + it("picks the newest non-draft release on the requested channel", () => { + const releases = [ + { tag_name: "v1.2.4-preview.20260912.9", draft: true }, + { tag_name: "v1.2.4-preview.20260912.8" }, + { tag_name: "v1.2.4-nightly.20260912.7" }, + { tag_name: "desktop-preview" }, + { tag_name: "v1.2.3" }, + { tag_name: "v1.2.3-nightly.20260911.2" }, + ]; + expect(newestCliReleaseVersion(releases, "preview")).toBe("1.2.4-preview.20260912.8"); + expect(newestCliReleaseVersion(releases, "nightly")).toBe("1.2.4-nightly.20260912.7"); + expect(newestCliReleaseVersion(releases, "stable")).toBe("1.2.3"); + expect(newestCliReleaseVersion([{ tag_name: "v1.2.3" }], "preview")).toBeUndefined(); + }); + + it("pages through the release index at the largest page GitHub allows", () => { + expect(cliReleaseIndexPageUrl(1)).toBe( + "https://api.github.com/repos/pingdotgg/t3code/releases?per_page=100&page=1", + ); + expect(cliReleaseIndexPageUrl(3)).toContain("page=3"); + }); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 8863a5c1ebf7..4bdc4749b619 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -74,3 +74,47 @@ export function parseChecksums(text: string): ReadonlyMap { export function isArchiveDistributedVersion(version: string): boolean { return /-preview\.\d{8}\.\d+$/.test(version); } + +export type CliReleaseChannel = "stable" | "nightly" | "preview"; +export const CLI_RELEASE_CHANNELS: ReadonlyArray = [ + "stable", + "nightly", + "preview", +]; + +/** The release train a version was published on, derived from its prerelease tag. */ +export function cliReleaseChannelOf(version: string): CliReleaseChannel { + const channel = /^[^-+]+-(nightly|preview)\.\d{8}\.\d+$/.exec(version)?.[1]; + return channel === "nightly" || channel === "preview" ? channel : "stable"; +} + +/** + * One page of GitHub's list-releases endpoint, newest first. Callers walk pages + * until a channel match turns up; a busy nightly train can push the newest + * preview or stable release past any single page. + */ +export function cliReleaseIndexPageUrl(page: number): string { + return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`; +} + +/** + * Picks the newest version on a channel from the release index. Tags are + * `v`; the channel is decided by the same rule the runtime uses, so + * a preview tag never satisfies a nightly lookup and vice versa. Drafts are + * skipped because their assets are not downloadable. + */ +export function newestCliReleaseVersion( + releases: ReadonlyArray<{ + readonly tag_name: string; + readonly draft?: boolean | undefined; + }>, + channel: CliReleaseChannel, +): string | undefined { + for (const release of releases) { + if (release.draft) continue; + const version = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(release.tag_name)?.[1]; + if (version === undefined) continue; + if (cliReleaseChannelOf(version) === channel) return version; + } + return undefined; +} diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index dd08dfb86153..9bd41a825044 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as NodeDns from "node:dns"; import * as NodeOS from "node:os"; +import * as NodeSea from "node:sea"; export const HostProcessPlatform = Context.Reference( "@t3tools/shared/hostProcess/HostProcessPlatform", @@ -52,6 +53,31 @@ export const HostProcessArguments = Context.Reference>( }, ); +/** + * The command the shell was given, before Node resolved it to the binary: + * `t3` for a PATH lookup, `./t3` or the launcher symlink for an explicit + * path. `process.argv[0]` and `execPath` are always the resolved binary. + */ +export const HostProcessInvokedAs = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessInvokedAs", + { + defaultValue: () => process.argv0, + }, +); + +/** + * Whether this process is a Node single-executable rather than a script run + * by a Node on the machine. Code that needs a sibling file or a Node to run + * one branches on this: an executable hosts such things as hidden + * subcommands of itself. + */ +export const HostProcessIsExecutable = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessIsExecutable", + { + defaultValue: () => NodeSea.isSea(), + }, +); + /** * Every IP address this machine answers to: the interface addresses, plus * whatever the resolver returns for the machine's own hostname. The latter diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 84e8f9f31494..0a9c21193fd3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2616,7 +2616,7 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } -// Pull request builds (`-pr..`) and the temporary preview train +// Pull request builds (`-pr..`) and the maintainers' preview train // (`-preview..`) are downloaded by hand and never through an // updater. Building them without a publish config means electron-builder // emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and From af6c138a0de297f94ddef3133e2f47d017b46e94 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:22 -0700 Subject: [PATCH 14/27] feat(server): manage runtimes as release archives only, never from npm (#11510) Co-authored-by: Claude Fable 5 --- apps/desktop/src/main.ts | 25 +- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 12 +- apps/marketing/.gitignore | 2 + apps/marketing/package.json | 5 +- .../scripts/stage-install-scripts.mjs | 15 + apps/server/package.json | 2 +- apps/server/scripts/cli.ts | 6 +- apps/server/src/cli/service.test.ts | 3 +- apps/server/src/cli/serviceLauncher.ts | 7 +- apps/server/src/cli/update.ts | 17 +- apps/server/src/cloud/bootService.test.ts | 127 +++---- apps/server/src/cloud/bootService.ts | 50 +-- apps/server/src/cloud/pinnedRuntime.test.ts | 272 ++++---------- apps/server/src/cloud/pinnedRuntime.ts | 133 ++----- apps/server/src/cloud/selfUpdate.test.ts | 43 ++- apps/server/src/cloud/selfUpdate.ts | 13 +- apps/server/src/cloud/serviceProtocol.ts | 1 - apps/server/src/service-launcher.ts | 20 -- apps/server/src/serviceLauncher.test.ts | 65 ++-- apps/server/src/serviceLauncher.ts | 40 +-- docs/operations/release.md | 28 +- docs/user/background-service.md | 7 +- knip.jsonc | 1 - packages/shared/src/cliRelease.test.ts | 7 - packages/shared/src/cliRelease.ts | 5 - packages/ssh/src/command.test.ts | 36 -- packages/ssh/src/command.ts | 20 +- packages/ssh/src/runnerProcess.test.ts | 339 +++++------------- packages/ssh/src/tunnel.test.ts | 208 +++++------ packages/ssh/src/tunnel.ts | 276 +++++++------- .../marketing/public => scripts}/install.ps1 | 2 +- {apps/marketing/public => scripts}/install.sh | 2 +- 32 files changed, 626 insertions(+), 1163 deletions(-) create mode 100644 apps/marketing/.gitignore create mode 100644 apps/marketing/scripts/stage-install-scripts.mjs delete mode 100644 apps/server/src/service-launcher.ts rename {apps/marketing/public => scripts}/install.ps1 (97%) rename {apps/marketing/public => scripts}/install.sh (96%) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b273fe7822a..0d626a51955d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,8 +17,6 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; -import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( }), ); +// The remote runs the exact release this app is on, from its self-contained +// archive, so it needs neither Node nor npm. Development points the remote at +// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], - settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = ( nodeEngineRange: serverPackageJson.engines.node, }; } - // Preview builds ship as self-contained archives, so the remote runs the - // same version this app is on without Node or npm. - if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) { - return { archiveVersion: environment.appVersion }; - } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: environment.appVersion, - updateChannel: settings.updateChannel, - isDevelopment: environment.isDevelopment, - }), - nodeEngineRange: serverPackageJson.engines.node, - }; + return { archiveVersion: environment.appVersion }; }; const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopSshEnvironment.layer({ - resolveCliRunner: settings.get.pipe( - Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), - ), + resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), }); }), ); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 3b135eb2508f..b0094f6867bc 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined + SshTunnel.SshEnvironmentManager.layer( + options.resolveCliRunner === undefined ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), + : { resolveCliRunner: options.resolveCliRunner }, + ), ), ); diff --git a/apps/marketing/.gitignore b/apps/marketing/.gitignore new file mode 100644 index 000000000000..254b88f1a73f --- /dev/null +++ b/apps/marketing/.gitignore @@ -0,0 +1,2 @@ +/public/install.sh +/public/install.ps1 diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 80a36fc54af4..511552d74fbd 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "stage:install-scripts": "node scripts/stage-install-scripts.mjs", + "dev": "node scripts/stage-install-scripts.mjs && astro dev", + "build": "node scripts/stage-install-scripts.mjs && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/scripts/stage-install-scripts.mjs b/apps/marketing/scripts/stage-install-scripts.mjs new file mode 100644 index 000000000000..80f458e8acaa --- /dev/null +++ b/apps/marketing/scripts/stage-install-scripts.mjs @@ -0,0 +1,15 @@ +// The CLI install scripts live in scripts/ at the repo root with the rest of +// the release tooling; the site serves them at /install.sh and /install.ps1. +// Copy them into public/ before every Astro build and dev server so the two +// never drift. The copies are gitignored. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir)); +const publicDir = NodePath.join(marketingDir, "public"); +NodeFS.mkdirSync(publicDir, { recursive: true }); +for (const name of ["install.sh", "install.ps1"]) { + NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name)); +} diff --git a/apps/server/package.json b/apps/server/package.json index 1892f7f3d6d7..f3ee6cda3aef 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,7 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", + "build:bundle": "vp pack", "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", "typecheck": "tsc --noEmit", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 09866c316eea..06c22738853e 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -284,11 +284,7 @@ const publishCmd = Command.make( const packageJsonPath = path.join(serverDir, "package.json"); // Assert build assets exist - for (const relPath of [ - "dist/bin.mjs", - "dist/service-launcher.mjs", - "dist/client/index.html", - ]) { + for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index b682c4ce44ce..50bdb1f5fbd3 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { Effect.sync(() => { installOptions.push(options); return { - program: ["/test/node", "/test/service-launcher.mjs"], - launcherPath: "/test/service-launcher.mjs", + program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"], baseDir: "/test/t3", unitPath: serviceStatus.unitPath, logPath: serviceStatus.logPath, diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts index 696fc55c45f0..280177fc2807 100644 --- a/apps/server/src/cli/serviceLauncher.ts +++ b/apps/server/src/cli/serviceLauncher.ts @@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli"; import { main as runServiceLauncher } from "../serviceLauncher.ts"; /** - * Hosts the service launcher inside the CLI executable. Archive-distributed - * runtimes have no Node on the machine to run `service-launcher.mjs`, so the - * service manager runs `t3 __service-launcher` and the launcher spawns the - * server from the same executable. + * Hosts the service launcher inside the CLI executable. The service manager + * runs `t3 __service-launcher` and the launcher spawns the server from the + * same executable, so the machine needs no Node to run either. * * The launcher owns SIGTERM handling and the process lifetime: it must finish * stopping its child before the process exits, so it runs detached from the diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index dfcebf07e4ee..62a66b985022 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,7 +1,6 @@ import { HostProcessArchitecture, HostProcessEnvironment, - HostProcessExecutablePath, HostProcessInvokedAs, HostProcessIsExecutable, HostProcessPlatform, @@ -12,7 +11,6 @@ import { CLI_RELEASE_CHANNELS, cliReleaseIndexPageUrl, cliReleaseChannelOf, - isArchiveDistributedVersion, newestCliReleaseVersion, type CliReleaseChannel, } from "@t3tools/shared/cliRelease"; @@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun Effect.map((result) => (result.code === 0 ? result.stdout : "")), Effect.orElseSucceed(() => ""), ); - return /__service-launcher|service-launcher\.mjs/.test(command); + return /__service-launcher/.test(command); } return false; }); @@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const runner = yield* ProcessRunner.ProcessRunner; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; - const execPath = yield* HostProcessExecutablePath; const environment = yield* HostProcessEnvironment; const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; @@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ? serviceVersion : currentVersion; - // Only archive-distributed versions install without Node and npm on the - // machine. Until nightly and stable ship archives, updating onto them from - // here would reintroduce the dependency this command exists to remove. - if (!isArchiveDistributedVersion(targetVersion)) { - return yield* new CliUpdateError({ - reason: `t3@${targetVersion} is published on npm only. Install it with \`npm install -g t3@${targetVersion}\`, or pick a version from the preview channel.`, - }); - } if (executableCurrent && serviceCurrent) { yield* Console.log( serviceVersion !== undefined @@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { validate: (paths) => runner .run({ - command: pinnedRuntimeCommand(paths, execPath).command, - args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"], + command: pinnedRuntimeCommand(paths).command, + args: [...pinnedRuntimeCommand(paths).args, "--version"], timeout: Duration.seconds(30), }) .pipe( diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 52a2fdbb9abe..54ef1ffdd5f5 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { - HostProcessArguments, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,6 +11,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 { HttpClient } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -23,24 +23,25 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; -it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); +const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; +const linuxPlan = { + program: [linuxRuntime, "__service-launcher"], + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", +}; + +it("runs the pinned runtime's own executable as the systemd launcher", () => { + const unit = BootService.renderBootServiceUnit(linuxPlan); - expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); + expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); expect(unit).toContain("KillMode=mixed"); - expect(unit).not.toContain("versions/1.2.3"); + expect(unit).not.toContain("node"); }); it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ - program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`], - launcherPath: `${baseDir}/runtime/service-launcher.mjs`, + program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], baseDir, logPath: `${baseDir}/userdata/logs/boot-service.log`, unitPath: "/home/theo/.config/systemd/user/t3code.service", @@ -66,36 +67,15 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); }); -it("runs archive-distributed runtimes as their own executable", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); - - expect(unit).toContain( - "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher", - ); - expect(unit).not.toContain("node"); -}); - it("survives the kernel OOM-killing a greedy agent child", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); + const unit = BootService.renderBootServiceUnit(linuxPlan); expect(unit).toContain("OOMPolicy=continue"); }); +const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; const macPlan = { - program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", + program: [macRuntime, "__service-launcher"], baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", @@ -104,12 +84,13 @@ const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; -it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { +it("runs the pinned runtime's own executable as the launch agent", () => { const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - expect(plist).toContain("/opt/homebrew/bin/node"); - expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); - expect(plist).not.toContain("versions/1.2.3"); + expect(plist).toContain( + ` \n ${macRuntime}\n __service-launcher\n `, + ); + expect(plist).not.toContain("node"); }); it("preserves the installer's provider search path in the launch agent", () => { @@ -150,23 +131,18 @@ it("escapes XML in host paths", () => { const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", - usePinnedLauncher = false, installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); const baseDir = path.join(home, ".t3"); - const sourceLauncher = path.join(home, "service-launcher.mjs"); const statePath = path.join(baseDir, "runtime", "service-state.json"); - yield* fs.writeFileString(sourceLauncher, "export {};\n"); - const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + // A complete pinned runtime is already present, so install only validates + // it and never downloads a release archive. + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); - yield* fs.writeFileString(runtime.entryPath, "export {};\n"); - yield* fs.writeFileString( - path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), - "export const source = 'pinned runtime';\n", - ); + yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; @@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( } return { stdout: - input.args[1] === "--version" + input.args[0] === "--version" ? "t3 v1.2.3\n" : input.command === "loginctl" && input.args[0] === "show-user" ? `${control.linger}\n` @@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( baseDir, logsDir: path.join(baseDir, "userdata", "logs"), cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, + host: { execPath: "/usr/bin/t3" }, }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provide( Layer.mergeAll( Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("no release download expected")), + ), ConfigProvider.layer( ConfigProvider.fromEnv({ env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, @@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(error.message).toContain("last login session ends"); expect(yield* fs.exists(before.unitPath)).toBe(false); expect(yield* fs.exists(statePath)).toBe(false); - expect( - commands.some((command) => command.startsWith("npm ") || command.includes("--version")), - ).toBe(false); + expect(commands.some((command) => command.includes("--version"))).toBe(false); expect( commands.some( (command) => command.includes("daemon-reload") || command.includes("restart"), @@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); + const { service, fs, statePath, timeouts, runtime } = yield* makeHarness(); const plan = yield* service.install(); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + `ExecStart=${runtime.entryPath} __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", @@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).current).toBe(false); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); // The stop can block up to systemd's 90s TimeoutStopSec; the runner's // 60s default would cancel it mid-shutdown. expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( @@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs, statePath, commands, control } = yield* makeHarness(platform); const plan = yield* service.install(); - const launcher = yield* fs.readFileString(plan.launcherPath); const unit = yield* fs.readFileString(plan.unitPath); control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; commands.length = 0; @@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { targetVersion: "1.2.3", }); expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); - expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); expect( commands.filter( @@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("copies the launcher from the prepared pinned runtime", () => - Effect.gen(function* () { - const { service, fs } = yield* makeHarness("linux", true); - const plan = yield* service.install(); - - expect(yield* fs.readFileString(plan.launcherPath)).toBe( - "export const source = 'pinned runtime';\n", - ); - }), - ); - it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); @@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); + const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); const path = yield* Path.Path; const plan = yield* service.install(); @@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` ${runtime.entryPath}\n __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", }); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // A bootout can block up to the plist's 90s ExitTimeOut; the runner's // 60s default would cancel it and let bootstrap race a loaded job. @@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, ""); + const { service, fs } = yield* makeHarness("darwin", ""); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("adds missing provider directories to a minimal installer PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin"); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs } = yield* makeHarness( "darwin", - false, "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", ); const plan = yield* service.install(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 94046b2e07c1..cb712879bbdd 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -26,7 +26,6 @@ import { PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { - SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, compareExactServiceVersions, @@ -86,7 +85,6 @@ export interface BootServicePlan { * subcommand so the machine never needs Node. */ readonly program: ReadonlyArray; - readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; @@ -533,7 +531,6 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; - readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: { @@ -546,9 +543,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; const uid = yield* HostProcessUserId; - // Archive-distributed versions download from GitHub Releases; npm versions - // never touch HTTP, so callers without a client still work. - const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); @@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); - const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); - const launcherSourcePath = - host.launcherSourcePath ?? - path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { @@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // The executable hosts the launcher as a hidden subcommand of itself, so + // the unit runs the pinned runtime directly. const plan: BootServicePlan = { - program: - runtimePaths.layout === "archive" - ? [runtimePaths.entryPath, "__service-launcher"] - : [host.execPath, launcherPath], - launcherPath, + program: [runtimePaths.entryPath, "__service-launcher"], baseDir: input.baseDir, logPath, unitPath, @@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { validate: (runtime) => runner .run({ - command: pinnedRuntimeCommand(runtime, host.execPath).command, - args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], + command: pinnedRuntimeCommand(runtime).command, + args: [...pinnedRuntimeCommand(runtime).args, "--version"], timeout: Duration.seconds(30), }) .pipe( @@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : new BootServiceInstallError({ cause: error }), ), ); - // Archive runtimes host the launcher in the executable itself; there is - // no standalone script to copy. - const launcherSource = - runtimePaths.layout === "archive" - ? undefined - : yield* fs - .readFileString(launcherSourcePath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); @@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (launcherSource !== undefined) { - yield* writeDurably(launcherPath, launcherSource); - } yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. @@ -893,14 +870,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = - yield* Effect.all([ - fs.readFileString(unitPath), - runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), - fs.exists(runtimePaths.entryPath), - fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), - fs.readFileString(statePath).pipe(Effect.option), - ]); + const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ + fs.readFileString(unitPath), + fs.exists(runtimePaths.entryPath), + fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), + fs.readFileString(statePath).pipe(Effect.option), + ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; const installedVersion = Option.isSome(stateText) ? serviceStateActiveVersion(stateText.value) @@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { current: problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && - launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && runtimeSentinel.value.trim() === input.cliVersion && diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index b542907b4d01..e4b16b8f7190 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -17,16 +16,38 @@ import { PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; -const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => +// Every install fetches the release archive, checks it against SHA256SUMS, +// and unpacks it with tar. The fake client serves both files; the fake runner +// stands in for tar and drops the executable where extraction would. +const version = "1.2.3"; +const archiveName = `t3-${version}-linux-x64.tar.gz`; +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const archiveHex = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); +const validChecksums = archiveHex(archiveBytes).pipe( + Effect.map((hex) => `${hex} ${archiveName}\n`), +); +const releaseHttpClient = (checksums: string, requests: string[] = []) => + HttpClient.make((request) => { + requests.push(request.url); + const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); + }); +const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) => ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - const prefixIndex = input.args.indexOf("--prefix"); - const stagingDir = input.args[prefixIndex + 1]; - if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); - const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + commands.push(input.command); + const targetIndex = input.args.indexOf("-C"); + const stagingDir = input.args[targetIndex + 1]; + if (input.command !== "tar" || stagingDir === undefined) { + return yield* Effect.die(`unexpected command ${input.command}`); + } + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { - it.effect("installs through pnpm when its Node runtime has no npm executable", () => + it.effect("installs the verified release archive as the runtime executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); - const commands: Array = []; - const install = successfulRunner(fs, path); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); + const requests: string[] = []; + const commands: string[] = []; const paths = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input); - return input.command === "npm" - ? Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "npm", - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - }), - }), - ) - : install.run(input); - }, - }), + httpClient: releaseHttpClient(yield* validChecksums, requests), + releaseBaseUrl: "https://releases.example/download", + runner: extractingRunner(fs, path, commands), validate: (staging) => fs.exists(staging.entryPath).pipe( Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), Effect.orDie, ), }); - assert.deepEqual( - commands.map((command) => command.command), - ["npm", "pnpm"], - ); - assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); + assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] }); + assert.deepEqual(requests, [ + `https://releases.example/download/v${version}/SHA256SUMS`, + `https://releases.example/download/v${version}/${archiveName}`, + ]); + assert.deepEqual(commands, ["tar"]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); + assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); }), ); - it.effect("does not try a different installer for npm permission failures", () => + it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); const commands: string[] = []; - yield* ensurePinnedRuntimeInstalled({ + const error = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input.command); - return Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - }), - }), - ); - }, - }), - validate: () => Effect.die("must not validate a failed install"), + httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`), + runner: extractingRunner(fs, path, commands), + validate: () => Effect.die("must not validate an unverified archive"), }).pipe(Effect.flip); - assert.deepEqual(commands, ["npm"]); + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal(error.step, "verifying the t3 release archive checksum"); + assert.deepEqual(commands, []); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); }), ); @@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); let validatedDirectory = ""; const installed = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: (staging) => Effect.gen(function* () { validatedDirectory = staging.versionDir; @@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.notEqual(validatedDirectory, finalPaths.versionDir); assert.deepEqual(installed, finalPaths); assert.isTrue(yield* fs.exists(finalPaths.entryPath)); - assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); + assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`); }), ); @@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), }).pipe(Effect.flip); @@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.void, }); @@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); - yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); + yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`); let validations = 0; + const requests: string[] = []; yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path), validate: (paths) => Effect.gen(function* () { validations += 1; @@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }).pipe(Effect.flip); assert.equal(validations, 1); + assert.deepEqual(requests, []); assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); }), ); @@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }); const install = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), runner, validate: () => Effect.void, }).pipe(Effect.forkScoped); @@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(yield* fs.readDirectory(versionsDir), []); }), ); - - // Archive-distributed versions never touch npm: the release archive is - // fetched, checked against SHA256SUMS, and unpacked with tar. - const archiveVersion = "1.3.0-preview.20260911.7"; - const archiveName = `t3-${archiveVersion}-linux-x64.tar.gz`; - const archiveBytes = new TextEncoder().encode("not really a tarball"); - const archiveHex = (bytes: Uint8Array) => - Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( - Effect.map((digest) => - Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const releaseHttpClient = (checksums: string, requests: string[]) => - HttpClient.make((request) => { - requests.push(request.url); - const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); - }); - const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[]) => - ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.gen(function* () { - commands.push(input.command); - const targetIndex = input.args.indexOf("-C"); - const stagingDir = input.args[targetIndex + 1]; - if (input.command !== "tar" || stagingDir === undefined) { - return yield* Effect.die(`unexpected command ${input.command}`); - } - yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); - return { - stdout: "", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - - it.effect("installs archive-distributed versions from the verified release archive", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); - const requests: string[] = []; - const commands: string[] = []; - const checksums = `${yield* archiveHex(archiveBytes)} ${archiveName}\n`; - const paths = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: archiveVersion, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(checksums, requests), - releaseBaseUrl: "https://releases.example/download", - runner: extractingRunner(fs, path, commands), - validate: (staging) => - fs.exists(staging.entryPath).pipe( - Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), - Effect.orDie, - ), - }); - assert.equal(paths.layout, "archive"); - assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); - assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { - command: paths.entryPath, - args: [], - }); - assert.deepEqual(requests, [ - `https://releases.example/download/v${archiveVersion}/SHA256SUMS`, - `https://releases.example/download/v${archiveVersion}/${archiveName}`, - ]); - assert.deepEqual(commands, ["tar"]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), `${archiveVersion}\n`); - assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); - }), - ); - - it.effect("refuses an archive whose checksum does not match the release", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); - const commands: string[] = []; - const error = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: archiveVersion, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`, []), - runner: extractingRunner(fs, path, commands), - validate: () => Effect.die("must not validate an unverified archive"), - }).pipe(Effect.flip); - assert.instanceOf(error, PinnedRuntimeInstallError); - assert.equal(error.step, "verifying the t3 release archive checksum"); - assert.deepEqual(commands, []); - assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); - }), - ); - - it("runs npm layouts through the host Node", () => { - const paths = pinnedRuntimePaths( - { join: (...parts: string[]) => parts.join("/") } as Path.Path, - "/home/theo/.t3", - "1.2.3", - "linux", - ); - assert.equal(paths.layout, "npm"); - assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { - command: "/usr/bin/node", - args: ["/home/theo/.t3/runtime/versions/1.2.3/node_modules/t3/dist/bin.mjs"], - }); - }); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index c4e02e41ddf7..686d5cec9d2a 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; @@ -15,26 +14,21 @@ import { cliArchivePlatformKey, cliArchiveTarCommand, cliReleaseDownloadBaseUrl, - isArchiveDistributedVersion, parseChecksums, } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; /** - * A pinned runtime is an exact `t3@` installed into - * /runtime/versions/. The boot service points its unit or - * launch agent here, and server self-update installs the target version here before - * switching over, never `npx t3`, whose cache is ephemeral and whose - * registry fetch at boot would make startup depend on the network. - * - * Two layouts exist. npm-distributed versions are `npm install`ed and run as - * ` node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the - * self-contained release archive unpacked in place and run as `./t3`, which - * needs neither Node nor npm on the machine. The layout is decided by the - * version string alone so every consumer agrees without probing the disk. + * A pinned runtime is an exact t3 release archive unpacked into + * /runtime/versions/: the self-contained executable, the + * web client, and the native packages beside it. The boot service points its + * unit or launch agent at the executable, and server self-update installs the + * target version here before switching over. The runtime never depends on a + * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` + * package for people who prefer `npx t3` or `npm install -g t3`, and even a + * CLI installed that way pins an archive when it sets up the service. */ - const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; @@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); -export type PinnedRuntimeLayout = "npm" | "archive"; - export interface PinnedRuntimePaths { - readonly layout: PinnedRuntimeLayout; readonly versionDir: string; - /** - * `bin.mjs` for npm layouts, the executable itself for archives. Existence - * of this file is what marks a runtime as present. - */ + /** The executable. Its existence is what marks a runtime as present. */ readonly entryPath: string; readonly sentinelPath: string; } -/** The exact command that runs a pinned runtime, given the Node hosting the caller. */ -export function pinnedRuntimeCommand( - paths: PinnedRuntimePaths, - nodePath: string, -): { readonly command: string; readonly args: ReadonlyArray } { - return paths.layout === "archive" - ? { command: paths.entryPath, args: [] } - : { command: nodePath, args: [paths.entryPath] }; +/** The exact command that runs a pinned runtime. */ +export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { + readonly command: string; + readonly args: ReadonlyArray; +} { + return { command: paths.entryPath, args: [] }; } export function pinnedRuntimePaths( @@ -72,20 +58,10 @@ export function pinnedRuntimePaths( platform: NodeJS.Platform, ): PinnedRuntimePaths { const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); - const sentinelPath = path.join(versionDir, ".install-complete"); - if (isArchiveDistributedVersion(version)) { - return { - layout: "archive", - versionDir, - entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), - sentinelPath, - }; - } return { - layout: "npm", versionDir, - entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), - sentinelPath, + entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), + sentinelPath: path.join(versionDir, ".install-complete"), }; } @@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError` into the pinned runtime directory unless a complete - * install is already there, and returns its paths. The sentinel is written - * only after the install step exits 0; checking the entry file alone is not - * enough. npm extracts files before running native builds (node-pty), and tar - * writes the executable before the last native package, so a killed install - * leaves a plausible-looking but broken tree behind. + * Installs the t3 release archive for `version` into the pinned runtime + * directory unless a complete install is already there, and returns its + * paths. The sentinel is written only after extraction and validation + * succeed; checking the entry file alone is not enough, since tar writes the + * executable before the last native package and a killed install leaves a + * plausible-looking but broken tree behind. */ interface PinnedRuntimeInstallInput { readonly baseDir: string; @@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput { ) => Effect.Effect; readonly platform: NodeJS.Platform; readonly arch: string; - /** Archive-distributed versions download from here; npm versions never need it. */ - readonly httpClient?: HttpClient.HttpClient | undefined; + readonly httpClient: HttpClient.HttpClient; readonly releaseBaseUrl?: string | undefined; } -const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* ( - input: PinnedRuntimeInstallInput, - stagingDir: string, -) { - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ]; - yield* input.runner - .run({ - command: "npm", - args: installArgs, - // Native dependencies may compile from source on slower machines. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? // pnpm-managed Node installations do not include npm. Keep npm - // installation semantics for the pinned runtime and native builds. - input.runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - : Effect.fail(error), - }), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ); -}); - const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( httpClient: HttpClient.HttpClient, url: string, @@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun }); } const httpClient = input.httpClient; - if (httpClient === undefined) { - return yield* new PinnedRuntimeInstallError({ - step: "downloading the t3 release archive (no HTTP client available)", - }); - } const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); const fileName = cliArchiveFileName(input.version, platformKey); @@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ); const stagingPaths: PinnedRuntimePaths = { - layout: paths.layout, versionDir: stagingDir, entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { - if (paths.layout === "archive") { - yield* installFromArchive(input, stagingDir); - } else { - yield* installFromNpm(input, stagingDir); - } + yield* installFromArchive(input, stagingDir); yield* input.validate(stagingPaths); yield* fs diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index e6d8010f19d7..8ca0baa64a32 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,13 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; @@ -25,6 +26,28 @@ interface HarnessOptions { readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; } +// The staged runtime is a release archive: the fake client serves SHA256SUMS +// and the tarball, and the fake runner stands in for tar before it answers +// the staged preflight. +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const releaseHttpClient = (order: string[]) => + HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/SHA256SUMS")) { + const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); + const hex = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return HttpClientResponse.fromWeb( + request, + new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`), + ); + } + order.push("download"); + return HttpClientResponse.fromWeb(request, new Response(archiveBytes)); + }), + ); + const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( options: HarnessOptions = {}, ) { @@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - if (input.command === "npm") { - order.push("install"); - const prefix = input.args[input.args.indexOf("--prefix") + 1]; - if (prefix === undefined) return yield* Effect.die("missing npm prefix"); - const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + if (input.command === "tar") { + order.push("extract"); + const stagingDir = input.args[input.args.indexOf("-C") + 1]; + if (stagingDir === undefined) return yield* Effect.die("missing tar target"); + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( run: () => Effect.die("unexpected desktop app update run"), }, ), - Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), + Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); return { selfUpdate, order }; @@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { method: "boot-service", updateId: "launcher-id", }); - expect(order).toEqual(["install", "preflight", "accept"]); + expect(order).toEqual(["download", "extract", "preflight", "accept"]); }), ); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 734c8ca732fd..a388f8a0b4d7 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,11 +6,7 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { - HostProcessArchitecture, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const execPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; // Archive-distributed targets download from GitHub Releases. The client is // optional so callers without one (tests, npm-only hosts) still construct. - const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); @@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { validate: (runtime) => runner .run({ - command: pinnedRuntimeCommand(runtime, execPath).command, + command: pinnedRuntimeCommand(runtime).command, args: [ - ...pinnedRuntimeCommand(runtime, execPath).args, + ...pinnedRuntimeCommand(runtime).args, "__service-preflight", "--database-path", serverConfig.dbPath, diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index bb61866a93ff..89610ca918a3 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; -export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; export const SERVICE_STATE_FILE = "service-state.json"; /** Written by the launcher just before an explicit stop kills its child, so the child can tell "the service is going away" from "the launcher is about diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts deleted file mode 100644 index b76199833923..000000000000 --- a/apps/server/src/service-launcher.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Standalone launcher entry for npm-distributed runtimes: `node -// service-launcher.mjs`. Archive runtimes reach the same code through the -// `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run -// anything on import. -import { isEntrypoint } from "./entrypoint.ts"; -import { main } from "./serviceLauncher.ts"; - -if ( - isEntrypoint({ - moduleUrl: import.meta.url, - entryPath: process.argv[1], - runtimeMain: import.meta.main, - }) -) { - main().catch((cause: unknown) => { - const error = cause instanceof Error ? cause : new Error(String(cause)); - process.stderr.write(`[service-launcher] ${error.message}\n`); - process.exitCode = 1; - }); -} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 45c472af1fc4..db1a6a8bee34 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -75,6 +75,27 @@ it("rejects contradictory service state", () => { ); }); +// A pinned runtime is an executable at /t3. The tests stand one up +// as a Node shebang script so the launcher spawns it the way it spawns the +// real single-executable, IPC channel included. +const writeFakeRuntime = ( + fs: FileSystem.FileSystem, + path: Path.Path, + versionDir: string, + childSource: string, +) => + Effect.gen(function* () { + const entryPath = path.join(versionDir, "t3"); + yield* fs.makeDirectory(versionDir, { recursive: true }); + yield* fs.writeFileString(entryPath, `#!${process.execPath}\n${childSource}`); + yield* fs.chmod(entryPath, 0o755); + yield* fs.writeFileString( + path.join(versionDir, ".install-complete"), + `${path.basename(versionDir)}\n`, + ); + return entryPath; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { @@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); const statePath = path.join(root, "runtime", "service-state.json"); - const versionDir = path.join(root, "runtime", "versions", "1.0.0"); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); yield* Effect.promise(() => writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -148,11 +170,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -198,11 +221,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -257,11 +281,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index fc3d8674e68e..a1e16a627163 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,13 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// This file is shipped as a standalone bundle and copied to a stable path by -// `t3 service update`. Keep runtime imports limited to Node built-ins. +// The launcher supervises the server child for the boot service and must keep +// working across server versions, so it stays on Node built-ins with no Effect +// runtime: it is the one part of the executable that cannot depend on the +// rest of it being loadable. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeSea from "node:sea"; import type { PendingServiceUpdate, @@ -41,37 +42,24 @@ interface ManagedChild { readonly process: NodeChildProcess.ChildProcess; } -// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked -// release archives whose executable runs on its own, npm versions are a -// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node +// Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose +// executable runs on its own. Kept inline so this file stays on Node // built-ins only. const runtimePaths = (baseDir: string, version: string) => { const versionDir = NodePath.join(baseDir, "runtime", "versions", version); - const archive = /-preview\.\d{8}\.\d+$/.test(version); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const executableName = process.platform === "win32" ? "t3.exe" : "t3"; return { versionDir, - entryPath: archive - ? NodePath.join(versionDir, executableName) - : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: NodePath.join(versionDir, executableName), sentinelPath: NodePath.join(versionDir, ".install-complete"), - executable: archive, }; }; -const runtimeSpawnArguments = (paths: ReturnType) => - paths.executable - ? { command: paths.entryPath, args: ["serve"] } - : { command: process.execPath, args: [paths.entryPath, "serve"] }; - -// An npm-layout runtime needs a Node interpreter. When the launcher itself is -// the single-executable, process.execPath is `t3`, which cannot run a -// bin.mjs, so the two layouts cannot be mixed within one service install. -const launcherIsExecutable = NodeSea.isSea(); - -const canLaunchRuntime = (paths: ReturnType) => - paths.executable || !launcherIsExecutable; +const runtimeSpawnArguments = (paths: ReturnType) => ({ + command: paths.entryPath, + args: ["serve"], +}); /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; @@ -505,12 +493,6 @@ export class Launcher { await reject("The requested database path is not absolute."); return; } - if (!canLaunchRuntime(runtimePaths(this.#baseDir, message.targetVersion))) { - await reject( - "This service runs from a self-contained t3 executable and cannot switch to an npm-installed version. Reinstall the service with the target version instead.", - ); - return; - } if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { await reject("The requested target runtime is missing or incomplete."); return; diff --git a/docs/operations/release.md b/docs/operations/release.md index 2641bccf3547..54e3f5e55eae 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. - - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the download every runtime installer will verify against `SHA256SUMS`. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. + - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. @@ -251,12 +251,13 @@ Windows packages the bundled server and only its runtime-external/native dependency closure in `resources/server.asar`. Native modules and helper executables declared as unpacked by that archive must be present at the matching paths below `resources/server.asar.unpacked`. The Windows-native backend reads -the archive in place through Electron. Packaged Windows builds also ship a -Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies -and extracts that archive into `~/.t3/wsl-runtime/sha256-` inside -the selected distro, then reuses it for later launches of the same update. The -Windows-side `wsl-server-tree/` extraction remains a fallback and is -removed after the distro-local runtime passes preflight. +the archive in place through Electron. Packaged Windows builds also ship +`resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive +(`t3--linux-x64.tar.gz`) built by the `build_linux_cli` job and handed +to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs +the exact bytes a Linux user downloads. WSL verifies and extracts that archive +into `~/.t3/wsl-runtime/sha256-` inside the selected distro, +then reuses it for later launches of the same update. Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only native libraries and helper executables. Avoid enabling whole-package smart @@ -272,11 +273,12 @@ break: - On same-architecture Windows builds, the packaged primary cannot load the fff native library from inside `server.asar` through its `.unpacked` sibling. - The isolated, extracted sidecar cannot load the server entry with plain Node. -- A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256 - sidecar, the sidecar digest does not match the emitted archive, or required - Linux runtime members are absent. -- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY, - pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings. +- A Windows build given `--wsl-runtime` omits the WSL archive or SHA-256 + sidecar, or the sidecar digest does not match the emitted archive. +- The emitted WSL archive is not a Linux CLI release archive: it must unpack to + a single `t3--linux-` directory holding `t3`, `client/`, and + `node_modules/` with the Linux node-pty binary, and must not carry a loose + server bundle (`bin.mjs`). - The external Windows resource monitor is absent. - The unpacked Windows application contains more than 80 files. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 06a32e533029..86b022cf1245 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. It places `t3` in `~/.local/bin` and reuses the same download when you later run `t3 service install`. It follows the stable train by default; set `T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact -version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that -were only published to npm are refused with the `npm install` to run instead. +version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. `preview` is a third train that maintainers cut from unreleased branches to exercise the release pipeline. Those builds can be broken, receive no fixes, @@ -58,9 +57,7 @@ restart the service. A server you started by hand is never touched; the command tells you it is still on the old version so you can restart it yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or -`--allow-downgrade` to move backwards. Versions published only to npm cannot -be installed this way; the command says so and names the `npm install` to run -instead. +`--allow-downgrade` to move backwards. ## Platform support diff --git a/knip.jsonc b/knip.jsonc index e225298fabc0..d203e6405585 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -20,7 +20,6 @@ // Vite+ pack entries and the launcher used by installed background services. "entry": [ "src/bin.ts!", - "src/service-launcher.ts!", "src/claude-history-worker.ts!", "scripts/cli.ts", "src/provider/testFixtures/*.mjs", diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index e988dabc799b..c5109ebee3a0 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -7,7 +7,6 @@ import { cliReleaseDownloadBaseUrl, cliReleaseChannelOf, cliReleaseIndexPageUrl, - isArchiveDistributedVersion, newestCliReleaseVersion, parseChecksums, } from "./cliRelease.ts"; @@ -56,12 +55,6 @@ describe("cliRelease", () => { expect(checksums.size).toBe(2); }); - it("treats only preview builds as archive-distributed", () => { - expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true); - expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false); - expect(isArchiveDistributedVersion("1.2.3")).toBe(false); - }); - it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { expect(cliArchiveTarCommand("linux", {})).toBe("tar"); expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 4bdc4749b619..614b0d48bc03 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap { return checksums; } -/** Whether a version was published from a release train that ships archives. */ -export function isArchiveDistributedVersion(version: string): boolean { - return /-preview\.\d{8}\.\d+$/.test(version); -} - export type CliReleaseChannel = "stable" | "nightly" | "preview"; export const CLI_RELEASE_CHANNELS: ReadonlyArray = [ "stable", diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index e5b621f87aa8..02e86b699a35 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -14,7 +14,6 @@ import { baseSshArgs, getLastNonEmptyOutputLine, parseSshResolveOutput, - resolveRemoteT3CliPackageSpec, runSshCommand, } from "./command.ts"; import { SshCommandError } from "./errors.ts"; @@ -99,41 +98,6 @@ describe("ssh command", () => { }), ); - it.effect("resolves the remote t3 package spec from the desktop release channel", () => - Effect.sync(() => { - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.17", - updateChannel: "latest", - }), - "t3@0.0.17", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.17-nightly.20260415.44", - updateChannel: "nightly", - }), - "t3@0.0.17-nightly.20260415.44", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.0-dev", - updateChannel: "nightly", - isDevelopment: true, - }), - "t3@nightly", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.0-dev", - updateChannel: "latest", - isDevelopment: true, - }), - "t3@nightly", - ); - }), - ); - it.effect("reads the last non-empty ssh output line", () => Effect.sync(() => { assert.equal( diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 7a94670370b2..fc388114351d 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "node:crypto"; -import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; +import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; -const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; @@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi ), ); }); - -export function resolveRemoteT3CliPackageSpec(input: { - readonly appVersion: string; - readonly updateChannel: DesktopUpdateChannel; - readonly isDevelopment?: boolean; -}): string { - const appVersion = input.appVersion.trim(); - if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { - return `t3@${appVersion}`; - } - - if (input.isDevelopment) { - return "t3@nightly"; - } - - return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; -} diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 82061c0d8c84..f26bbc39415e 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( "remote runner process ownership", () => { - it.live.each(["npx", "npm"] as const)( - "keeps the server PID and graceful shutdown through the %s fallback", - (packageManager) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); - const bin = path.join(fixture, "bin"); - const cliPath = path.join(fixture, "installed cli.mjs"); - const callsPath = path.join(fixture, "package-manager-calls.jsonl"); - const packageSpec = "t3@0.0.35"; - yield* fs.makeDirectory(bin); - yield* fs.symlink(process.execPath, path.join(bin, "node")); - yield* fs.writeFileString( - cliPath, - `#!/usr/bin/env node + it.live("keeps the server PID and graceful shutdown through the node-script runner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node import * as net from "node:net"; const server = net.createServer((socket) => { socket.end(); @@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { }) + "\\n"); }); `, - ); - yield* fs.chmod(cliPath, 0o700); - yield* fs.writeFileString( - path.join(bin, packageManager), - `#!/usr/bin/env node -const fs = require("node:fs"); -const childProcess = require("node:child_process"); -const args = process.argv.slice(2); -fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n"); -if (args.includes("--package")) { - process.stdout.write(process.env.T3_TEST_CLI + "\\n"); -} else { - const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" }); - child.once("exit", (code) => { process.exitCode = code ?? 1; }); -} -`, - ); - yield* fs.chmod(path.join(bin, packageManager), 0o700); + ); + yield* fs.chmod(cliPath, 0o700); - const runServer = (port = 0) => - Effect.gen(function* () { - const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { - cwd: fixture, - env: { - PATH: bin, - T3_TEST_CLI: cliPath, - T3_TEST_CALLS: callsPath, - T3_TEST_PORT: String(port), - }, - detached: false, - stdin: Stream.make( - new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })), - ), - }), - ); - const ready = yield* Deferred.make(); - const stdout: string[] = []; - const output = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.runForEach((line) => - Effect.gen(function* () { - stdout.push(line); - if (stdout.length === 1) { - yield* Deferred.succeed(ready, decodeStarted(line)); - } - }), - ), - Effect.forkScoped, - ); - const stderr = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.mkString, - Effect.forkScoped, - ); - const receipt = yield* Effect.raceFirst( - Deferred.await(ready), - Fiber.join(output).pipe( - Effect.flatMap(() => Fiber.join(stderr)), - Effect.flatMap((message) => - Effect.die(new Error(`Runner exited before listening: ${message}`)), - ), + const runServer = (port = 0) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { + cwd: fixture, + env: { + PATH: bin, + T3_TEST_PORT: String(port), + }, + detached: false, + stdin: Stream.make( + new TextEncoder().encode(buildRemoteT3RunnerScript({ nodeScriptPath: cliPath })), ), - ); - // A failed PID assertion must still close the owned fixture server, including an npm child. - yield* Effect.addFinalizer(() => + }), + ); + const ready = yield* Deferred.make(); + const stdout: string[] = []; + const output = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => Effect.gen(function* () { - if (yield* child.isRunning) { - yield* Effect.callback((resume) => { - const connection = NodeNet.connect(receipt.port, "127.0.0.1"); - connection.on("error", () => undefined); - connection.once("close", () => resume(Effect.void)); - return Effect.sync(() => connection.destroy()); - }); - yield* child.exitCode; + stdout.push(line); + if (stdout.length === 1) { + yield* Deferred.succeed(ready, decodeStarted(line)); } - }).pipe(Effect.orDie), - ); - assert.equal(receipt.pid, child.pid); - assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); - yield* child.kill({ killSignal: "SIGTERM" }); - assert.equal(yield* child.exitCode, 0); - yield* Fiber.join(output); - assert.include(stdout, "graceful shutdown"); - return receipt.port; - }).pipe(Effect.scoped); + }), + ), + Effect.forkScoped, + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkScoped, + ); + const receipt = yield* Effect.raceFirst( + Deferred.await(ready), + Fiber.join(output).pipe( + Effect.flatMap(() => Fiber.join(stderr)), + Effect.flatMap((message) => + Effect.die(new Error(`Runner exited before listening: ${message}`)), + ), + ), + ); + // A failed PID assertion must still close the owned fixture server. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* child.isRunning) { + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(receipt.port, "127.0.0.1"); + connection.on("error", () => undefined); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + yield* child.exitCode; + } + }).pipe(Effect.orDie), + ); + assert.equal(receipt.pid, child.pid); + assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); + yield* child.kill({ killSignal: "SIGTERM" }); + assert.equal(yield* child.exitCode, 0); + yield* Fiber.join(output); + assert.include(stdout, "graceful shutdown"); + return receipt.port; + }).pipe(Effect.scoped); - const port = yield* runServer(); - assert.equal(yield* runServer(port), port); - const calls = (yield* fs.readFileString(callsPath)) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const expectedCall = [ - ...(packageManager === "npm" ? ["exec"] : []), - "--yes", - "--package", - packageSpec, - "--", - "sh", - "-c", - "command -v t3", - ]; - assert.deepEqual(calls, [expectedCall, expectedCall]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + const port = yield* runServer(); + assert.equal(yield* runServer(port), port); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }, ); @@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => { ); }, ); - -describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( - "remote runner install diagnostics", - () => { - const decodeArguments = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Array(Schema.String)), - ); - const cases = (["npx", "npm"] as const).flatMap((packageManager) => - ( - [ - "etarget", - "network", - "empty-success", - "success", - "failed-with-path", - "existing-cli", - "node-override", - ] as const - ).map((mode) => ({ packageManager, mode })), - ); - - it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); - const bin = path.join(fixture, "bin"); - const cliPath = path.join(fixture, "installed cli.mjs"); - const callsPath = path.join(fixture, "installer-calls.jsonl"); - const packageSpec = "t3@0.0.39-nightly.20260905.1286"; - const args = ["serve", "a path with spaces"]; - yield* fs.makeDirectory(bin); - yield* fs.symlink(process.execPath, path.join(bin, "node")); - yield* fs.writeFileString( - cliPath, - `#!/usr/bin/env node -process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); -`, - ); - yield* fs.chmod(cliPath, 0o700); - yield* fs.writeFileString(callsPath, ""); - yield* fs.writeFileString( - path.join(bin, packageManager), - `#!/usr/bin/env node -const fs = require("node:fs"); -fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); -const mode = process.env.T3_TEST_MODE; -if (mode === "success" || mode === "failed-with-path") { - process.stdout.write(process.env.T3_TEST_CLI + "\\n"); -} -if (mode === "etarget" || mode === "failed-with-path") { - process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); - process.exitCode = 42; -} else if (mode === "network") { - process.stderr.write("npm error code ENETUNREACH\\n"); - process.exitCode = 43; -} -`, - ); - yield* fs.chmod(path.join(bin, packageManager), 0o700); - if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); - - const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-s", "--", ...args], { - cwd: fixture, - extendEnv: false, - env: { - PATH: bin, - T3_TEST_MODE: mode, - T3_TEST_CLI: cliPath, - T3_TEST_CALLS: callsPath, - }, - stdin: Stream.make( - new TextEncoder().encode( - buildRemoteT3RunnerScript({ - packageSpec, - ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), - }), - ), - ), - }), - ); - const { stdout, stderr, exitCode } = yield* Effect.all( - { - stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), - stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), - exitCode: child.exitCode, - }, - { concurrency: "unbounded" }, - ); - const installFailed = - mode === "etarget" || mode === "network" || mode === "failed-with-path"; - const missingExecutable = mode === "empty-success"; - assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); - if (installFailed || missingExecutable) { - assert.equal(stdout, ""); - } else { - assert.deepEqual(decodeArguments(stdout), args); - } - if (installFailed) { - const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; - assert.include(stderr, `npm error code ${npmError}\n`); - assert.include(stderr, `Remote host could not install ${packageSpec}.`); - assert.notInclude(stderr, "Remote host installed"); - assert.notInclude(stderr, "Install a C toolchain"); - } else if (missingExecutable) { - assert.include(stderr, `Remote host installed ${packageSpec}`); - assert.include(stderr, "npm produced no t3 executable"); - assert.include(stderr, "Install a C toolchain"); - } else { - assert.equal(stderr, ""); - } - const expectedCall = [ - ...(packageManager === "npm" ? ["exec"] : []), - "--yes", - "--package", - packageSpec, - "--", - "sh", - "-c", - "command -v t3", - ]; - const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; - const calls = yield* fs.readFileString(callsPath); - if (usesInstaller) { - assert.deepEqual( - calls - .trim() - .split("\n") - .map((line) => decodeArguments(line)), - [expectedCall], - ); - } else { - assert.equal(calls, ""); - } - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - }, -); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 182a8eb4a0ce..980107d19a82 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -23,6 +23,7 @@ import { buildRemoteStopScript, buildRemoteT3RunnerScript, SshInvalidArchiveVersionError, + SshMissingRunnerError, describeReadinessCause, issueRemotePairingToken, launchOrReuseRemoteServer, @@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { return command._tag === "StandardCommand" ? command.args : []; } -describe("ssh tunnel scripts", () => { - it("builds the remote t3 runner with npx and npm fallbacks", () => { - const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); - - assert.include(script, "T3_NODE_SCRIPT_PATH=''"); - assert.include(script, 'exec t3 "$@"'); - assert.include(script, 'exec "$T3_CLI_PATH" "$@"'); - assert.include(script, "could not install 't3@latest'"); - assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); - assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); - assert.include(script, "npm produced no t3 executable"); - assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); - assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); - assert.include(script, "remote_node_satisfies_engine()"); - assert.include(script, "function satisfiesSemverRange"); - assert.include(script, "satisfiesSemverRange(rawVersion, range)"); - assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); - assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); - assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); - assert.include(script, 'eval "$(fnm env --shell bash)"'); - assert.include(script, "fnm use --silent-if-unchanged"); - assert.include(script, "fnm use default"); - assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); - assert.include(script, 'NVM_DIR="$HOME/.nvm"'); - assert.include(script, "nvm use --silent default"); - assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); - assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); - }); +const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const; +const NODE_SCRIPT = { + nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", +} as const; - it("installs and runs the release archive when an archive version is set", () => { - const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }); +describe("ssh tunnel scripts", () => { + it("installs and runs the release archive without Node, npm, or npx", () => { + const script = buildRemoteT3RunnerScript(ARCHIVE); assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); + assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include( script, "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", @@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => { assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); assert.include(script, "SHA256SUMS"); assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); + assert.notInclude(script, "npx"); + assert.notInclude(script, "npm exec"); + assert.notInclude(script, "t3@latest"); + assert.notInclude(script, 'exec t3 "$@"'); // Concurrent launches serialize on a per-version mkdir lock and recheck // the completion marker after acquiring it. assert.include( @@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => { script.indexOf('"$T3_STAGING/t3" --version'), script.indexOf('> "$T3_STAGING/.install-complete"'), ); - // The archive branch execs before any of the Node discovery runs. + // Node discovery is defined for the dev path but only ever invoked inside + // the node-script branch, which the archive path skips entirely. + assert.equal(script.split("ensure_remote_node_path || true").length - 1, 1); + assert.isBelow( + script.indexOf("ensure_remote_node_path || true"), + script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), + ); assert.isBelow( - script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), - script.indexOf("prepend_path_if_dir()"), + script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), + script.indexOf("T3_ARCHIVE_VERSION="), ); const launch = buildRemoteLaunchScript({ - archiveVersion: "1.2.3-preview.20260911.4", + ...ARCHIVE, releaseBaseUrl: "https://mirror.example/t3/", }); assert.include(launch, "T3_ARCHIVE_MODE=1"); @@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => { assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); - assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0"); + assert.include(buildRemoteLaunchScript(NODE_SCRIPT), "T3_ARCHIVE_MODE=0"); }); it("rejects archive versions that are not a single exact version segment", () => { @@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => { ); } assert.include( - buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), + buildRemoteT3RunnerScript(ARCHIVE), "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", ); }); + it("refuses to build a runner with neither an archive version nor a node script", () => { + for (const input of [undefined, {}, { archiveVersion: " " }, { nodeScriptPath: null }]) { + assert.throws(() => buildRemoteT3RunnerScript(input), SshMissingRunnerError); + } + assert.throws(() => buildRemoteLaunchScript(), SshMissingRunnerError); + }); + it("does not hard-code a remote node engine range", () => { - const script = buildRemoteT3RunnerScript(); + const script = buildRemoteT3RunnerScript(NODE_SCRIPT); assert.include(script, "T3_NODE_ENGINE_RANGE=''"); assert.notInclude(script, TEST_NODE_ENGINE_RANGE); }); - it("shell-quotes package specs in the remote t3 runner", () => { - const script = buildRemoteT3RunnerScript({ - packageSpec: "t3@nightly; touch /tmp/t3-owned", - }); - - assert.include( - script, - "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", - ); - assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); - }); - it("builds the remote t3 runner with a node script override", () => { const script = buildRemoteT3RunnerScript({ - nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", + ...NODE_SCRIPT, + nodeEngineRange: TEST_NODE_ENGINE_RANGE, }); assert.include( @@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => { "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'", ); assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"'); + assert.include(script, "T3_ARCHIVE_VERSION=''"); + assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); + assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); + assert.include(script, "remote_node_satisfies_engine()"); + assert.include(script, "function satisfiesSemverRange"); + assert.include(script, "satisfiesSemverRange(rawVersion, range)"); + assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); + assert.include(script, 'eval "$(fnm env --shell bash)"'); + assert.include(script, "fnm use --silent-if-unchanged"); + assert.include(script, "fnm use default"); + assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); + assert.include(script, 'NVM_DIR="$HOME/.nvm"'); + assert.include(script, "nvm use --silent default"); + assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); + assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); + assert.notInclude(script, "npx"); }); it("uses the remote t3 runner for launch and pairing scripts", () => { @@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => { username: "julius", port: 2222, } as const; + const launch = buildRemoteLaunchScript(ARCHIVE); + const devLaunch = buildRemoteLaunchScript({ + ...NODE_SCRIPT, + nodeEngineRange: TEST_NODE_ENGINE_RANGE, + }); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + launch, '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', ); - assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); - assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()"); - assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then"); + assert.include(launch, "RUNNER_CHANGED=1"); + assert.include(launch, "ensure_remote_node_path()"); + assert.include(launch, "if ! ensure_remote_node_path; then"); + assert.include(devLaunch, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); + assert.include(devLaunch, "does not satisfy required range "); + assert.include(launch, 'kill "$REMOTE_PID" 2>/dev/null || true'); + assert.include(launch, "wait_ready"); + assert.include(launch, '"$RUNNER_FILE" serve --host 127.0.0.1'); + assert.include(launch, '--base-dir "$DEFAULT_SERVER_HOME"'); + assert.notInclude(launch, "server-home"); + assert.include(launch, "Remote T3 server did not become ready"); + assert.include(launch, 'wait_ready "60000"'); + assert.include(launch, 'if [ -s "$LOG_FILE" ]; then'); + assert.include(launch, "It wrote nothing to %s"); + assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), - `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`, + buildRemotePairingScript(target, ARCHIVE), + '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', ); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), - "does not satisfy required range ", + buildRemotePairingScript(target, ARCHIVE), + 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"', ); - assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); - assert.include(buildRemoteLaunchScript(), "wait_ready"); - assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); - assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemoteLaunchScript(), "server-home"); - assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); - assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); - assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then'); - assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s"); - assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); + assert.notInclude(buildRemotePairingScript(target, ARCHIVE), "server-home"); assert.include( - buildRemotePairingScript(target), - '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', + buildRemotePairingScript(target, ARCHIVE), + "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", ); - assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemotePairingScript(target), "server-home"); - assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemoteStopScript(target), 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', @@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); assert.include( - buildRemoteLaunchScript(), + launch, 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', ); - assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()"); - assert.include( - buildRemoteLaunchScript(), - 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port', - ); - assert.include( - buildRemoteLaunchScript(), - "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))", - ); - assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); - assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); - assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"'); - assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); - assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); + assert.include(launch, "resolve_default_runtime_port()"); + assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'); + assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))"); + assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); + assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); + assert.include(launch, 'rm -f "$PID_FILE"'); + assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\""); + assert.include(launch, 'if [ -z "$REMOTE_PORT" ]; then'); assert.isBelow( - buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), - buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), + launch.indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), + launch.indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), ); assert.isBelow( - buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), - buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), + launch.indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), + launch.indexOf('elif [ -n "$REMOTE_PID" ]'), ); }); @@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* launchOrReuseRemoteServer(target); + const result = yield* launchOrReuseRemoteServer(target, undefined, ARCHIVE); assert.equal(result.remotePort, 3774); assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]); }).pipe(Effect.provide(processLayer)); @@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); return Effect.gen(function* () { - const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + const fiber = yield* Effect.forkChild( + launchOrReuseRemoteServer(target, undefined, NODE_SCRIPT), + ); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(75)); @@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); - it.effect("gives cold archive launches a larger budget than npm launches", () => { + it.effect("gives cold archive launches a larger budget than node-script launches", () => { const target = { alias: "devbox", hostname: "devbox.example.com", @@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); return Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - launchOrReuseRemoteServer(target, undefined, { - archiveVersion: "1.2.3-preview.20260911.4", - }), - ); + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target, undefined, ARCHIVE)); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(800)); @@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), ); const target = { alias: "devbox", @@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), ); yield* Effect.gen(function* () { const manager = yield* SshEnvironmentManager; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index d3df7d39307f..135cb7decae9 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { - readonly packageSpec?: string; + /** + * Dev mode: run `node ` on the remote instead of a release archive. + * The only mode that needs Node on the remote. + */ readonly nodeScriptPath?: string | null; readonly nodeEngineRange?: string | null; /** - * Exact version whose release archive the remote installs and runs. Takes - * precedence over `packageSpec`; the remote then needs neither Node nor npm. + * Exact version whose self-contained release archive the remote installs + * and runs. Required unless `nodeScriptPath` is set; the remote then needs + * neither Node nor npm. */ readonly archiveVersion?: string | null; readonly releaseBaseUrl?: string | null; } export interface SshEnvironmentManagerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { }; } +function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean { + return Boolean(runner?.nodeScriptPath?.trim()); +} + function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { - if (runner?.archiveVersion?.trim()) { - return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; - } if (runner?.nodeScriptPath?.trim()) { return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; } - if (runner?.packageSpec?.trim()) { - return { runner: "package", packageSpec: runner.packageSpec.trim() }; + if (runner?.archiveVersion?.trim()) { + return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; } - return { runner: "default" }; + return { runner: "archive" }; } interface SshAuthOperationInput { @@ -424,144 +428,117 @@ ensure_remote_node_path() { const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu -T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ -if [ -n "$T3_ARCHIVE_VERSION" ]; then - # Self-contained release archive: no Node, npm, or compiler on the remote. - # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. - T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ - T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" - t3_runtime_ready() { - [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] - } - if ! t3_runtime_ready; then - mkdir -p "$HOME/.t3/runtime/versions" - # Concurrent launches (two clients, a retry racing a slow first run) must - # not both install: mkdir is the atomic lock and the ready check repeats - # under it. - T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" - # mkdir is the only portable atomic exclusive create (mv would silently - # nest a candidate inside an existing lock). The owner publishes its pid - # right after, so a lock with a live owner is never reclaimed however - # slow its download is, and a lock whose owner is dead is reclaimed at - # once. A lock with no pid at all is a crash between mkdir and the pid - # write; it is reclaimed after a short grace so a live owner has time to - # publish. - T3_LOCK_WAITED=0 - T3_LOCK_UNOWNED=0 - while ! mkdir "$T3_LOCK" 2>/dev/null; do - T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" - if [ -n "$T3_LOCK_OWNER" ]; then - T3_LOCK_UNOWNED=0 - if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then - rm -rf "$T3_LOCK" - continue - fi - else - T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) - if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then - rm -rf "$T3_LOCK" - continue - fi - fi - if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then - printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 - exit 1 - fi - sleep 1 - T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) - done - printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" - trap 'rm -rf "$T3_LOCK"' EXIT - fi - if ! t3_runtime_ready; then - case "$(uname -s)" in - Darwin) T3_PLATFORM="darwin" ;; - Linux) T3_PLATFORM="linux" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; - esac - case "$(uname -m)" in - arm64 | aarch64) T3_ARCH="arm64" ;; - x86_64 | amd64) T3_ARCH="x64" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; - esac - T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" - T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" - trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT - t3_fetch() { - if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" - elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" - else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 - fi - } - t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ - t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ - T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" - if command -v sha256sum >/dev/null 2>&1; then - T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" - else - T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" - fi - if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then - printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 - fi - tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 - rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" - # Prove the binary runs here (libc, arch) before marking it ready, or every - # later launch would exec a broken install instead of retrying. - if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then - printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 - fi - printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" - rm -rf "$T3_RUNTIME_DIR" - mv "$T3_STAGING" "$T3_RUNTIME_DIR" - fi - if [ -n "\${T3_LOCK:-}" ]; then - rm -rf "$T3_LOCK" - trap - EXIT - fi - exec "$T3_RUNTIME_DIR/t3" "$@" -fi @@T3_NODE_ENV_SCRIPT@@ -ensure_remote_node_path || true T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ if [ -n "$T3_NODE_SCRIPT_PATH" ]; then + # Dev mode: a source checkout on the remote. This is the only path that + # needs Node, so Node discovery runs here and nowhere else. + ensure_remote_node_path || true if ! command -v node >/dev/null 2>&1; then printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 exit 1 fi exec node "$T3_NODE_SCRIPT_PATH" "$@" fi -if command -v t3 >/dev/null 2>&1; then - exec t3 "$@" +T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ +if [ -z "$T3_ARCHIVE_VERSION" ]; then + printf 'No t3 release version was provided for the remote runtime.\\n' >&2 + exit 1 +fi +# Self-contained release archive: no Node, npm, or compiler on the remote. +# Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. +T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ +T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" +t3_runtime_ready() { + [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] +} +if ! t3_runtime_ready; then + mkdir -p "$HOME/.t3/runtime/versions" + # Concurrent launches (two clients, a retry racing a slow first run) must + # not both install: mkdir is the atomic lock and the ready check repeats + # under it. + T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" + # mkdir is the only portable atomic exclusive create (mv would silently + # nest a candidate inside an existing lock). The owner publishes its pid + # right after, so a lock with a live owner is never reclaimed however + # slow its download is, and a lock whose owner is dead is reclaimed at + # once. A lock with no pid at all is a crash between mkdir and the pid + # write; it is reclaimed after a short grace so a live owner has time to + # publish. + T3_LOCK_WAITED=0 + T3_LOCK_UNOWNED=0 + while ! mkdir "$T3_LOCK" 2>/dev/null; do + T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" + if [ -n "$T3_LOCK_OWNER" ]; then + T3_LOCK_UNOWNED=0 + if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then + rm -rf "$T3_LOCK" + continue + fi + else + T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) + if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then + rm -rf "$T3_LOCK" + continue + fi + fi + if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then + printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 + exit 1 + fi + sleep 1 + T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) + done + printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" + trap 'rm -rf "$T3_LOCK"' EXIT fi -# npm extracts a package before it runs the native builds of its dependencies, -# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves -# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without -# running anything at all, which the caller only ever sees as a server that -# never becomes ready. Resolve the CLI once up front so that install failure is -# reported here, with npm's own output on stderr. -require_installed_t3_cli() { - if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then - printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 - return 1 +if ! t3_runtime_ready; then + case "$(uname -s)" in + Darwin) T3_PLATFORM="darwin" ;; + Linux) T3_PLATFORM="linux" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) T3_ARCH="arm64" ;; + x86_64 | amd64) T3_ARCH="x64" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; + esac + T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" + T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" + trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT + t3_fetch() { + if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" + else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + } + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ + T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" + if command -v sha256sum >/dev/null 2>&1; then + T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + else + T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" fi - if [ -n "$T3_CLI_PATH" ]; then - return 0 + if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then + printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 fi - printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 - return 1 -} -# The launcher records this PID, so exec the CLI without an npm wrapper process. -if command -v npx >/dev/null 2>&1; then - require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec "$T3_CLI_PATH" "$@" + tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 + rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" + # Prove the binary runs here (libc, arch) before marking it ready, or every + # later launch would exec a broken install instead of retrying. + if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then + printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 + fi + printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" + rm -rf "$T3_RUNTIME_DIR" + mv "$T3_STAGING" "$T3_RUNTIME_DIR" fi -if command -v npm >/dev/null 2>&1; then - require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec "$T3_CLI_PATH" "$@" +if [ -n "\${T3_LOCK:-}" ]; then + rm -rf "$T3_LOCK" + trap - EXIT fi -printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 -exit 1 +exec "$T3_RUNTIME_DIR/t3" "$@" `; const REMOTE_LAUNCH_SCRIPT = `set -eu @@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError()( + "SshMissingRunnerError", + {}, +) { + override get message(): string { + return "A remote t3 runner needs an archive version or a node script path."; + } +} + export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { - const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; const archiveVersion = input?.archiveVersion?.trim() || ""; + if (nodeScriptPath === "" && archiveVersion === "") { + throw new SshMissingRunnerError(); + } if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { throw new SshInvalidArchiveVersionError({ archiveVersion }); } @@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string ); return stripTrailingNewlines( applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { - T3_PACKAGE_SPEC: packageSpec, T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), @@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { - T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", + T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1", T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), @@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), - timeoutMs: runner?.archiveVersion?.trim() - ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS - : REMOTE_LAUNCH_TIMEOUT_MS, + timeoutMs: isNodeScriptRunner(runner) + ? REMOTE_LAUNCH_TIMEOUT_MS + : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT stdin: buildRemotePairingScript(target, runner), // Pairing may be the first command on a cold remote, so it can install // the archive on the way. - ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}), + ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshTargetLogFields(resolvedTarget), key, }); - const packageSpec = options.resolveCliPackageSpec?.(); const runner = - options.resolveCliRunner === undefined - ? packageSpec === undefined - ? undefined - : { packageSpec } - : yield* options.resolveCliRunner; + options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner; yield* Effect.logDebug("ssh.environment.runner.resolved", { ...sshTargetLogFields(resolvedTarget), ...sshRunnerLogFields(runner), diff --git a/apps/marketing/public/install.ps1 b/scripts/install.ps1 similarity index 97% rename from apps/marketing/public/install.ps1 rename to scripts/install.ps1 index 4aa46d327b87..a779854717ad 100644 --- a/apps/marketing/public/install.ps1 +++ b/scripts/install.ps1 @@ -79,7 +79,7 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 404) { - Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead" + Fail "t3 $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'" } throw } diff --git a/apps/marketing/public/install.sh b/scripts/install.sh similarity index 96% rename from apps/marketing/public/install.sh rename to scripts/install.sh index 19263563402a..22e4b11867a1 100755 --- a/apps/marketing/public/install.sh +++ b/scripts/install.sh @@ -110,7 +110,7 @@ else fetch_status=0 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? if [ "$fetch_status" -eq 44 ]; then - fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead" + fail "t3 ${version} has no release archive for ${platform}-${arch}; releases before the self-contained CLI can only be installed with \`npm install -g t3@${version}\`" elif [ "$fetch_status" -ne 0 ]; then fail "could not download the release checksums" fi From 07549200dcdc70dce57285276879004024beeaa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:22 -0700 Subject: [PATCH 15/27] feat(desktop): run the WSL backend from the Linux CLI archive (#11511) Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 143 +++-- .../DesktopBackendConfiguration.test.ts | 101 ++-- .../backend/DesktopBackendConfiguration.ts | 125 ++-- .../src/wsl/DesktopWslEnvironment.test.ts | 87 ++- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 196 ++++--- docs/operations/development.md | 5 +- scripts/build-desktop-artifact.test.ts | 540 +++++++----------- scripts/build-desktop-artifact.ts | 372 ++++-------- 8 files changed, 725 insertions(+), 844 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e7305931421..05ee8b2bd452 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -354,21 +354,28 @@ jobs: # job — the Windows artifact then ships a ready WSL backend binary with no # cross-compiling and no first-launch compiler/node-gyp/network on the user's # machine. node-pty is N-API, so one binary works across all WSL Node versions. - build_wsl_node_pty: - name: Build WSL node-pty (linux-x64) + # The Linux CLI archive is built ahead of the desktop matrix because two + # consumers need it: the Linux desktop entry attaches it to the release, and + # the Windows desktop entry embeds it as the WSL runtime. Building it once + # here means the WSL backend runs the exact bytes a Linux user downloads. + build_linux_cli: + name: Build CLI archive (linux-x64) # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. - needs: [resolve_commit] - if: | - needs.resolve_commit.result == 'success' && - (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 + needs: [resolve_commit, preflight, relay_public_config] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 20 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.resolve_commit.outputs.ref }} + ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ @@ -382,38 +389,92 @@ jobs: run-install: | args: - --filter=t3... + - --filter=@t3tools/web... + - --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor + key: resource-monitor-x86_64-unknown-linux-gnu-${{ 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: x86_64-unknown-linux-gnu + + - name: Build resource monitor + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target x86_64-unknown-linux-gnu + + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing - - name: Build node-pty linux-x64 prebuild + - name: Load relay client tracing config shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + # The t3 build task depends on @t3tools/web#build, so the web client is + # built as part of this step. + - name: Build CLI package + run: vp run --filter t3 build + + - name: Build CLI single-executable + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Stage resource monitor for the CLI archive run: | set -euo pipefail - # Resolve node-pty from apps/server (where it's a dependency) and build - # its native binary from source for Linux. node-addon-api resolves from - # node-pty's own dependency tree, so node-gyp has everything it needs. - pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" - pty_dir="$(dirname "$pty_pkg")" - ( cd "$pty_dir" && npx --yes node-gyp rebuild ) - mkdir -p wsl-prebuild - cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node - file wsl-prebuild/pty.node - - - name: Upload node-pty linux-x64 prebuild + target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64" + mkdir -p "$target_dir" + cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/" + + - name: Build CLI archive + run: | + node scripts/build-cli-archive.ts \ + --platform linux \ + --arch x64 \ + --version "${{ needs.preflight.outputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" + + - name: Upload CLI archive uses: actions/upload-artifact@v7 with: - name: wsl-node-pty-x64 - path: wsl-prebuild/pty.node + name: cli-linux-x64 + path: release-cli/* if-no-files-found: error build: name: Build ${{ matrix.label }} - # build_wsl_node_pty stays in `needs` so it runs first and its artifact is - # available to download, but only the Windows matrix entry consumes it. We - # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring - # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux - # builds. `!cancelled()` (not `!failure()`) lets the job run even when - # build_wsl_node_pty failed; the Windows-only download step below then fails - # that single platform if the prebuild is missing. - needs: [preflight, relay_public_config, build_wsl_node_pty] + # build_linux_cli stays in `needs` so it runs first and its artifact is + # available to download, but only the Windows matrix entry consumes it (as + # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring + # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds. + # `!cancelled()` (not `!failure()`) lets the job run even when + # build_linux_cli failed; the Windows-only download step below then fails + # that single platform if the archive is missing. + needs: [preflight, relay_public_config, build_linux_cli] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 @@ -448,6 +509,7 @@ jobs: rust_target: x86_64-apple-darwin resource_key: darwin-x64 cli_archive: false + # The Linux CLI archive is produced by build_linux_cli, not here. - label: Linux x64 runner: blacksmith-32vcpu-ubuntu-2404 platform: linux @@ -455,7 +517,7 @@ jobs: arch: x64 rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 - cli_archive: true + cli_archive: false - label: Windows x64 runner: blacksmith-32vcpu-windows-2025 platform: win @@ -553,12 +615,14 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Download WSL node-pty prebuild + # The WSL backend runs the Linux CLI archive inside the distro, so the + # Windows desktop embeds the same archive the release attaches. + - name: Download Linux CLI archive for WSL if: matrix.platform == 'win' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: - name: wsl-node-pty-x64 - path: wsl-prebuild + name: cli-linux-x64 + path: wsl-runtime - name: Install Spectre-mitigated MSVC libs if: matrix.platform == 'win' @@ -720,10 +784,9 @@ jobs: echo "macOS signing disabled (missing one or more Apple signing secrets)." fi elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Bundle the Linux node-pty binary built by the build_wsl_node_pty job - # so the packaged WSL backend ships a ready binary (no first-launch - # compile). Required for a working WSL backend on Windows. - args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") + # Embed the Linux CLI archive built by build_linux_cli as the WSL + # runtime. Required for a working WSL backend on Windows. + args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) if has_all \ "$AZURE_TENANT_ID" \ "$AZURE_CLIENT_ID" \ diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 747663b80ac0..642a1bc82f42 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => { runtimeId: string; sha256: string; }> = []; - const observedNodePtyRoots: string[] = []; + const observedProbeRoots: string[] = []; let legacyCleanupCount = 0; const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; + const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin"; return withPackagedWslHarness( { @@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => { }); return { ok: true, linuxAppRoot }; }, - ensureNodePty: (_distro, root) => { - observedNodePtyRoots.push(root); - return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + probeRuntime: (_distro, root) => { + observedProbeRoots.push(root); + return { ok: true, resolvedPath }; + }, + // The staged runtime carries its own Node, so the preflight must not + // go looking for one in the distro. + ensureNodePty: () => { + throw new Error("the staged runtime must not probe for Node"); }, }), }, @@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => { sha256: archiveHash, }, ]); - assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); + assert.deepEqual(observedProbeRoots, [linuxAppRoot]); assert.equal( config.entryPath, path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), ); - assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); + assert.deepEqual(config.args, [ + "-d", + "Ubuntu", + "--exec", + "env", + `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, + `${linuxAppRoot}/t3`, + "--bootstrap-fd", + "0", + ]); + assert.notInclude(config.args, "/usr/bin/node"); assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); assert.equal(legacyCleanupCount, 1); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); + assert.include(first.args, `/runtime/sha256-${firstHash}/t3`); assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); + assert.include(second.args, `/runtime/sha256-${secondHash}/t3`); assert.isUndefined(invalidIdentity.wslRuntimeId); + assert.include(invalidIdentity.args, "/usr/bin/node"); assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); }), ); @@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); assert.equal(config.entryPath, mountedEntryPath); + assert.include(config.args, "/usr/bin/node"); assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); assert.isUndefined(config.wslRuntimeId); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => { ); }); - it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { + it.effect("resolveWsl retires a staged runtime whose executable does not start", () => { const archiveHash = "c".repeat(64); const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; + const observedProbeRoots: string[] = []; const observedNodePtyRoots: string[] = []; const invalidatedRuntimeIds: string[] = []; return withPackagedWslHarness( @@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), + probeRuntime: (_distro, root) => { + observedProbeRoots.push(root); + return { ok: false, reason: `${root}/t3 --version failed (exit 127)` }; + }, ensureNodePty: (_distro, root) => { observedNodePtyRoots.push(root); - return root === stagedAppRoot - ? { ok: false, reason: "pty.node could not be loaded", fatal: true } - : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; }, }), }, @@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); + assert.deepEqual(observedProbeRoots, [stagedAppRoot]); + assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); + assert.include(config.args, "/usr/bin/node"); assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.notInclude(config.args, `${stagedAppRoot}/t3`); assert.equal(config.entryPath, mountedEntryPath); assert.isUndefined(config.wslRuntimeId); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), - ensureNodePty: (_distro, root) => ({ + probeRuntime: () => ({ ok: false, - reason: - root === stagedAppRoot - ? "unsupported CPU architecture or incompatible system libraries" - : "mounted tree is broken in some other way", + reason: "unsupported CPU architecture or incompatible system libraries", + }), + ensureNodePty: () => ({ + ok: false, + reason: "mounted tree is broken in some other way", fatal: true, }), }), @@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), - ensureNodePty: (_distro, root) => - root === stagedAppRoot - ? { ok: false, reason: "pty.node could not be loaded", fatal: true } - : { - ok: false, - reason: "WSL backend preflight timed out while probing for Node.js.", - fatal: false, - }, - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "timed out"); - assert.deepEqual(invalidatedRuntimeIds, []); - }), - ); - }); - - it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { - const invalidatedRuntimeIds: string[] = []; - return withPackagedWslHarness( - { - archiveHash: "e".repeat(64), - forbidFallback: "A transient probe failure must not extract the fallback", - forbidCleanup: "A transient probe failure must not clean the fallback tree", - wsl: () => ({ - prepareRuntime: () => ({ - ok: true, - linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", - }), - invalidateRuntime: (_distro, runtimeId) => - Effect.sync(() => { - invalidatedRuntimeIds.push(runtimeId); - }), + probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }), ensureNodePty: () => ({ ok: false, reason: "WSL backend preflight timed out while probing for Node.js.", fatal: false, - retryLimit: 12, }), }), }, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d7c524d16815..3486daebf79e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const nodeBinDirOf = (nodePath: string): string => { + const lastSlash = nodePath.lastIndexOf("/"); + return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; +}; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); @@ -210,18 +215,31 @@ interface SharedBootstrapInput { readonly observabilitySettings: BackendObservabilitySettings; } +// What the launch runs inside the distro. The staged runtime is the release's +// self-contained `t3` executable (Node inside); the mounted server tree is a +// script that needs the distro's own Node. +type WslPreflightRuntime = + | { + readonly kind: "executable"; + readonly entryPath: string; + } + | { + readonly kind: "node-script"; + // Absolute path to the node binary the preflight validated after the + // shared remote resolver repaired PATH. The launch must use this exact + // path so it doesn't fall through to a different/old node than the one + // node-pty was probed with. + readonly nodePath: string; + readonly linuxEntryPath: string; + }; + interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; readonly windowsEntryPath: string; - readonly linuxEntryPath: string; - // Absolute path to the node binary the preflight validated after the shared - // remote resolver repaired PATH. The launch must use this exact path so it - // doesn't fall through to a different/old node than the one node-pty was - // built against. - readonly nodePath: string; - // PATH captured from the same login shell after the shared resolver loaded - // version managers. The launch forwards this value directly without a shell. + readonly runtime: WslPreflightRuntime; + // PATH captured from the user's login shell. The launch forwards this value + // directly without a shell so the server can spawn provider CLIs by name. readonly resolvedPath: string; // Identifies the distro-local runtime cache selected from the packaged archive. readonly runtimeId?: string; @@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // fatal verdict the cached reason is the more actionable one to report. // A transient mounted failure is neither — it rules nothing out, so it stays // retryable and the staged verdict waits for an attempt that can answer. - let stagedFailure: - | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } - | undefined; + let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; + const failedStaged = (failure: { readonly reason: string }) => + ({ + _tag: "Failed", + reason: `WSL runtime unavailable: ${failure.reason}`, + fatal: true, + }) as const; if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - const stagedNodePty = yield* wslEnv.ensureNodePty( - runningDistro, - runtime.linuxAppRoot, - nodePtyOptions, - ); - if (stagedNodePty.ok) { + // The staged runtime is self-contained, so the only question is whether + // it runs here; there is no Node to find or node-pty to load. + const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); + if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; return { _tag: "Ready", runningDistro, windowsEntryPath: environment.backendEntryPath, - linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, - nodePath: stagedNodePty.nodePath, - resolvedPath: stagedNodePty.resolvedPath, + runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` }, + resolvedPath: stagedProbe.resolvedPath, runtimeId: input.runtimeArchive.runtimeId, } as const; } - // A transport failure says nothing about the staged tree, so it is - // retried against the same cache rather than spending a second probe on - // the mounted tree and risking a needless reinstall. - if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty); yield* Effect.logWarning( - "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", - { reason: stagedNodePty.reason }, + "The staged WSL runtime did not start; retrying from the mounted server tree.", + { reason: stagedProbe.reason }, ); - stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; + stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; } else { yield* Effect.logWarning( "Could not stage the WSL runtime; launching from the mounted server tree instead.", @@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f const mounted = yield* resolveMountedAppRoot; if (!mounted.ok) { return stagedFailure && mounted.fatal - ? failedNodePty(stagedFailure.nodePty) + ? failedStaged(stagedFailure) : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); } @@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // turn a retryable failure into a fatal one, ending the WSL attempt (and, // in wsl-only mode, persisting Windows) before the slow /mnt path had a // chance to answer and clear the bad cache. - return failedNodePty( - stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, - ); + return stagedFailure && nodePtyResult.fatal + ? failedStaged(stagedFailure) + : failedNodePty(nodePtyResult); } // The mounted tree runs what the cache could not, so the cache is the broken @@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f _tag: "Ready", runningDistro, windowsEntryPath: mounted.windowsEntryPath, - linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, - nodePath: nodePtyResult.nodePath, + runtime: { + kind: "node-script", + nodePath: nodePtyResult.nodePath, + linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, + }, resolvedPath: nodePtyResult.resolvedPath, } as const; }); @@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl runtimeId: `sha256-${archiveHash}`, sha256: archiveHash, }, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. + // Packaged builds run the self-contained Linux runtime and, on fallback, + // whatever Linux node-pty the mounted tree carries, so the WSL backend never + // needs a compiler, node-gyp, or network on first launch. Compiling from + // source is a dev-only convenience: a checkout has no Linux binary, and + // developers have the toolchain. In packaged builds we instead surface a + // clear diagnostic if the binary can't load (unsupported arch/distro), + // rather than silently dropping into a fragile runtime build. allowBuild: !environment.isPackaged, }); @@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // The WSL server spawns commands its providers reference by name — `npm`/`npx` // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on + // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on // the process PATH, so `npm install -g ...` fails with NotFound. Pass the - // user PATH entries captured by the login-shell preflight. Every dynamic - // value is a separate argv entry under `wsl.exe --exec`; no shell command is - // involved, so Windows cannot mangle nested quotes and stdin remains reserved - // for the bootstrap envelope. - const lastSlash = preflight.nodePath.lastIndexOf("/"); - const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; - const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + // user PATH entries captured by the preflight. Every dynamic value is a + // separate argv entry under `wsl.exe --exec`; no shell command is involved, + // so Windows cannot mangle nested quotes and stdin remains reserved for the + // bootstrap envelope. A node-script runtime additionally leads with the + // probed Node's bin dir so the server cannot pick up a different node than + // the one node-pty was probed with. + const runtime = preflight.runtime; + const launchPath = + runtime.kind === "executable" + ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}` + : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + const command = + runtime.kind === "executable" + ? [runtime.entryPath] + : [runtime.nodePath, runtime.linuxEntryPath]; return { ...baseConfig, @@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl "--exec", "env", `PATH=${launchPath}`, - preflight.nodePath, - preflight.linuxEntryPath, + ...command, "--bootstrap-fd", "0", ...devUrlArgs, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index e1188e1a3387..366bdfce9946 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => { return line.slice(field.length + 1).trim(); }; -const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; +// Stands in for the release's self-contained `t3` executable: the install +// script only asks it for `--version`. +const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => @@ -164,21 +166,22 @@ describe("WSL runtime cache", () => { expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); - expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); - expect(script).toContain(' node_pty_payload_present "$runtime_root"'); + expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); expect(script).toContain("if runtime_is_ready; then"); + expect(script).not.toContain("bin.mjs"); + expect(script).not.toContain("node-pty"); expect(script).toContain("trap 'exit 1' HUP INT TERM"); expect(script).toContain('exec 9> "$runtime_lock"'); expect(script).toContain("flock -x 9"); expect(script).not.toContain('rm -rf "$runtime_lock"'); expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); + // The release archive wraps everything in one `t3--linux-x64/` + // directory; stripping it puts the executable at `$runtime_root/t3`. expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", + "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", ); - expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); - expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); + expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); expect(script).not.toContain('rm -rf "$runtime_root"'); @@ -248,46 +251,39 @@ describe("WSL runtime cache", () => { expect(deleted).toBeGreaterThan(kept); }); - it("treats a runtime whose native payload went missing as a cache miss", () => { + it("treats a runtime whose executable no longer runs as a cache miss", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - // A glob, not a mapped `uname -m`: this is a presence check, and the later - // native probe is what judges arch and loadability. - expect(script).toContain( - ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', - ); - // The marker the probe reads must sit beside the binary, or the runtime is - // just as unusable as one missing pty.node outright. - expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); + // The same proof the SSH runner and CLI installers use: executable, and + // `--version` exits 0. That is what decides arch and loadability, so no + // separate native probe is needed. + expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); - // Readiness gates the short-circuit, so a cache missing the payload + // Readiness gates the short-circuit, so a cache whose executable broke // reinstalls from the archive instead of being reused forever. - const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); + const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); const readinessDefined = script.indexOf("runtime_is_ready() {"); const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - expect(payloadCheckDefined).toBeGreaterThan(-1); - expect(payloadCheckDefined).toBeLessThan(readinessDefined); + expect(entryCheckDefined).toBeGreaterThan(-1); + expect(entryCheckDefined).toBeLessThan(readinessDefined); expect(readinessDefined).toBeLessThan(readyShortCircuit); }); - // A truncated or half-written bin.mjs passes every presence check the cache - // had: the file exists, node-pty still loads, and launch then picks a server - // that exits before it becomes ready — forever, because nothing ever - // reinstalls. The digest the install records is what turns that into a miss. - it("re-hashes the server entry against the digest the install recorded", () => { + // A swapped or half-written `t3` can still exist and even still answer + // `--version`, and launch then runs something this install never verified. + // The digest the install records is what turns that into a miss. + it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain( - ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, - ); + expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); expect(script).toContain( ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', ); @@ -310,18 +306,18 @@ describe("WSL runtime cache", () => { expect(promoted).toBeGreaterThan(markerWritten); }); - it("refuses to mark an archive without a native payload as ready", () => { + it("refuses to mark an archive whose executable does not run as ready", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); + expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); // The extracted tree is rejected before the ready marker is written, so a // defective archive falls back to the mounted tree instead of caching. - const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); + const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); expect(payloadValidated).toBeGreaterThan(-1); @@ -351,7 +347,7 @@ describe("WSL runtime cache", () => { it("never deletes a runtime another backend is running from", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // The running backend's argv holds `/apps/server/dist/bin.mjs`, so + // The running backend's argv holds `/t3`, so // the process itself is the lease and exiting releases it. Nothing has to be // registered up front, which is what makes this cover backends already // running from an older version that knows nothing about pruning. @@ -394,7 +390,7 @@ describe("WSL runtime cache", () => { }); // Reading the generated script proves what it says, not what it does. A cache -// whose bin.mjs was truncated satisfied every assertion above and still got +// whose entry was truncated satisfied every assertion above and still got // reused, so these run the real script against a real archive in a throwaway // HOME and check the outcome. describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { @@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ "set -eu", "work=$(mktemp -d)", - 'stage="$work/stage"', - 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', - `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, - `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, - `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, - `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, - `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, + // Mirrors the release archive: one top-level versioned directory that + // holds the executable and its native addons. + 'stage="$work/stage/t3-0.0.0-linux-x64"', + 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', + `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, + 'chmod +x "$stage/t3"', + `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, + `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, `printf 'work:%s\\n' "$work"`, `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, ].join("\n"), @@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed runtimeId, runtimeParent: `${work}/home/.t3/wsl-runtime`, runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, - serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, + serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, installScript, install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), }; @@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); }); - it("reinstalls a cache whose server entry was truncated", () => { + it("reinstalls a cache whose executable was truncated", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); @@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `runtime_root=${sh(fixture.runtimeRoot)}`, `runtime_parent=${sh(fixture.runtimeParent)}`, 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &', "active_pid=$!", "sleep 0.1", fixture.installScript(), @@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'home="$work/home"', 'runtime_parent="$home/.t3/wsl-runtime"', 'mkdir -p "$runtime_parent"', - 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', + 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', "make_ready sha256-current", "make_ready sha256-previous", "make_ready sha256-active", @@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', - 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &', "active_pid=$!", "(", ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 79d2213d6990..ebd4f853da60 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult = readonly retryLimit?: number; }; +// Outcome of asking the staged self-contained runtime to prove itself. Any +// failure sends the launch to the mounted server tree; the caller decides what +// to do with the cache. +export type ProbeWslRuntimeResult = + | { + readonly ok: true; + readonly resolvedPath: string; + } + | { + readonly ok: false; + readonly reason: string; + }; + export class DesktopWslDistroListError extends Schema.TaggedError()( "DesktopWslDistroListError", { reason: Schema.String }, @@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service< readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; + // Proves a staged self-contained runtime can run (`/t3 --version`) + // and captures the user's login-shell PATH for the launch. Needs no Node + // in the distro; the mounted server tree still goes through ensureNodePty. + readonly probeRuntime: ( + distro: string | null, + linuxAppRoot: string, + ) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, linuxAppRoot: string, @@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = { const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], + subject = "Node.js", ): string | null => { switch (failure) { case "timeout": - return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; + return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`; case "spawn": - return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; + return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`; case "process": - return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; + return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`; case null: return null; } @@ -255,7 +276,7 @@ const runWslShell = ( const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; -// Holds the sha256 of the runtime's server entry, written when the install +// Holds the sha256 of the runtime's `t3` executable, written when the install // promotes a verified tree. Presence alone only says an install once finished // here; the digest is what lets a later launch prove the entry still is what // that install wrote. @@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = ( 'runtime_parent="$HOME/.t3/wsl-runtime"', `runtime_root="$runtime_parent/${safeRuntimeId}"`, `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, - // The native payload is the part of the tree the WSL backend actually - // dlopens, and the only part a user can plausibly break by hand. Checking - // node-pty's package.json alone let a runtime whose pty.node had gone - // missing stay cache-ready forever: every launch reused it and then failed - // the native probe, with no reinstall and no fallback. Match on the glob - // rather than a mapped `uname -m` so this stays a presence check; the probe - // is what decides whether the binary is the right arch and loadable. - "node_pty_payload_present() {", - ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', - ' [ -f "$candidate" ] || continue', - ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', - " return 0", - " done", - " return 1", + // The runtime is a self-contained `t3` executable with Node inside, so the + // readiness proof is the same one the SSH runner and the CLI installers + // use: the file is executable and `t3 --version` exits 0. That covers the + // truncated-binary and wrong-arch cases without a separate native probe. + "runtime_entry_runs() {", + ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', "}", - // Hashing the server entry is the only check that can tell a working cache - // from one whose bin.mjs was truncated or half-written: the file is still - // there, the native probe still passes, and launch then picks a server that - // exits before it can become ready, on every restart. Hashing the ~7MB - // entry measures in single-digit milliseconds inside the distro, once per - // launch, against a cold reinstall of a few hundred megabytes. + // Hashing the entry is what tells a working cache from one whose `t3` was + // swapped or half-written after install: the file is still there and may + // even still run, and launch then picks an executable that is not what + // this install verified. Hashing the executable measures in tens of + // milliseconds inside the distro, once per launch, against a cold + // reinstall of a few hundred megabytes. "runtime_server_entry_digest() {", - ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`, "}", "runtime_is_ready() {", ' [ -f "$ready_marker" ] &&', - ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', - ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', - ' node_pty_payload_present "$runtime_root" &&', + ' runtime_entry_runs "$runtime_root" &&', // An empty or unreadable marker is a miss, not a pass: that is what a // runtime installed before the marker carried a digest looks like, and one // reinstall is the cheapest way to make it verifiable from then on. @@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = ( `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', "trap cleanup_runtime_install EXIT", - `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, - 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', - 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', - - // Never write the ready marker over a tree that is missing the native - // payload. Failing here drops out to the mounted-tree fallback, which is + // The release archive has one top-level `t3--linux-/` + // directory; strip it so the executable lands at `$runtime_root/t3`. + `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`, + // Never write the ready marker over a tree whose executable does not run. + // Failing here drops out to the mounted-tree fallback, which is // recoverable; promoting it would mark the defect ready and cache it. - 'if ! node_pty_payload_present "$runtime_tmp"; then', - " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", + 'if ! runtime_entry_runs "$runtime_tmp"; then', + " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2", " exit 1", "fi", // The archive's bytes were verified against archiveSha256 above, so the @@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { }; // Drops the ready marker so the next launch reinstalls the runtime from the -// archive. Readiness is a presence check by design, so a cached tree whose -// native payload is present but unloadable (truncated pty.node, a distro whose -// glibc the binary needs and the tree was copied from another machine) stays -// ready forever and fails the probe on every launch. Only the probe can see -// that, so the probe is what revokes the marker. The tree itself is left in -// place: the install script moves an unready root aside before extracting. +// archive. Readiness is decided inside the install script, so a cached tree +// that passes there but fails the launch-time probe (a distro whose glibc the +// executable needs, a tree copied from another machine) would stay ready +// forever and fail on every launch. Only the probe can see that, so the probe +// is what revokes the marker. The tree itself is left in place: the install +// script moves an unready root aside before extracting. export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ @@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { return runtimeRoot.startsWith("/") ? runtimeRoot : null; }; -const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; +// The mounted server tree carries no Linux pty.node unless the build put one +// there. Distinct from a binary that is present but will not load, which is a +// distro problem rather than a build problem. +const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; const formatNodePtyProbeFailureReason = (exitCode: number): string | null => - exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE - ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild ` or install a build that includes WSL support." + exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE + ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." : null; +// Captures the login-shell PATH as `resolvedPath:` so the launch can forward the +// user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name. +const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`; + const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" -printf 'resolvedPath:%s\\n' "$PATH" +${RESOLVED_PATH_LINE} cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The WSL Node can't read inside app.asar, so confirm what the server needs is // unpacked on the real filesystem before reporting the backend healthy. Exit 3 -// marks this distinct from a node-pty prebuild problem so the caller can report +// marks this distinct from a node-pty binary problem so the caller can report // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // @@ -517,26 +534,27 @@ const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across -// Node versions — require() succeeding IS the real compatibility test. Compare -// only arch and node-pty version (a stale binary from a different node-pty), -// NOT process.versions.modules: that would reject a perfectly loadable prebuilt -// whenever the user's WSL Node ABI differs from the build's, defeating the -// whole point of shipping one prebuilt for all Node versions. -const expected = { - arch: process.arch, - nodePtyVersion: require("node-pty/package.json").version, -}; -const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); -const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); -const binary = path.join(prebuildDir, "pty.node"); -if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); +// Node versions — require() succeeding IS the real compatibility test. Look in +// the same places node-pty's own loader does. +const candidates = [ + path.join(pkgDir, "build", "Release", "pty.node"), + path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"), +]; +if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE}); require("node-pty"); -const actual = JSON.parse(fs.readFileSync(marker, "utf8")); -for (const key of Object.keys(expected)) { - if (actual[key] !== expected[key]) process.exit(2); -} NODE`; +// Readiness proof for a staged self-contained runtime: the executable runs and +// reports its version, and the login shell's PATH is captured for the launch. +// This runs under plain `sh` (no Node resolver preamble, since the runtime +// needs no Node), so the login shell is entered explicitly for the PATH +// capture; a distro without bash falls back to the PATH sh was started with. +const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => + [ + `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, + ].join("\n"); + const TOOLCHAIN_CHECK_SCRIPT = [ "for tool in node make g++ python3; do", ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', @@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => "set -e", `cd ${shellQuote(linuxServerDir)}`, `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, - `arch=$(node -p "process.arch")`, - `modules=$(node -p "process.versions.modules")`, - `node_pty_version=$(node -p "require('node-pty/package.json').version")`, `cd "$pkg_dir"`, "npx --yes node-gyp rebuild", - `prebuild_dir="prebuilds/linux-$arch"`, - `mkdir -p "$prebuild_dir"`, - `cp build/Release/pty.node "$prebuild_dir/pty.node"`, - `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, `node -e 'require("node-pty")'`, ].join("\n"); @@ -660,6 +671,38 @@ export const formatMissingToolsReason = ( return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; }; +const probeWslRuntimeImpl = ( + distro: string | null, + linuxAppRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { + resolveNode: false, + }); + const transportFailureReason = formatWslShellTransportFailureReason( + probe.transportFailure, + "the staged runtime", + ); + if (transportFailureReason !== null) { + return { ok: false, reason: transportFailureReason } as const; + } + if (probe.exitCode !== 0) { + const trimmedTail = probe.stderr.trim().slice(-500); + return { + ok: false, + reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + const resolvedPath = parseResolvedPath(probe.stdout); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + } as const; + } + return { ok: true, resolvedPath } as const; + }); + const ensureNodePtyImpl = ( distro: string | null, linuxRepoRoot: string, @@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub { ) => PrepareWslRuntimeResult; readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect; + // Defaults to success with a plain PATH: a staged runtime that was prepared + // is assumed to run unless the test says otherwise. + readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult; readonly ensureNodePty?: ( distro: string | null, linuxAppRoot: string, @@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, invalidateRuntime: (distro, runtimeId) => stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, + probeRuntime: (distro, linuxAppRoot) => + Effect.succeed( + stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, + ), ensureNodePty: (distro, linuxAppRoot, options) => Effect.succeed( stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { @@ -1259,6 +1309,10 @@ export const layer = Layer.effect( provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( Effect.withSpan("desktop.wsl.invalidateRuntime"), ), + probeRuntime: (distro, linuxAppRoot) => + provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( + Effect.withSpan("desktop.wsl.probeRuntime"), + ), ensureNodePty: (distro, linuxAppRoot, options) => provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"), diff --git a/docs/operations/development.md b/docs/operations/development.md index 505a59e19818..1cbf09c16008 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc rustup target add aarch64-pc-windows-msvc ``` -NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild; -see the [release runbook](./release.md#windows-payload-topology-and-update-validation). +NSIS is downloaded by electron-builder. WSL support additionally needs the Linux CLI archive +passed as `--wsl-runtime`; see the +[release runbook](./release.md#windows-payload-topology-and-update-validation). ### Signing and passkeys diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 610df8013e1c..08d7cf10e1a6 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BundleNotSelfContainedError, BuildCommandFailedError, - buildWslRuntimeArchiveArgs, parseWslRuntimeArchiveMembers, DesktopDmgBackgroundSourceMissingError, createStageWorkspaceConfig, @@ -88,32 +87,48 @@ import { WSL_RUNTIME_ARCHIVE_HASH_NAME, WSL_RUNTIME_ARCHIVE_NAME, WSL_RUNTIME_EXTRA_RESOURCES, - wslRuntimeArchiveTarTarget, + WslRuntimeArchiveMissingError, + wslRuntimeArchiveStem, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -// A minimal stand-in for the staged sidecar roots packed into the WSL archive. -const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* ( - root: string, - serverSource: string, -) { +// A minimal stand-in for the Linux CLI release archive: one top-level +// directory named after the archive stem holding the executable, the web +// client, and the runtime externals with node-pty built from source. +const makeLinuxCliArchiveFixture = Effect.fn("test.makeLinuxCliArchiveFixture")(function* (input: { + readonly root: string; + readonly stem: string; + readonly extraMembers?: ReadonlyArray; + readonly omitMembers?: ReadonlyArray; +}) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true }); - yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource); - yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), { - recursive: true, - }); - yield* fs.writeFileString( - path.join(root, "node_modules/node-pty/package.json"), - '{"name":"node-pty"}\n', - ); - yield* fs.writeFileString( - path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), - "pty", + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const contentRoot = path.join(input.root, "content"); + const members = [ + `${input.stem}/t3`, + `${input.stem}/client/index.html`, + `${input.stem}/node_modules/node-pty/package.json`, + `${input.stem}/node_modules/node-pty/build/Release/pty.node`, + ...(input.extraMembers ?? []), + ].filter((member) => !(input.omitMembers ?? []).includes(member)); + for (const member of members) { + const memberPath = path.join(contentRoot, member); + yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); + yield* fs.writeFileString(memberPath, member); + } + const archivePath = path.join(input.root, `${input.stem}.tar.gz`); + const tar = yield* spawner.spawn( + ChildProcess.make("tar", ["-czf", archivePath, "-C", contentRoot, "."], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }), ); + assert.equal(Number(yield* tar.exitCode), 0); + return archivePath; }); function mockProcess(exitCode: number, stdout = "") { @@ -154,10 +169,12 @@ function iconResizeSpawnerLayer( ); } +const WINDOWS_PAYLOAD_FIXTURE_VERSION = "1.2.3"; + const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { readonly copyUnpackedNatives: boolean; readonly serverEntrySource?: string; - readonly wslRuntime?: "valid" | "forbidden" | "bad-digest"; + readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest"; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); if (input.wslRuntime !== undefined) { - const wslSourceDir = path.join(tempDir, "wsl-source"); - const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64"); - yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true }); - yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true }); - yield* fs.writeFileString( - path.join(wslSourceDir, "apps/server/dist/bin.mjs"), - "console.log('wsl server');\n", - ); - yield* fs.writeFileString( - path.join(wslSourceDir, "node_modules/node-pty/package.json"), - '{"name":"node-pty"}', - ); - yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty"); - yield* fs.writeFileString( - path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"), - '{"arch":"x64"}', - ); - if (input.wslRuntime === "forbidden") { - const windowsPrebuildDir = path.join( - wslSourceDir, - "node_modules/node-pty/prebuilds/win32-x64", - ); - yield* fs.makeDirectory(windowsPrebuildDir, { recursive: true }); - yield* fs.writeFileString(path.join(windowsPrebuildDir, "pty.node"), "windows-pty"); - } - + const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64"); + const sourceArchivePath = + input.wslRuntime === "loose-server-tree" + ? // The old hand-rolled runtime: apps/server/dist + node_modules at the + // archive root, no single stem directory, no `t3` executable. + yield* makeLinuxCliArchiveFixture({ + root: path.join(tempDir, "wsl-runtime"), + stem: "apps", + omitMembers: ["apps/t3", "apps/client/index.html"], + extraMembers: ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], + }) + : yield* makeLinuxCliArchiveFixture({ + root: path.join(tempDir, "wsl-runtime"), + stem, + ...(input.wslRuntime === "missing-pty" + ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] } + : {}), + }); const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const tar = yield* spawner.spawn( - ChildProcess.make( - "tar", - [ - "-czf", - wslRuntimeArchiveTarTarget(path.relative(wslSourceDir, archivePath)), - "apps/server/dist", - "node_modules", - ], - { cwd: wslSourceDir, stdin: "ignore", stdout: "ignore", stderr: "pipe" }, - ), - ); - assert.equal(Number(yield* tar.exitCode), 0); - const archiveDigest = NodeCrypto.createHash("sha256"); - yield* fs - .stream(archivePath) - .pipe(Stream.runForEach((chunk) => Effect.sync(() => archiveDigest.update(chunk)))); - yield* fs.writeFileString( - hashPath, - input.wslRuntime === "bad-digest" - ? `${"0".repeat(64)}\n` - : `${archiveDigest.digest("hex")}\n`, - ); + yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath }); + if (input.wslRuntime === "bad-digest") { + yield* fs.writeFileString(hashPath, `${"0".repeat(64)}\n`); + } } return { @@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { libc: ["glibc"], }, }); - // The Windows app stage only serves the desktop main process; the server - // sidecar stage is the one that needs Linux natives (below). + // Windows stages only win32 natives; WSL runs the separately built Linux + // CLI archive rather than anything installed here. assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { supportedArchitectures: { os: ["win32"], cpu: ["x64"], }, }); - // The server sidecar stage bundles the same-architecture WSL (Linux, - // glibc) backend, so its install must fetch Linux native optional deps - // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar - // packing and runtime extraction without symlinks. - assert.deepStrictEqual( - createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }), - { - supportedArchitectures: { - os: ["win32", "linux"], - cpu: ["x64"], - libc: ["glibc"], - }, - nodeLinker: "hoisted", - }, - ); - assert.deepStrictEqual( - createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }), - { - supportedArchitectures: { - os: ["win32", "linux"], - cpu: ["arm64"], - libc: ["glibc"], - }, - nodeLinker: "hoisted", + assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { + supportedArchitectures: { + os: ["win32"], + cpu: ["arm64"], }, - ); + }); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { supportedArchitectures: { os: ["darwin"], @@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, true, ); - const winWithoutWslPrebuild = yield* createBuildConfig( + const winWithoutWslRuntime = yield* createBuildConfig( "win", "nsis", "1.2.3", @@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(linux, "asarUnpack"); assert.deepStrictEqual(win.asar, { smartUnpack: false }); assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]); - assert.deepStrictEqual(winWithoutWslPrebuild.asar, win.asar); - assert.deepStrictEqual(winWithoutWslPrebuild.asarUnpack, win.asarUnpack); + assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar); + assert.deepStrictEqual(winWithoutWslRuntime.asarUnpack, win.asarUnpack); assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES); assert.deepStrictEqual(linux.extraResources, [ ...DESKTOP_EXTRA_RESOURCES, @@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ...WINDOWS_SERVER_EXTRA_RESOURCES, ...WSL_RUNTIME_EXTRA_RESOURCES, ]); - // No Linux prebuild means the sidecar staging never writes the archive, - // so listing it here would fail the build on a missing source file. - assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [ + // No Linux CLI archive means staging never writes the runtime, so + // listing it here would fail the build on a missing source file. + assert.deepStrictEqual(winWithoutWslRuntime.extraResources, [ { from: "apps/desktop/prod-resources/resource-monitor", to: "resource-monitor", @@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); - assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files); + assert.deepStrictEqual(winWithoutWslRuntime.files, win.files); assert.notProperty(mac.mac as Record, "sign"); for (const config of [linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); @@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ]); }); - it.effect( - "keeps target and WSL native files while excluding the other Windows architecture", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-windows-architecture-test-", - }); - const sourceDir = path.join(tempDir, "server"); - const nativeFiles = [ - "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", - "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", - "node_modules/node-pty/prebuilds/linux-x64/pty.node", - "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", - "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", - ]; - - for (const nativeFile of nativeFiles) { - const nativePath = path.join(sourceDir, nativeFile); - yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); - yield* fs.writeFileString(nativePath, "native"); - } + it.effect("keeps target native files while excluding the other Windows architecture", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-windows-architecture-test-", + }); + const sourceDir = path.join(tempDir, "server"); + const nativeFiles = [ + "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", + "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", + "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", + "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", + ]; + + for (const nativeFile of nativeFiles) { + const nativePath = path.join(sourceDir, nativeFile); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(nativePath, "native"); + } - const asarPath = path.join(tempDir, "server.asar"); - yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); - const unpackedRoot = `${asarPath}.unpacked`; + const asarPath = path.join(tempDir, "server.asar"); + yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); + const unpackedRoot = `${asarPath}.unpacked`; - assert.isTrue( - yield* fs.exists( - path.join( - unpackedRoot, - "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", - ), - ), - ); - assert.isTrue( - yield* fs.exists( - path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), - ), - ); - assert.isFalse( - yield* fs.exists( - path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64"), - ), - ); - assert.isFalse( - yield* fs.exists( - path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), + assert.isTrue( + yield* fs.exists( + path.join( + unpackedRoot, + "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", ), - ); - }), - ), + ), + ); + assert.isFalse( + yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64")), + ); + assert.isFalse( + yield* fs.exists( + path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), + ), + ); + }), + ), ); it.effect("stages a cached resource monitor without invoking Cargo", () => @@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }); const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); @@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ).pipe(Effect.provideService(HostProcessPlatform, "linux")), ); - it.effect("validates the emitted WSL archive and its SHA-256 sidecar", () => + it.effect("accepts an embedded Linux CLI release archive with a matching digest", () => Effect.scoped( Effect.gen(function* () { const fixture = yield* makeWindowsPayloadFixture({ @@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, expectWslRuntime: true, }); @@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ).pipe(Effect.provideService(HostProcessPlatform, "linux")), ); + it.effect("rejects an embedded archive built for a different release version", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + appVersion: "9.9.9", + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); + it.effect("rejects a Windows package missing its expected WSL runtime", () => Effect.scoped( Effect.gen(function* () { @@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, expectWslRuntime: true, }).pipe(Effect.flip); @@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); - it.effect("rejects forbidden native members in the emitted WSL archive", () => + it.effect("rejects a loose server tree that is not a Linux CLI release archive", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "loose-server-tree", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); + + it.effect("rejects an embedded archive without the Linux node-pty binary", () => Effect.scoped( Effect.gen(function* () { const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true, - wslRuntime: "forbidden", + wslRuntime: "missing-pty", }); const error = yield* validateWindowsPackagedPayload({ stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, expectWslRuntime: true, }).pipe(Effect.flip); assert.instanceOf(error, WindowsPackagedPayloadValidationError); assert.equal(error.reason, "wsl-runtime-invalid"); + assert.deepStrictEqual(error.missingFiles, [ + `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/build/Release/pty.node`, + ]); }), ), ); @@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, expectWslRuntime: true, }).pipe(Effect.flip); @@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }); const primaryProbe = commands.find( @@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "arm64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }); assert.isFalse( @@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "arm64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }).pipe(Effect.flip); assert.instanceOf(error, WindowsPrimaryNativeProbeError); @@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }).pipe(Effect.flip); assert.instanceOf(error, WindowsPackagedPayloadValidationError); @@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }).pipe(Effect.flip); assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); assert.equal(nativeError.reason, "unpacked-native-missing"); @@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }).pipe(Effect.flip); assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); @@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, fileLimit: 2, }).pipe(Effect.flip); @@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { stageDistDir: fixture.stageDistDir, appExecutableName: fixture.appExecutableName, targetArch: "x64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, }).pipe(Effect.flip); assert.instanceOf(error, BundleNotSelfContainedError); @@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); }); - it("packages the WSL server and production dependencies as one compressed runtime", () => { + it("ships the Linux CLI release archive as the WSL runtime", () => { assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz"); assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256"); assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, { @@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", to: "wsl-runtime.tar.gz.sha256", }); - // The archive is only usable alongside a Linux pty.node, so both the - // staging and the packaging config hang off this one decision. - assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" })); - assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" })); - assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined })); - assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" })); - - assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [ - "-czf", - "apps/desktop/prod-resources/wsl-runtime.tar.gz", - "--exclude=node_modules/@anthropic-ai/claude-agent-sdk-*", - "--exclude=node_modules/.bin*", - "--exclude=node_modules/.pnpm*", - "--exclude=node_modules/.modules.yaml*", - "--exclude=node_modules/.pnpm-workspace-state-v1.json*", - "--exclude=node_modules/node-pty/prebuilds/darwin-*", - "--exclude=node_modules/node-pty/prebuilds/win32-*", - "--exclude=node_modules/node-pty/build*", - "--exclude=node_modules/node-pty/third_party/conpty*", - "--exclude=node_modules/@ff-labs/fff-bin-win32-*", - "--exclude=node_modules/@yuuang/ffi-rs-win32-*", - "--exclude=node_modules/@msgpackr-extract/msgpackr-extract-win32-*", - "apps/server/dist", - "node_modules", - ]); + // Both the staging and the packaging config hang off this one decision: + // Windows only, and only when CI handed the build a Linux CLI archive. + const runtimeArchivePath = "/tmp/t3-1.2.3-linux-x64.tar.gz"; + assert.isTrue(bundlesWslRuntime({ platform: "win", runtimeArchivePath })); + assert.isFalse(bundlesWslRuntime({ platform: "win", runtimeArchivePath: undefined })); + assert.isFalse(bundlesWslRuntime({ platform: "linux", runtimeArchivePath })); + assert.isFalse(bundlesWslRuntime({ platform: "mac", runtimeArchivePath })); + assert.equal(wslRuntimeArchiveStem("1.2.3", "x64"), "t3-1.2.3-linux-x64"); }); it("parses Windows bsdtar member listings with CRLF line endings", () => { assert.deepStrictEqual( - parseWslRuntimeArchiveMembers( - "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n", - ), - ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], + parseWslRuntimeArchiveMembers("./t3-1.2.3-linux-x64/t3\r\nt3-1.2.3-linux-x64/client/\r\n"), + ["t3-1.2.3-linux-x64/t3", "t3-1.2.3-linux-x64/client"], ); }); - it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => { - assert.equal( - wslRuntimeArchiveTarTarget("..\\app\\apps\\desktop\\prod-resources\\wsl-runtime.tar.gz"), - "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", - ); - assert.equal( - wslRuntimeArchiveTarTarget("../app/apps/desktop/prod-resources/wsl-runtime.tar.gz"), - "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", - ); - }); - - // The staged source tree and the archive live in sibling stage directories, - // so this covers the real call: on Windows the archive path is an absolute - // C:\... path, and handing that to tar is what made Git's GNU tar try to - // reach a host named "C". - it.effect("spawns tar with an archive target relative to the staged source tree", () => { - const commands: Array<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly options: { readonly cwd?: string }; - }> = []; - - return Effect.scoped( + it.effect("stages the Linux CLI archive verbatim with its SHA-256 sidecar", () => + Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" }); - const sourceDir = path.join(stageRoot, "server"); - const stageAppDir = path.join(stageRoot, "app"); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-stage-" }); + const sourceArchivePath = yield* makeLinuxCliArchiveFixture({ + root, + stem: "t3-1.2.3-linux-x64", + }); + const stageAppDir = path.join(root, "app"); const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from); const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from); - yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const childProcess = command as unknown as (typeof commands)[number]; - commands.push(childProcess); - // Stand in for tar: write the archive by resolving the -f target - // against the cwd tar was spawned in, exactly as tar would. - const target = path.resolve(childProcess.options.cwd ?? "", childProcess.args[1] ?? ""); - return Effect.as(fs.writeFileString(target, "wsl-runtime-archive"), mockProcess(0)); - }), - ); - - yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }).pipe( - Effect.provide(spawnerLayer), - ); - const tarCommand = commands.find((command) => command.command === "tar"); - if (tarCommand === undefined) return assert.fail("tar was not spawned"); + yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath }); - const target = tarCommand.args[1] ?? ""; - assert.equal(tarCommand.options.cwd, sourceDir); - assert.notInclude(target, ":"); - assert.isFalse(path.isAbsolute(target)); - // Relative or not, tar has to land the archive where the build expects it. - assert.equal(path.resolve(sourceDir, target), archivePath); - assert.isTrue(yield* fs.exists(archivePath)); - - // The archive digest both gates installation and names the cache. + const [source, staged] = yield* Effect.all([ + fs.readFile(sourceArchivePath), + fs.readFile(archivePath), + ]); + assert.deepStrictEqual(staged, source); + // The digest both gates installation inside the distro and names the + // extracted runtime's cache directory. const hash = yield* fs.readFileString(hashPath); - assert.match(hash.trim(), /^[0-9a-f]{64}$/); + assert.equal(hash, `${NodeCrypto.createHash("sha256").update(source).digest("hex")}\n`); }), - ); - }); + ), + ); - it.effect("ships only Linux runtime members in the WSL archive", () => + it.effect("fails when the Linux CLI archive handed to the build does not exist", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" }); - const sourceDir = path.join(root, "server"); - const archivePath = path.join(root, "wsl-runtime.tar.gz"); - const hashPath = `${archivePath}.sha256`; - yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); - - const members = [ - "node_modules/node-pty/prebuilds/darwin-x64/pty.node", - "node_modules/node-pty/prebuilds/win32-x64/pty.node", - "node_modules/node-pty/build/Release/pty.node", - "node_modules/node-pty/third_party/conpty/win10-x64/conpty.dll", - "node_modules/@ff-labs/fff-bin-win32-x64/fff.dll", - "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so", - "node_modules/@yuuang/ffi-rs-win32-x64-msvc/ffi.dll", - "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so", - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/addon.node", - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", - "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/index.js", - "node_modules/.bin/tool", - "node_modules/.pnpm/lock.yaml", - "node_modules/.modules.yaml", - "node_modules/.pnpm-workspace-state-v1.json", - ] as const; - yield* Effect.forEach( - members, - (member) => - Effect.gen(function* () { - const memberPath = path.join(sourceDir, member); - yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); - yield* fs.writeFileString(memberPath, member); - }), - { discard: true }, - ); - - yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }); - const process = yield* spawner.spawn( - ChildProcess.make("tar", ["-tzf", archivePath], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }), - ); - const listing = yield* process.stdout.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (output, chunk) => output + chunk, - ), - ); - assert.equal(Number(yield* process.exitCode), 0); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-missing-" }); + const error = yield* stageWslRuntimeArchive({ + sourceArchivePath: path.join(root, "t3-1.2.3-linux-x64.tar.gz"), + archivePath: path.join(root, WSL_RUNTIME_ARCHIVE_NAME), + hashPath: path.join(root, WSL_RUNTIME_ARCHIVE_HASH_NAME), + }).pipe(Effect.flip); - assert.include(listing, "apps/server/dist/bin.mjs"); - assert.include(listing, "node_modules/node-pty/prebuilds/linux-x64/pty.node"); - assert.include(listing, "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so"); - assert.include(listing, "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so"); - assert.include( - listing, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", - ); - for (const excluded of [ - "prebuilds/darwin-", - "prebuilds/win32-", - "node-pty/build", - "third_party/conpty", - "fff-bin-win32-", - "ffi-rs-win32-", - "msgpackr-extract-win32-", - "claude-agent-sdk-", - "node_modules/.bin", - "node_modules/.pnpm", - "node_modules/.modules.yaml", - "node_modules/.pnpm-workspace-state-v1.json", - ]) { - assert.notInclude(listing, excluded); - } + assert.instanceOf(error, WslRuntimeArchiveMissingError); }), ), ); @@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { verbose: Option.none(), mockUpdates: Option.none(), mockUpdateServerPort: Option.none(), - wslPrebuild: Option.none(), + wslRuntime: Option.none(), }).pipe( Effect.provide( Layer.mergeAll( @@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { verbose: Option.none(), mockUpdates: Option.none(), mockUpdateServerPort: Option.none(), - wslPrebuild: Option.none(), + wslRuntime: Option.none(), }), ); @@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { verbose: Option.some(false), mockUpdates: Option.some(false), mockUpdateServerPort: Option.none(), - wslPrebuild: Option.none(), + wslRuntime: Option.none(), }).pipe( Effect.provide( ConfigProvider.layer( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 0a9c21193fd3..cb47e40b8b11 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe( ); const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); -const decodeNodePtyManifest = Schema.decodeUnknownEffect( - Schema.fromJsonString(Schema.Struct({ version: Schema.String })), -); const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { @@ -164,7 +161,7 @@ interface BuildCliInput { readonly verbose: Option.Option; readonly mockUpdates: Option.Option; readonly mockUpdateServerPort: Option.Option; - readonly wslPrebuild: Option.Option; + readonly wslRuntime: Option.Option; } function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { @@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [ id: "msvc", description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries", }, - { id: "tar", description: "tar for the bundled WSL runtime" }, + { id: "tar", description: "tar to inspect the bundled WSL runtime archive" }, ] as const; export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError()( @@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError()( - "WslNodePtyPrebuildMissingError", +export class WslRuntimeArchiveMissingError extends Schema.TaggedError()( + "WslRuntimeArchiveMissingError", { - prebuildPath: Schema.String, + archivePath: Schema.String, }, ) { override get message(): string { - return `WSL node-pty prebuild not found at ${this.prebuildPath}.`; + return `WSL runtime archive not found at ${this.archivePath}.`; } } @@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError()( - "WslNodePtyManifestReadError", - { - manifestPath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Could not read node-pty version from ${this.manifestPath}.`; - } -} - export class LinuxIconResizeError extends Schema.TaggedError()( "LinuxIconResizeError", { @@ -933,7 +918,7 @@ interface ResolvedBuildOptions { readonly verbose: boolean; readonly mockUpdates: boolean; readonly mockUpdateServerPort: number | undefined; - readonly wslPrebuild: string | undefined; + readonly wslRuntime: string | undefined; } interface StagePackageJson { @@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { // then extracts a handful of large archives instead of thousands of small // files, which dominates install (and update) time. The Windows primary runs // the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE -// runtime. WSL normally uses the dedicated compressed Linux runtime below; -// DesktopWslServerTree can still materialize this sidecar as a fallback. +// runtime. WSL does not use this sidecar: it runs the Linux CLI archive +// embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME). export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; // dlopen/spawn need real files, so native modules, shared libraries, and // helper executables live in each archive's .unpacked sibling (the standard @@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = { from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`, to: WSL_RUNTIME_ARCHIVE_HASH_NAME, } as const; -export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const; - -// The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar. -// Keep build/install metadata and target-native packages that cannot run in -// WSL out of the compressed archive. -export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [ - "node_modules/@anthropic-ai/claude-agent-sdk-", - "node_modules/.bin", - "node_modules/.pnpm", - "node_modules/.modules.yaml", - "node_modules/.pnpm-workspace-state-v1.json", - "node_modules/node-pty/prebuilds/darwin-", - "node_modules/node-pty/prebuilds/win32-", - "node_modules/node-pty/build", - "node_modules/node-pty/third_party/conpty", - "node_modules/@ff-labs/fff-bin-win32-", - "node_modules/@yuuang/ffi-rs-win32-", - "node_modules/@msgpackr-extract/msgpackr-extract-win32-", -] as const; -// WSL runs the same CPU arch as the Windows host; universal is mac-only. -export const resolveWslPrebuildArch = (arch: typeof BuildArch.Type): "x64" | "arm64" | undefined => - arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : undefined; - -// A packaged WSL runtime is only usable when a Linux pty.node is bundled with -// it, so this one predicate decides both whether the archive is built and -// whether the packaging config ships it. Without it the build would produce an -// archive that can never pass the install script's payload check, and every -// launch would extract a few hundred MB from /mnt/c only to throw it away. + +// The WSL runtime is the Linux CLI release archive (t3--linux- +// .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL +// runs the exact bytes a Linux user downloads. This one predicate decides both +// whether the archive is staged and whether the packaging config ships it: +// listing an extraResource whose source was never written fails electron-builder. export const bundlesWslRuntime = (input: { - readonly arch: typeof BuildArch.Type; - readonly prebuildPath: string | undefined; -}): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined; + readonly platform: typeof BuildPlatform.Type; + readonly runtimeArchivePath: string | undefined; +}): boolean => input.platform === "win" && input.runtimeArchivePath !== undefined; export const WSL_RUNTIME_EXTRA_RESOURCES = [ WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, @@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: { readonly allowBuilds?: Record; readonly patchedDependencies?: Record; readonly overrides?: Record; - // The Windows server sidecar stage runs both the Windows primary and the - // WSL Linux backend from one dependency tree, so it needs win32 + linux - // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical, - // symlink-free) node_modules: the tree gets packed into server.asar and - // later extracted for WSL, and neither step can rely on pnpm's - // symlink/junction layout surviving the trip. - readonly linuxServerBackend?: boolean; }): StageWorkspaceConfig { - const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input; + const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; // Linux AppImages execute a Linux/glibc Node process that loads @@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: { cpu: hostCpu, libc: ["glibc"], } - : linuxServerBackend - ? { - os: Array.from(new Set([hostOs, "linux"])), - cpu: hostCpu, - libc: ["glibc"], - } - : { - os: [hostOs], - cpu: hostCpu, - }; + : { + os: [hostOs], + cpu: hostCpu, + }; return { supportedArchitectures, @@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: { ? { patchedDependencies } : {}), ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), - ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}), }; } @@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({ verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), - // Path to a prebuilt Linux node-pty binary (pty.node) for the target arch, - // produced by the Linux CI job and handed to the Windows packaging job. Placed - // into the staged node-pty so the WSL backend ships a ready binary and never - // compiles on the user's machine. - wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option), + // Path to the Linux CLI release archive (t3--linux-x64.tar.gz) built + // by the build_linux_cli CI job. The Windows build embeds it verbatim as the + // WSL runtime. + wslRuntime: Config.string("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option), }); const MockUpdateServerPortSchema = Schema.NumberFromString.check( @@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( ), )); - const wslPrebuild = - Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild); + const wslRuntime = + Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime); return { platform, @@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( verbose, mockUpdates, mockUpdateServerPort, - wslPrebuild, + wslRuntime, } satisfies ResolvedBuildOptions; }); @@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, - // Windows only, and false when no Linux node-pty prebuild was bundled: the - // sidecar staging skips the archive in that case, and listing a resource - // whose source file was never written fails the electron-builder step. + // Windows only, and false when no Linux CLI archive was handed to the build: + // staging skips the archive in that case, and listing a resource whose + // source file was never written fails the electron-builder step. wslRuntimeBundled = false, arch?: typeof BuildArch.Type, ) { @@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f } }); -// Stage the prebuilt Linux node-pty binary into the packaged app so the WSL -// backend never compiles on the user's machine. node-pty publishes no Linux -// prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the -// Linux CI job builds pty.node and hands it here. We drop it into the staged -// node-pty's prebuilds/linux-/ with a t3code marker the WSL preflight -// checks (arch + node-pty version; the binary is N-API, hence ABI-stable across -// Node versions). A missing prebuild is a warning, not an error, so local and -// non-Windows builds still succeed — they just won't ship a working WSL backend. -const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (input: { - readonly stageAppDir: string; - readonly arch: typeof BuildArch.Type; - readonly prebuildPath: string | undefined; -}) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - if (input.prebuildPath === undefined) { - yield* Effect.logWarning( - "[desktop-artifact] No WSL node-pty prebuild provided (--wsl-prebuild / T3CODE_DESKTOP_WSL_PREBUILD); the packaged WSL backend will not start until a Linux pty.node is bundled.", - ); - return; - } - - const linuxArch = resolveWslPrebuildArch(input.arch); - if (linuxArch === undefined) { - yield* Effect.logWarning( - `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`, - ); - return; - } - - const prebuildExists = yield* fs - .exists(input.prebuildPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!prebuildExists) { - return yield* new WslNodePtyPrebuildMissingError({ - prebuildPath: input.prebuildPath, - }); - } - - // Resolve through the (pnpm) symlink so we write into the stage's own node-pty - // copy, never a shared content-addressable store. - const nodePtyLink = path.join(input.stageAppDir, "node_modules", "node-pty"); - const nodePtyDir = yield* fs.realPath(nodePtyLink).pipe(Effect.orElseSucceed(() => nodePtyLink)); - - const manifestPath = path.join(nodePtyDir, "package.json"); - const pkgRaw = yield* fs.readFileString(manifestPath); - const manifest = yield* decodeNodePtyManifest(pkgRaw).pipe( - Effect.mapError( - (cause) => - new WslNodePtyManifestReadError({ - manifestPath, - cause, - }), - ), - ); - const nodePtyVersion = manifest.version; - - const prebuildDir = path.join(nodePtyDir, "prebuilds", `linux-${linuxArch}`); - yield* fs.makeDirectory(prebuildDir, { recursive: true }); - yield* fs.copyFile(input.prebuildPath, path.join(prebuildDir, "pty.node")); - const markerJson = yield* encodeJsonString({ arch: linuxArch, nodePtyVersion }); - yield* fs.writeFileString(path.join(prebuildDir, "t3code-wsl-node-pty.json"), `${markerJson}\n`); - - yield* Effect.log( - `[desktop-artifact] Staged WSL node-pty prebuild (linux-${linuxArch}, node-pty ${nodePtyVersion}).`, - ); -}); - -// tar reads an `-f` target containing a colon as `host:path` and tries to reach -// it over rsh, so handing it a Windows drive path (C:\...\wsl-runtime.tar.gz) -// makes Git for Windows' GNU tar fail with "Cannot connect to C: resolve -// failed". The staged source tree and the archive both live under the build's -// stage root, so the target is always expressible relative to tar's cwd. -export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string => - relativeArchivePath.replaceAll("\\", "/"); - -// `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget. -export const buildWslRuntimeArchiveArgs = ( - archivePath: string = WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from, -): ReadonlyArray => [ - "-czf", - archivePath, - ...WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.map((prefix) => `--exclude=${prefix}*`), - ...WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS, -]; - -export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray => - listing - .split(/\r?\n/) - .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) - .filter((member) => member.length > 0); - +// Copy the Linux CLI release archive into the stage verbatim and record its +// SHA-256 beside it. The desktop app verifies the digest inside the distro +// before extracting, and the digest also names the extracted runtime's cache +// directory, so it must be computed from the exact bytes that ship. export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { - readonly sourceDir: string; + readonly sourceArchivePath: string; readonly archivePath: string; readonly hashPath: string; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const sourceExists = yield* fs + .exists(input.sourceArchivePath) + .pipe(Effect.orElseSucceed(() => false)); + if (!sourceExists) { + return yield* new WslRuntimeArchiveMissingError({ archivePath: input.sourceArchivePath }); + } yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true }); - const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath)); - yield* runCommand( - ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), { - cwd: input.sourceDir, - }), - { label: "tar WSL runtime", verbose: false }, - ); + yield* fs.copyFile(input.sourceArchivePath, input.archivePath); const hash = NodeCrypto.createHash("sha256"); yield* fs .stream(input.archivePath) @@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi const digest = hash.digest("hex"); yield* fs.writeFileString(input.hashPath, `${digest}\n`); yield* Effect.log( - `[desktop-artifact] Staged compressed WSL runtime at ${input.archivePath} (${digest}).`, + `[desktop-artifact] Staged WSL runtime archive at ${input.archivePath} (${digest}).`, ); }); +// Mirrors cliArchiveStem in scripts/build-cli-archive.ts (which imports from +// this module, so it cannot be imported here). WSL runs the same CPU arch as +// the Windows host. +export const wslRuntimeArchiveStem = (version: string, arch: typeof BuildArch.Type): string => + `t3-${version}-linux-${arch}`; + +export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray => + listing + .split(/\r?\n/) + .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) + .filter((member) => member.length > 0); + // Stage and pack the Windows server sidecar: the bundled server plus a hoisted -// install of only its runtime-external/native dependency closure for win32 and -// WSL Linux. The Windows primary runs from the archive through the asar-aware -// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory. -// Shipping one packed archive instead of thousands of loose files is what -// makes the NSIS install/update fast. +// install of only its runtime-external/native dependency closure for win32. +// The Windows primary runs from the archive through the asar-aware +// ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of +// thousands of loose files is what makes the NSIS install/update fast. export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { readonly sourceDir: string; readonly asarPath: string; @@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( readonly allowBuilds: Record; readonly patchedDependencies: Record; readonly overrides: Record; - readonly wslPrebuildPath: string | undefined; readonly asarPath: string; - readonly wslRuntimeArchivePath: string; - readonly wslRuntimeArchiveHashPath: string; readonly verbose: boolean; }) { const fs = yield* FileSystem.FileSystem; @@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( const sidecarDependencies = { ...input.runtimeExternalDependencies, - // The sidecar serves two processes: the Windows primary loads win32 - // natives, and the WSL backend loads the matching Linux natives (fff via - // ffi-rs) from the extracted copy of this same tree. ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), - ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion), }; const sidecarPatchedDependencies = createStagePatchedDependencies( input.patchedDependencies, @@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( path.join(serverStageDir, "package.json"), `${sidecarPackageJsonString}\n`, ); - const sidecarWorkspaceConfig = createStageWorkspaceConfig({ - platform: "win", - arch: input.arch, - allowBuilds: input.allowBuilds, - patchedDependencies: sidecarPatchedDependencies, - overrides: input.overrides, - linuxServerBackend: true, - }); + const sidecarWorkspaceConfig = { + ...createStageWorkspaceConfig({ + platform: "win", + arch: input.arch, + allowBuilds: input.allowBuilds, + patchedDependencies: sidecarPatchedDependencies, + overrides: input.overrides, + }), + // The tree gets packed into server.asar, which cannot carry pnpm's + // symlink/junction layout, so install a physical, hoisted node_modules. + nodeLinker: "hoisted" as const, + }; const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); yield* fs.writeFileString( path.join(serverStageDir, "pnpm-workspace.yaml"), @@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( { label: "vp install --prod (server sidecar)", verbose: input.verbose }, ); - yield* stageWslNodePtyPrebuild({ - stageAppDir: serverStageDir, - arch: input.arch, - prebuildPath: input.wslPrebuildPath, - }); - // Skip the archive entirely rather than shipping one the install script must - // extract and reject on every launch. The desktop app treats a missing - // archive as "no WSL-local runtime" and goes straight to the mounted tree. - if (bundlesWslRuntime({ arch: input.arch, prebuildPath: input.wslPrebuildPath })) { - yield* stageWslRuntimeArchive({ - sourceDir: serverStageDir, - archivePath: input.wslRuntimeArchivePath, - hashPath: input.wslRuntimeArchiveHashPath, - }); - } - yield* Effect.log("[desktop-artifact] Packing server.asar..."); yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); yield* packWindowsServerAsar({ @@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn( readonly stageDistDir: string; readonly appExecutableName: string; readonly targetArch: typeof BuildArch.Type; + // The version the embedded Linux CLI archive must carry; its top-level + // directory is named t3--linux-. + readonly appVersion: string; readonly expectWslRuntime?: boolean; readonly fileLimit?: number; readonly verbose?: boolean; @@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn( ); } const members = parseWslRuntimeArchiveMembers(listing.stdout); - const forbiddenMember = members.find((member) => - WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)), - ); - if (forbiddenMember !== undefined) { + // A release archive unpacks to one directory named after its stem; the + // desktop app's WSL install script relies on that layout to find `t3`. + const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch); + const topLevel = new Set(members.map((member) => member.split("/")[0])); + if (topLevel.size !== 1 || !topLevel.has(stem)) { return yield* invalidWslRuntime( - new Error(`WSL runtime archive contains forbidden member ${forbiddenMember}`), + new Error( + `WSL runtime archive must contain a single top-level directory ${stem}, found ${[...topLevel].join(", ") || "nothing"}`, + ), ); } - const wslArch = resolveWslPrebuildArch(input.targetArch); const requiredMembers = [ - "apps/server/dist/bin.mjs", - "node_modules/node-pty/package.json", - ...(wslArch === undefined - ? [] - : [ - `node_modules/node-pty/prebuilds/linux-${wslArch}/pty.node`, - `node_modules/node-pty/prebuilds/linux-${wslArch}/t3code-wsl-node-pty.json`, - ]), + `${stem}/t3`, + `${stem}/client`, + `${stem}/node_modules`, + `${stem}/node_modules/node-pty/build/Release/pty.node`, ]; const missingMembers = requiredMembers.filter((member) => !members.includes(member)); if (missingMembers.length > 0) { @@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn( reason: "wsl-runtime-invalid", packagedAppDir, missingFiles: missingMembers, - cause: new Error("WSL runtime archive is incomplete"), + cause: new Error("WSL runtime archive is not a Linux CLI release archive"), }); } + // The CLI archive runs the single-executable, never a loose server bundle. + const bundleEntry = members.find((member) => member.endsWith("/bin.mjs")); + if (bundleEntry !== undefined) { + return yield* invalidWslRuntime( + new Error(`WSL runtime archive contains a server bundle entry ${bundleEntry}`), + ); + } } const fileCount = yield* countPayloadFiles(packagedAppDir); @@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* preflightWindowsDesktopBuild({ arch: options.arch, bundlesWslRuntime: bundlesWslRuntime({ - arch: options.arch, - prebuildPath: options.wslPrebuild, + platform: options.platform, + runtimeArchivePath: options.wslRuntime, }), }); } @@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( provisioningProfilePath: macPasskeySigning.provisioningProfilePath, } : undefined, - bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }), + bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }), options.arch, ), dependencies: stageDependencies, @@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); - // WSL is Windows-only, so only the Windows artifact carries the server - // sidecar (which embeds the Linux node-pty prebuild); other platforms - // ignore the prebuild input. + // Only the Windows artifact carries the server sidecar and the WSL runtime; + // other platforms ignore the --wsl-runtime input. if (options.platform === "win" && windowsServerAsarPath) { yield* stageWindowsServerSidecar({ stageRoot, @@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( allowBuilds: workspaceAllowBuilds, patchedDependencies: workspacePatchedDependencies, overrides: resolvedOverrides, - wslPrebuildPath: options.wslPrebuild, asarPath: windowsServerAsarPath, - wslRuntimeArchivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), - wslRuntimeArchiveHashPath: path.join( - stageAppDir, - WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from, - ), - verbose: options.verbose, }); } + if ( + options.wslRuntime !== undefined && + bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }) + ) { + yield* stageWslRuntimeArchive({ + sourceArchivePath: options.wslRuntime, + archivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), + hashPath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from), + }); + } // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") // as enabled, so copy the host env and scrub empty values instead of relying @@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( stageDistDir, appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, targetArch: options.arch, + appVersion, expectWslRuntime: bundlesWslRuntime({ - arch: options.arch, - prebuildPath: options.wslPrebuild, + platform: options.platform, + runtimeArchivePath: options.wslRuntime, }), verbose: options.verbose, }); @@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), Flag.optional, ), - wslPrebuild: Flag.string("wsl-prebuild").pipe( + wslRuntime: Flag.string("wsl-runtime").pipe( Flag.withDescription( - "Path to a prebuilt Linux node-pty (pty.node) for the target arch, staged for the WSL backend (env: T3CODE_DESKTOP_WSL_PREBUILD).", + "Path to the Linux CLI release archive (t3--linux-x64.tar.gz) to embed as the WSL runtime of a Windows build (env: T3CODE_DESKTOP_WSL_RUNTIME).", ), Flag.optional, ), From 2c54f2ff17b3ae87bdbe4e072e38bd6f3045eb5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:23 -0700 Subject: [PATCH 16/27] ci(release): build CLI archives for five targets, each on its own architecture (#11605) Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 225 +++++++++++++++++++++++-- docs/operations/release.md | 2 +- packages/shared/src/cliRelease.test.ts | 7 +- packages/shared/src/cliRelease.ts | 19 ++- 4 files changed, 225 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05ee8b2bd452..8c920d010e7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,13 +359,25 @@ jobs: # the Windows desktop entry embeds it as the WSL runtime. Building it once # here means the WSL backend runs the exact bytes a Linux user downloads. build_linux_cli: - name: Build CLI archive (linux-x64) + name: Build CLI archive (linux-${{ matrix.arch }}) # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. needs: [resolve_commit, preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 20 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: blacksmith-32vcpu-ubuntu-2404 + rust_target: x86_64-unknown-linux-gnu + # node-pty has no Linux prebuild and compiles from source, so the + # arm64 archive is built on arm64 hardware rather than cross-built. + - arch: arm64 + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-gnu env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} @@ -396,18 +408,18 @@ jobs: id: resource_monitor_cache uses: actions/cache@v6 with: - path: native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor - key: resource-monitor-x86_64-unknown-linux-gnu-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor + key: resource-monitor-${{ matrix.rust_target }}-${{ 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: x86_64-unknown-linux-gnu + targets: ${{ matrix.rust_target }} - name: Build resource monitor if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target x86_64-unknown-linux-gnu + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target ${{ matrix.rust_target }} - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -442,15 +454,15 @@ jobs: - name: Stage resource monitor for the CLI archive run: | set -euo pipefail - target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64" + target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-${{ matrix.arch }}" mkdir -p "$target_dir" - cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/" + cp native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor "$target_dir/" - name: Build CLI archive run: | node scripts/build-cli-archive.ts \ --platform linux \ - --arch x64 \ + --arch ${{ matrix.arch }} \ --version "${{ needs.preflight.outputs.version }}" \ --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ --output-dir release-cli @@ -461,7 +473,183 @@ jobs: - name: Upload CLI archive uses: actions/upload-artifact@v7 with: - name: cli-linux-x64 + name: cli-linux-${{ matrix.arch }} + path: release-cli/* + if-no-files-found: error + + # Windows arm64 has no desktop build yet (the NSIS arm64 row is still off), + # but the CLI archive is built here on arm64 hardware so it is signed and + # smoke-tested on the architecture it targets, like every other archive. + build_windows_arm64_cli: + name: Build CLI archive (win32-arm64) + needs: [resolve_commit, preflight, relay_public_config] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} + runs-on: windows-11-arm + timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + + - name: Install dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/scripts... + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-pc-windows-msvc + + - name: Build resource monitor + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target aarch64-pc-windows-msvc + + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + # The t3 build task depends on @t3tools/web#build, so the web client is + # built as part of this step. + - name: Build CLI package + run: vp run --filter t3 build + + - name: Build CLI single-executable + shell: bash + env: + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Prepare Azure Trusted Signing + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + $ErrorActionPreference = "Stop" + + $requiredSecrets = @( + $env:AZURE_TENANT_ID, + $env:AZURE_CLIENT_ID, + $env:AZURE_CLIENT_SECRET, + $env:AZURE_TRUSTED_SIGNING_ENDPOINT, + $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, + $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, + $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME + ) + if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { + Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." + exit 0 + } + + try { + Install-PackageProvider ` + -Name NuGet ` + -MinimumVersion 2.8.5.201 ` + -Force ` + -Scope CurrentUser ` + -ErrorAction Stop + } catch { + Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" + } + + Install-Module ` + -Name TrustedSigning ` + -MinimumVersion 0.5.0 ` + -Force ` + -AllowClobber ` + -Repository PSGallery ` + -Scope CurrentUser ` + -ErrorAction Stop + + Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force + Get-Command Invoke-TrustedSigning -ErrorAction Stop + + $moduleRoots = @( + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") + ) + $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -Unique + "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + + - name: Stage resource monitor for the CLI archive + shell: bash + run: | + set -euo pipefail + target_dir="$RUNNER_TEMP/cli-resource-monitor/win32-arm64" + mkdir -p "$target_dir" + cp native/resource-monitor/target/aarch64-pc-windows-msvc/release/t3-resource-monitor.exe "$target_dir/" + + - name: Build CLI archive + shell: bash + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + node scripts/build-cli-archive.ts \ + --platform win \ + --arch arm64 \ + --version "${{ needs.preflight.outputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" + + - name: Upload CLI archive + uses: actions/upload-artifact@v7 + with: + name: cli-win-arm64 path: release-cli/* if-no-files-found: error @@ -488,11 +676,9 @@ jobs: matrix: include: # cli_archive: whether the job also builds the self-contained CLI - # archive. The executable is built on the runner's own Node, so only - # native runners qualify. macOS x64 has no native runner: a - # cross-built executable crashed under Rosetta in the smoke test and - # cannot be verified on real x64 hardware in CI, so it is skipped - # until it can be. + # archive for its own platform/arch, on this runner, and smoke-tests + # it here. Every archive is built on hardware of its own + # architecture: Linux and Windows arm64 have their own jobs below. - label: macOS arm64 runner: blacksmith-12vcpu-macos-26 platform: mac @@ -501,6 +687,9 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: true + # No CLI archive: Node single-executables are unsupported on x64 + # macOS (the SEA docs list macOS as arm64 only) and the built binary + # segfaults on start. The x64 desktop app is Electron and unaffected. - label: macOS x64 runner: blacksmith-12vcpu-macos-26 platform: mac @@ -1058,8 +1247,8 @@ jobs: release: name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} + needs: [preflight, build, build_windows_arm64_cli, publish_cli] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.build_windows_arm64_cli.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: diff --git a/docs/operations/release.md b/docs/operations/release.md index 54e3f5e55eae..abe9ae00a8d4 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -34,7 +34,7 @@ This document covers the unified release workflow for stable and nightly desktop - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture (`build_linux_cli` and `build_windows_arm64_cli` have their own runners). There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index c5109ebee3a0..c92421db5ec2 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -23,11 +23,10 @@ describe("cliRelease", () => { expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64"); expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64"); expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64"); - // Built but not published (macOS x64 segfaults under Rosetta when - // cross-injected; the arm64 Linux and Windows runners do not exist yet). + // Node single-executables are unsupported on x64 macOS. expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined(); - expect(cliArchivePlatformKey("linux", "arm64")).toBeUndefined(); - expect(cliArchivePlatformKey("win32", "arm64")).toBeUndefined(); + expect(cliArchivePlatformKey("linux", "arm64")).toBe("linux-arm64"); + expect(cliArchivePlatformKey("win32", "arm64")).toBe("win32-arm64"); expect(cliArchivePlatformKey("freebsd", "x64")).toBeUndefined(); expect(cliArchivePlatformKey("linux", "ia32")).toBeUndefined(); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 614b0d48bc03..99339ba395a7 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -11,12 +11,21 @@ export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; /** - * The archives a release actually attaches. Kept in step with the - * `cli_archive` matrix flags in .github/workflows/release.yml: a key here - * without a build there produces download URLs that 404, and a build there - * without a key here is unreachable from every installer. + * The archives a release attaches. Kept in step with the build_linux_cli + * matrix, build_windows_arm64_cli, and the `cli_archive` rows in + * .github/workflows/release.yml: a key here without a build there produces + * download URLs that 404, and a build there without a key here is + * unreachable from every installer. */ -const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-x64", "win32-x64"] as const; +// No darwin-x64: Node single-executables are unsupported on x64 macOS (the +// SEA docs list macOS as arm64 only) and the binary segfaults on start. +const CLI_ARCHIVE_PLATFORM_KEYS = [ + "darwin-arm64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", +] as const; export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number]; export function cliArchivePlatformKey( From 2f7616ef1743de52d24581f7e5724f8f674d9152 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:23 -0700 Subject: [PATCH 17/27] ci(release): build the JS bundle once and run every platform and architecture in parallel (#11606) Co-authored-by: Claude Fable 5 --- .github/workflows/release-desktop.yml | 528 ++++++++++++++ .github/workflows/release.yml | 995 ++++++-------------------- apps/marketing/src/pages/index.astro | 2 +- docs/operations/release.md | 18 +- 4 files changed, 758 insertions(+), 785 deletions(-) create mode 100644 .github/workflows/release-desktop.yml diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 000000000000..5d790e49a061 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,528 @@ +name: Release desktop build + +# One desktop platform/arch build, called once per target from release.yml so +# each target is its own job with its own `needs`. The JS bundle (server, web +# client, Electron main) comes from the `js-bundle` artifact that build_bundle +# produced; this job only packages it, builds the native helpers, and, where +# `cli_archive` is set, the self-contained CLI archive for its platform. + +on: + workflow_call: + inputs: + label: + required: true + type: string + runner: + required: true + type: string + platform: + required: true + type: string + target: + required: true + type: string + arch: + required: true + type: string + rust_target: + required: true + type: string + resource_key: + required: true + type: string + # Whether the job also builds the self-contained CLI archive for its own + # platform/arch, on this runner, and smoke-tests it here. Every archive + # is built on hardware of its own architecture. + cli_archive: + required: false + default: false + type: boolean + version: + required: true + type: string + ref: + required: true + type: string + release_channel: + required: true + type: string + clerk_publishable_key: + required: true + type: string + clerk_jwt_template: + required: true + type: string + clerk_cli_oauth_client_id: + required: true + type: string + relay_url: + required: true + type: string + +permissions: + contents: read + +jobs: + build: + name: Build ${{ inputs.label }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ inputs.relay_url }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: ${{ inputs.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: inputs.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: inputs.platform == 'win' + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + # pnpm checks the lockfile and policy before reusing this result. A missing + # artifact leaves the cache empty, so installation runs the checks again. + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + + - name: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Cache Linux capture helpers + if: inputs.platform == 'linux' + id: capture_helper_cache + uses: actions/cache@v6 + with: + path: | + native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot + native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot + key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.rust_target }} + + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ inputs.version }}" + + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps + + # The WSL backend runs the Linux CLI archive inside the distro, so the + # Windows desktop embeds the same-arch archive the release attaches. + - name: Download Linux CLI archive for WSL + if: inputs.platform == 'win' + uses: actions/download-artifact@v8 + with: + name: cli-linux-${{ inputs.arch }} + path: wsl-runtime + + - name: Install Spectre-mitigated MSVC libs + if: inputs.platform == 'win' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Runtimes.${{ inputs.arch == 'arm64' && 'ARM64' || 'x86.x64' }}.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + - name: Install Linux desktop build libraries + if: inputs.platform == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + + - name: Prepare Azure Trusted Signing + if: inputs.platform == 'win' + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + $ErrorActionPreference = "Stop" + + $requiredSecrets = @( + $env:AZURE_TENANT_ID, + $env:AZURE_CLIENT_ID, + $env:AZURE_CLIENT_SECRET, + $env:AZURE_TRUSTED_SIGNING_ENDPOINT, + $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, + $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, + $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME + ) + if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { + Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." + exit 0 + } + + try { + Install-PackageProvider ` + -Name NuGet ` + -MinimumVersion 2.8.5.201 ` + -Force ` + -Scope CurrentUser ` + -ErrorAction Stop + } catch { + Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" + } + + Install-Module ` + -Name TrustedSigning ` + -MinimumVersion 0.5.0 ` + -Force ` + -AllowClobber ` + -Repository PSGallery ` + -Scope CurrentUser ` + -ErrorAction Stop + + Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force + Get-Command Invoke-TrustedSigning -ErrorAction Stop + + $moduleRoots = @( + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") + ) + $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -Unique + "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + + - name: Build desktop artifact + shell: bash + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + args=( + --platform "${{ inputs.platform }}" + --target "${{ inputs.target }}" + --arch "${{ inputs.arch }}" + --build-version "${{ inputs.version }}" + --skip-build + --verbose + ) + + has_all() { + for value in "$@"; do + if [[ -z "$value" ]]; then + return 1 + fi + done + return 0 + } + + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then + echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 + exit 1 + fi + + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + + echo "macOS signing enabled." + args+=(--signed) + else + echo "macOS signing disabled (missing one or more Apple signing secrets)." + fi + elif [[ "${{ inputs.platform }}" == "win" ]]; then + # Embed the Linux CLI archive built by the same-arch Linux job as + # the WSL runtime. Required for a working WSL backend on Windows. + args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-${{ inputs.arch }}.tar.gz) + if has_all \ + "$AZURE_TENANT_ID" \ + "$AZURE_CLIENT_ID" \ + "$AZURE_CLIENT_SECRET" \ + "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ + "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ + "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ + "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then + echo "Windows signing enabled (Azure Trusted Signing)." + args+=(--signed) + else + echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." + fi + else + echo "Signing disabled for ${{ inputs.platform }}." + fi + + vp run dist:desktop:artifact "${args[@]}" + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. It always + # injects into the runner's own Node: tsdown's cross-target download path + # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a + # remote host, and a cross-built macOS binary cannot be smoke-tested. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + + - name: Collect release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-publish + + shopt -s nullglob + patterns=( + "release/*.dmg" + "release/*.zip" + "release/*.AppImage" + "release/*.exe" + ) + # Preview builds have no publish config, so electron-builder writes + # no feed manifest for them, but it still emits blockmaps beside the + # installers. Neither belongs on a release no updater may follow. + if [[ "${{ inputs.release_channel }}" != "preview" ]]; then + patterns+=("release/*.blockmap" "release/*.yml") + fi + for pattern in "${patterns[@]}"; do + for file in $pattern; do + cp "$file" release-publish/ + done + done + + if [[ "${{ inputs.platform }}" == "mac" && "${{ inputs.arch }}" != "arm64" ]]; then + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ inputs.arch }}.yml" + done + fi + + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml") and carries no arch, so the x64 and + # arm64 jobs would upload the same name. Suffix each per-arch copy; + # the release job merges them back into one manifest per channel. + # builder-debug.yml is electron-builder's config dump, not a feed. + if [[ "${{ inputs.platform }}" == "win" ]]; then + for manifest in release-publish/*.yml; do + [[ "$manifest" == */builder-debug.yml ]] && continue + mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" + done + fi + + - name: Collect resource monitor + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + source_path="native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" + target_dir="resource-monitor-publish/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "$source_path" "$target_dir/$binary_name" + + - name: Upload build artifacts + uses: actions/upload-artifact@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 + with: + name: resource-monitor-${{ inputs.resource_key }} + path: resource-monitor-publish/${{ inputs.resource_key }}/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c920d010e7e..aff55e16c6d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -348,36 +348,18 @@ jobs: echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" - # node-pty publishes no Linux prebuilt and the WSL backend runs under the - # distro's own (Linux) Node, which can't load the Windows/Electron binary. We - # build the Linux pty.node here, on Linux, and hand it to the Windows packaging - # job — the Windows artifact then ships a ready WSL backend binary with no - # cross-compiling and no first-launch compiler/node-gyp/network on the user's - # machine. node-pty is N-API, so one binary works across all WSL Node versions. - # The Linux CLI archive is built ahead of the desktop matrix because two - # consumers need it: the Linux desktop entry attaches it to the release, and - # the Windows desktop entry embeds it as the WSL runtime. Building it once - # here means the WSL backend runs the exact bytes a Linux user downloads. - build_linux_cli: - name: Build CLI archive (linux-${{ matrix.arch }}) + # The platform-independent JS (server bundle, web client, Electron main) is + # built exactly once here and handed to every platform job as `js-bundle`. + # The relay/Clerk values are baked into the bundle, so they belong to this + # job rather than to the packaging jobs. + build_bundle: + name: Build JS bundle # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. - needs: [resolve_commit, preflight, relay_public_config] + needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - arch: x64 - runner: blacksmith-32vcpu-ubuntu-2404 - rust_target: x86_64-unknown-linux-gnu - # node-pty has no Linux prebuild and compiles from source, so the - # arm64 archive is built on arm64 hardware rather than cross-built. - - arch: arm64 - runner: ubuntu-24.04-arm - rust_target: aarch64-unknown-linux-gnu env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} @@ -398,358 +380,8 @@ jobs: with: node-version-file: package.json cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/web... - - --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor - key: resource-monitor-${{ matrix.rust_target }}-${{ 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: ${{ matrix.rust_target }} - - - name: Build resource monitor - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target ${{ matrix.rust_target }} - - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - # The t3 build task depends on @t3tools/web#build, so the web client is - # built as part of this step. - - name: Build CLI package - run: vp run --filter t3 build - - - name: Build CLI single-executable - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Stage resource monitor for the CLI archive - run: | - set -euo pipefail - target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-${{ matrix.arch }}" - mkdir -p "$target_dir" - cp native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor "$target_dir/" - - - name: Build CLI archive - run: | - node scripts/build-cli-archive.ts \ - --platform linux \ - --arch ${{ matrix.arch }} \ - --version "${{ needs.preflight.outputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" - - - name: Upload CLI archive - uses: actions/upload-artifact@v7 - with: - name: cli-linux-${{ matrix.arch }} - path: release-cli/* - if-no-files-found: error - - # Windows arm64 has no desktop build yet (the NSIS arm64 row is still off), - # but the CLI archive is built here on arm64 hardware so it is signed and - # smoke-tested on the architecture it targets, like every other archive. - build_windows_arm64_cli: - name: Build CLI archive (win32-arm64) - needs: [resolve_commit, preflight, relay_public_config] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: windows-11-arm - timeout-minutes: 30 - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: false run-install: false - - name: Download dependency verification - continue-on-error: true - uses: actions/download-artifact@v8 - with: - name: release-dependency-verification - path: ${{ runner.temp }}/pnpm-metadata - - - name: Install dependencies - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/scripts... - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-pc-windows-msvc - - - name: Build resource monitor - run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target aarch64-pc-windows-msvc - - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - # The t3 build task depends on @t3tools/web#build, so the web client is - # built as part of this step. - - name: Build CLI package - run: vp run --filter t3 build - - - name: Build CLI single-executable - shell: bash - env: - VP_NODE_VERSION: "26.8.2" - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Prepare Azure Trusted Signing - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - - name: Stage resource monitor for the CLI archive - shell: bash - run: | - set -euo pipefail - target_dir="$RUNNER_TEMP/cli-resource-monitor/win32-arm64" - mkdir -p "$target_dir" - cp native/resource-monitor/target/aarch64-pc-windows-msvc/release/t3-resource-monitor.exe "$target_dir/" - - - name: Build CLI archive - shell: bash - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - run: | - node scripts/build-cli-archive.ts \ - --platform win \ - --arch arm64 \ - --version "${{ needs.preflight.outputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" - - - name: Upload CLI archive - uses: actions/upload-artifact@v7 - with: - name: cli-win-arm64 - path: release-cli/* - if-no-files-found: error - - build: - name: Build ${{ matrix.label }} - # build_linux_cli stays in `needs` so it runs first and its artifact is - # available to download, but only the Windows matrix entry consumes it (as - # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring - # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds. - # `!cancelled()` (not `!failure()`) lets the job run even when - # build_linux_cli failed; the Windows-only download step below then fails - # that single platform if the archive is missing. - needs: [preflight, relay_public_config, build_linux_cli] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - strategy: - fail-fast: false - matrix: - include: - # cli_archive: whether the job also builds the self-contained CLI - # archive for its own platform/arch, on this runner, and smoke-tests - # it here. Every archive is built on hardware of its own - # architecture: Linux and Windows arm64 have their own jobs below. - - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: arm64 - rust_target: aarch64-apple-darwin - resource_key: darwin-arm64 - cli_archive: true - # No CLI archive: Node single-executables are unsupported on x64 - # macOS (the SEA docs list macOS as arm64 only) and the built binary - # segfaults on start. The x64 desktop app is Electron and unaffected. - - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: x64 - rust_target: x86_64-apple-darwin - resource_key: darwin-x64 - cli_archive: false - # The Linux CLI archive is produced by build_linux_cli, not here. - - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 - platform: linux - target: AppImage - arch: x64 - rust_target: x86_64-unknown-linux-gnu - resource_key: linux-x64 - cli_archive: false - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - rust_target: x86_64-pc-windows-msvc - resource_key: win32-x64 - cli_archive: true - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: ${{ matrix.platform != 'win' }} - run-install: false - - - name: Resolve Windows package cache path - if: matrix.platform == 'win' - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache Windows packages - if: matrix.platform == 'win' - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -759,33 +391,10 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Install bundle dependencies env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} - key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Cache Linux capture helpers - if: matrix.platform == 'linux' - id: capture_helper_cache - uses: actions/cache@v6 - with: - path: | - native/kde-snap-shot/target/${{ matrix.rust_target }}/release/t3-kde-snap-shot - native/hyprland-snap-shot/target/${{ matrix.rust_target }}/release/t3-hyprland-snap-shot - key: linux-capture-helpers-${{ matrix.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (matrix.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.rust_target }} + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -804,373 +413,190 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - # The WSL backend runs the Linux CLI archive inside the distro, so the - # Windows desktop embeds the same archive the release attaches. - - name: Download Linux CLI archive for WSL - if: matrix.platform == 'win' - uses: actions/download-artifact@v8 - with: - name: cli-linux-x64 - path: wsl-runtime - - - name: Install Spectre-mitigated MSVC libs - if: matrix.platform == 'win' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - uses: ./.github/actions/setup-apt-mirrors - if: matrix.platform == 'linux' - - - name: Install Linux desktop build libraries - if: matrix.platform == 'linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi - - - name: Prepare Azure Trusted Signing - if: matrix.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - - name: Build desktop artifact - shell: bash - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" - - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Embed the Linux CLI archive built by build_linux_cli as the WSL - # runtime. Required for a working WSL backend on Windows. - args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - vp run dist:desktop:artifact "${args[@]}" - - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. It always - # injects into the runner's own Node: tsdown's cross-target download path - # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a - # remote host, and a cross-built macOS binary cannot be smoke-tested. - - name: Build CLI single-executable - if: matrix.cli_archive - shell: bash - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Import macOS signing certificate for the CLI archive - if: matrix.cli_archive && matrix.platform == 'mac' - shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then - echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." - exit 0 - fi - keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" - keychain_password="$(openssl rand -hex 16)" - cert_path="$RUNNER_TEMP/t3-cli-signing.p12" - printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" - security create-keychain -p "$keychain_password" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$keychain_password" "$keychain" - security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" - if [[ -z "$identity" ]]; then - echo "No Developer ID Application identity found in CSC_LINK." >&2 - exit 1 - fi - echo "::add-mask::$keychain_password" - echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "macOS CLI signing enabled." - - - name: Stage resource monitor for the CLI archive - if: matrix.cli_archive - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" - - name: Build CLI archive - if: matrix.cli_archive - shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - run: | - set -euo pipefail - if [[ "${{ matrix.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - fi - node scripts/build-cli-archive.ts \ - --platform "${{ matrix.platform }}" \ - --arch "${{ matrix.arch }}" \ - --version "${{ needs.preflight.outputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - if: matrix.cli_archive - shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" - - - name: Upload CLI archive - if: matrix.cli_archive - uses: actions/upload-artifact@v7 - with: - name: cli-${{ matrix.platform }}-${{ matrix.arch }} - path: release-cli/* - if-no-files-found: error - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - patterns=( - "release/*.dmg" - "release/*.zip" - "release/*.AppImage" - "release/*.exe" - ) - # Preview builds have no publish config, so electron-builder writes - # no feed manifest for them, but it still emits blockmaps beside the - # installers. Neither belongs on a release no updater may follow. - if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then - patterns+=("release/*.blockmap" "release/*.yml") - fi - for pattern in "${patterns[@]}"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - shopt -s nullglob - for manifest in release-publish/*-mac.yml; do - mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" - done - fi + # @t3tools/desktop#build compiles the Linux browser secret helper on a + # Linux host before packing, and that needs libsecret headers. + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # Enable if Windows arm64 builds are enabled. - # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the - # release job can merge matching arm64/x64 manifests back into one - # canonical manifest per channel. - # if [[ "${{ matrix.platform }}" == "win" ]]; then - # shopt -s nullglob - # for manifest in release-publish/*.yml; do - # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" - # done - # fi - - - name: Collect resource monitor - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" - target_dir="resource-monitor-publish/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "$source_path" "$target_dir/$binary_name" + # Runs t3#build (which depends on @t3tools/web#build) and + # @t3tools/desktop#build, so apps/server/dist holds the server bundle + # plus the web client and apps/desktop/dist-electron the Electron main. + - name: Build JS bundle + run: vp run build:desktop - - name: Upload build artifacts + # Two paths under apps/ so the artifact root is apps/; consumers download + # into `apps` to restore both at their original locations. + - name: Upload JS bundle uses: actions/upload-artifact@v7 with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error + retention-days: 1 - - name: Upload resource monitor - uses: actions/upload-artifact@v7 - with: - name: resource-monitor-${{ matrix.resource_key }} - path: resource-monitor-publish/${{ matrix.resource_key }}/* - if-no-files-found: error + # One job per platform and architecture (see release-desktop.yml), each on + # hardware of its own architecture, and each gated only on what it consumes: + # every platform needs the JS bundle, and the Windows jobs also need the + # same-arch Linux job, whose CLI archive they embed as the WSL runtime. Every + # job builds the desktop app; all but macOS x64 also build the CLI archive + # for their platform, so a target either ships fully or not at all. + desktop_mac_arm64: + name: Desktop macOS arm64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: macOS arm64 + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: true + + desktop_mac_x64: + name: Desktop macOS x64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: macOS x64 + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 + # No CLI archive: Node single-executables are unsupported on x64 macOS + # (the SEA docs list macOS as arm64 only) and the built binary segfaults + # on start. The x64 desktop app is Electron and unaffected. + cli_archive: false + + desktop_linux_x64: + name: Desktop Linux x64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Linux x64 + runner: blacksmith-32vcpu-ubuntu-2404 + platform: linux + target: AppImage + arch: x64 + rust_target: x86_64-unknown-linux-gnu + resource_key: linux-x64 + cli_archive: true + + # node-pty has no Linux prebuild and compiles from source, so the arm64 app + # and archive are built on arm64 hardware rather than cross-built. + desktop_linux_arm64: + name: Desktop Linux arm64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Linux arm64 + runner: ubuntu-24.04-arm + platform: linux + target: AppImage + arch: arm64 + rust_target: aarch64-unknown-linux-gnu + resource_key: linux-arm64 + cli_archive: true + + # The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime. + # `!cancelled()` (not `!failure()`) still lets them start when that Linux job + # failed; the download step inside then fails this single platform if the + # archive is missing. + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_x64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Windows x64 + runner: blacksmith-32vcpu-windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: true + + desktop_win_arm64: + name: Desktop Windows arm64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_arm64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Windows arm64 + runner: windows-11-arm + platform: win + target: nsis + arch: arm64 + rust_target: aarch64-pc-windows-msvc + resource_key: win32-arm64 + cli_archive: true # Preview releases never reach npm: the archive on the GitHub Release is the # only way to obtain one, so no dist-tag can ever resolve to a preview build. publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} + needs: [preflight, relay_public_config, quality, build_bundle] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build_bundle.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -1199,30 +625,19 @@ jobs: run-install: | args: - --filter=t3... - - --filter=@t3tools/web... - --filter=@t3tools/scripts... - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - # The t3 build task depends on @t3tools/web#build, so the web client is - # built (once) as part of this step. - - name: Build CLI package - run: vp run --filter t3 build + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps - name: Download resource monitors uses: actions/download-artifact@v8 @@ -1247,8 +662,18 @@ jobs: release: name: Publish GitHub Release - needs: [preflight, build, build_windows_arm64_cli, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.build_windows_arm64_cli.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} + needs: + [ + preflight, + desktop_mac_arm64, + desktop_mac_x64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + publish_cli, + ] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -1319,6 +744,9 @@ jobs: exit 1 fi + # electron-updater reads one manifest per platform and channel and picks + # the file entry whose name carries the running arch, so the per-arch + # manifests the build jobs wrote are merged back into that one file. - name: Merge macOS updater manifests if: needs.preflight.outputs.release_channel != 'preview' run: | @@ -1331,6 +759,21 @@ jobs: fi done + - name: Merge Windows updater manifests + if: needs.preflight.outputs.release_channel != 'preview' + run: | + shopt -s nullglob + for x64_manifest in release-assets/*-win-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" + merged_manifest="${x64_manifest%-win-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" + rm -f "$x64_manifest" "$arm64_manifest" + else + mv "$x64_manifest" "$merged_manifest" + fi + done + # Updater manifests and blockmaps are what electron-updater consumes. # They are only listed for channels an updater is meant to follow. - id: release_files diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cb90f3687184..669bdce8a72d 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -444,7 +444,7 @@ const mobileEndorsementRows = [ return assets.find((a) => a.name.endsWith("-arm64.dmg"))?.browser_download_url ?? null; } if (platform.os === "linux") { - return assets.find((a) => a.name.endsWith(".AppImage"))?.browser_download_url ?? null; + return assets.find((a) => a.name.endsWith("-x86_64.AppImage"))?.browser_download_url ?? null; } return null; } diff --git a/docs/operations/release.md b/docs/operations/release.md index abe9ae00a8d4..1b74c27b903f 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -23,18 +23,19 @@ This document covers the unified release workflow for stable and nightly desktop the commit to ship is not the latest nightly, such as a cherry-picked fix on a release branch. - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. -- Builds four artifacts in parallel for both channels: +- Builds the platform-independent JS (server bundle, web client, Electron main) once in the `build_bundle` job and hands it to every platform job as the `js-bundle` artifact; the platform jobs only package it, so no runner rebuilds it. +- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle (the Windows jobs also wait for the same-arch Linux job, whose CLI archive they embed as the WSL runtime): - macOS `arm64` DMG - macOS `x64` DMG - - Linux `x64` AppImage - - Windows `x64` NSIS installer + - Linux `x64` and `arm64` AppImage + - Windows `x64` and `arm64` NSIS installer - Publishes one GitHub Release with all produced files. - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture (`build_linux_cli` and `build_windows_arm64_cli` have their own runners). There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. @@ -253,9 +254,10 @@ executables declared as unpacked by that archive must be present at the matching paths below `resources/server.asar.unpacked`. The Windows-native backend reads the archive in place through Electron. Packaged Windows builds also ship `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive -(`t3--linux-x64.tar.gz`) built by the `build_linux_cli` job and handed -to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs -the exact bytes a Linux user downloads. WSL verifies and extracts that archive +(`t3--linux-.tar.gz`, the same arch as the Windows host) built +by the Linux desktop job and handed to the Windows desktop build as +`--wsl-runtime`, copied in verbatim so WSL runs the exact bytes a Linux user +downloads. WSL verifies and extracts that archive into `~/.t3/wsl-runtime/sha256-` inside the selected distro, then reuses it for later launches of the same update. @@ -413,7 +415,7 @@ Checklist: 4. Verify workflow steps: - preflight passes - release quality checks pass - - all matrix builds pass + - `build_bundle` and all platform builds pass - `publish_cli` publishes the exact release version before the release job - release job uploads expected files 5. Smoke test downloaded artifacts. From 91cd91c0872bd52be3b45bea70a9539d1ce6adaa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:24 -0700 Subject: [PATCH 18/27] feat(release): publish npx t3 as a launcher over per-platform executable packages (#11607) Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 81 ++-- apps/server/scripts/cli.ts | 227 +++------- apps/server/scripts/cliErrors.ts | 22 - docs/operations/release.md | 42 +- docs/user/install.md | 22 +- packages/shared/src/cliRelease.ts | 2 +- scripts/build-cli-archive.ts | 40 +- scripts/build-npm-platform-packages.test.ts | 203 +++++++++ scripts/build-npm-platform-packages.ts | 443 ++++++++++++++++++++ 9 files changed, 820 insertions(+), 262 deletions(-) create mode 100644 scripts/build-npm-platform-packages.test.ts create mode 100644 scripts/build-npm-platform-packages.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aff55e16c6d2..998e5fd7eacb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,10 +150,11 @@ jobs: # Manual-only test train: exercises the whole release flow for a # commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. - # A preview release is reachable only by downloading it by hand: - # it is never published to npm, its desktop builds carry no update - # feed, and no updater manifest is attached to the release, so - # neither stable nor nightly installs can ever be offered one. + # A preview release is reachable only by asking for it: npm gets it + # under the `preview` dist-tag, which nothing resolves by default, + # its desktop builds carry no update feed, and no updater manifest + # is attached to the release, so neither stable nor nightly + # installs can ever be offered one. nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -164,7 +165,7 @@ jobs: --github-output echo "release_channel=preview" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else @@ -591,22 +592,28 @@ jobs: resource_key: win32-arm64 cli_archive: true - # Preview releases never reach npm: the archive on the GitHub Release is the - # only way to obtain one, so no dist-tag can ever resolve to a preview build. + # npm gets the same bytes as the GitHub Release: the launcher plus one + # package per CLI archive. Preview publishes too, under the `preview` + # dist-tag, which nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build_bundle] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build_bundle.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} + needs: + [ + preflight, + relay_public_config, + quality, + desktop_mac_arm64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + ] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read id-token: write - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -627,38 +634,28 @@ jobs: - --filter=t3... - --filter=@t3tools/scripts... - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - # The artifact root is `apps/` (upload-artifact keeps the least common - # ancestor of its paths), so extracting into `apps` restores - # apps/server/dist and apps/desktop/dist-electron at their build paths. - - name: Download JS bundle + - name: Download all CLI archives uses: actions/download-artifact@v8 with: - name: js-bundle - path: apps + pattern: cli-* + merge-multiple: true + path: release-cli - - name: Download resource monitors - uses: actions/download-artifact@v8 - with: - pattern: resource-monitor-* - path: ${{ runner.temp }}/resource-monitors + - name: Build npm packages from CLI archives + run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages - - name: Bundle resource monitors into CLI package - shell: bash + # A dry run of every package first: an auth or scope error here (the + # @t3code org missing, a package without a trusted publisher) fails + # before anything is live, instead of after some platforms already are. + - name: Check npm publish access (dry run) run: | - set -euo pipefail - for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do - resource_key="${artifact_dir##*/resource-monitor-}" - target_dir="apps/server/dist/resource-monitor/${resource_key}" - mkdir -p "$target_dir" - cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" - chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true - done + if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then + echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + exit 1 + fi - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose + - name: Publish CLI packages + run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose release: name: Publish GitHub Release @@ -673,7 +670,7 @@ jobs: desktop_win_arm64, publish_cli, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 06c22738853e..cc59e47e0a40 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - DEVELOPMENT_ICON_OVERRIDES, - resolveWebAssetBrandForPackageVersion, - resolveWebIconOverrides, -} from "../../../scripts/lib/brand-assets.ts"; +import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; -import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; -import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; -import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import serverPackageJson from "../package.json" with { type: "json" }; import { ServerCliBuildAssetMissingError, ServerCliCommandExitError, ServerCliDevelopmentIconSourceMissingError, ServerCliDevelopmentIconTargetMissingError, ServerCliExecutableImportError, - ServerCliPublishIconSourceMissingError, - ServerCliPublishIconTargetMissingError, } from "./cliErrors.ts"; -interface PackageJson { - name: string; - repository: { - type: string; - url: string; - directory: string; - }; - bin: Record; - type: string; - version: string; - engines: Record; - files: string[]; - dependencies: Record; - overrides: Record; -} - -const PackageJsonPrettyJson = fromJsonStringPretty(Schema.Unknown); -const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson); - -const WorkspaceConfig = Schema.Struct({ - catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), - overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}); -type WorkspaceConfig = typeof WorkspaceConfig.Type; -const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), ); -const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const repoRoot = yield* RepoRoot; - const workspaceYaml = yield* fs.readFileString(path.join(repoRoot, "pnpm-workspace.yaml")); - return yield* decodeWorkspaceConfig(workspaceYaml); -}); - const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(command); @@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan } }); -const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( - repoRoot: string, - serverDir: string, - version: string, -) { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const brand = resolveWebAssetBrandForPackageVersion(version); - const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ - sourcePath: path.join(repoRoot, override.sourceRelativePath), - targetPath: path.join(serverDir, override.targetRelativePath), - })); - - for (const icon of icons) { - if (!(yield* fs.exists(icon.sourcePath))) { - return yield* new ServerCliPublishIconSourceMissingError({ sourcePath: icon.sourcePath }); - } - if (!(yield* fs.exists(icon.targetPath))) { - return yield* new ServerCliPublishIconTargetMissingError({ targetPath: icon.targetPath }); - } - } - - return yield* Effect.forEach(icons, (icon) => - Effect.all({ - original: fs.readFile(icon.targetPath), - publish: fs.readFile(icon.sourcePath), - }).pipe(Effect.map((contents) => ({ ...icon, ...contents }))), - ); -}); - const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( repoRoot: string, serverDir: string, @@ -240,37 +165,21 @@ const buildExeCmd = Command.make( // publish subcommand // --------------------------------------------------------------------------- -interface PublishCommandConfig { - readonly access: string; - readonly tag: string; - readonly provenance: boolean; - readonly dryRun: boolean; -} - -const createVpPmPublishArgs = (config: PublishCommandConfig): ReadonlyArray => { - const args = [ - "publish", - "--filter", - "t3", - "--access", - config.access, - "--tag", - config.tag, - "--no-git-checks", - ]; - - if (config.provenance) args.push("--provenance"); - if (config.dryRun) args.push("--dry-run"); - - return args; -}; - +/** + * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: + * every `@t3code/t3-.tgz` first, `t3.tgz` (the launcher) last, so + * the launcher is never installable before the executables it depends on. + * Tarballs rather than directories because `npm publish ` strips the + * `node_modules/` the executable loads its native addons from. + */ const publishCmd = Command.make( "publish", { + packagesDir: Flag.string("packages-dir").pipe( + Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."), + ), tag: Flag.string("tag").pipe(Flag.withDefault("latest")), access: Flag.string("access").pipe(Flag.withDefault("public")), - appVersion: Flag.string("app-version").pipe(Flag.optional), provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), @@ -279,86 +188,48 @@ const publishCmd = Command.make( Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const repoRoot = yield* RepoRoot; - const serverDir = path.join(repoRoot, "apps/server"); - const packageJsonPath = path.join(serverDir, "package.json"); - - // Assert build assets exist - for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { - const abs = path.join(serverDir, relPath); - if (!(yield* fs.exists(abs))) { - return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); - } + // npm runs with cwd set to the packages dir below, so tarball paths are + // resolved once here rather than joined twice. + const packagesDir = path.resolve(config.packagesDir); + const scopeDir = path.join(packagesDir, "@t3code"); + const launcherTarball = path.join(packagesDir, "t3.tgz"); + const platformTarballs = (yield* fs + .readDirectory(scopeDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) + .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) + .sort() + .map((entry) => path.join(scopeDir, entry)); + if (platformTarballs.length === 0) { + return yield* new ServerCliBuildAssetMissingError({ + assetPath: path.join(scopeDir, "t3-.tgz"), + }); + } + if (!(yield* fs.exists(launcherTarball))) { + return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball }); } - yield* Effect.acquireUseRelease( - // Acquire: resolve publish metadata and read every original before mutation. - Effect.gen(function* () { - const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); - const workspaceConfig = yield* readWorkspaceConfig(); - const workspaceCatalog = workspaceConfig.catalog ?? {}; - const workspaceOverrides = workspaceConfig.overrides ?? {}; - const pkg: PackageJson = { - name: serverPackageJson.name, - repository: serverPackageJson.repository, - bin: serverPackageJson.bin, - type: serverPackageJson.type, - version, - engines: serverPackageJson.engines, - files: serverPackageJson.files, - dependencies: resolveCatalogDependencies( - serverPackageJson.dependencies, - workspaceCatalog, - "apps/server", - ), - overrides: resolveCatalogDependencies( - workspaceOverrides, - workspaceCatalog, - "apps/server", - ), - }; - - return { - packageJsonString: yield* encodePackageJson(pkg), - originalPackageJson: yield* fs.readFile(packageJsonPath), - icons: yield* preparePublishIcons(repoRoot, serverDir, version), - }; - }), - // Use: pnpm publish from the workspace root so pnpm-only workspace - // config, including override selectors, is interpreted correctly. - (resource) => - Effect.gen(function* () { - yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); - for (const icon of resource.icons) { - yield* fs.writeFile(icon.targetPath, icon.publish); - } - yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); - - const args = createVpPmPublishArgs(config); - const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); - - yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); - yield* runCommand( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: repoRoot, - stdout: config.verbose ? "inherit" : "ignore", - stderr: "inherit", - shell: spawnCommand.shell, - }), - ); + const args = ["publish", "--access", config.access, "--tag", config.tag]; + if (config.provenance) args.push("--provenance"); + if (config.dryRun) args.push("--dry-run"); + + for (const tarball of [...platformTarballs, launcherTarball]) { + const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); + yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: packagesDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + shell: spawnCommand.shell, }), - // Release: restore every file even if applying overrides or publishing fails. - (resource) => - Effect.gen(function* () { - yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); - for (const icon of resource.icons) { - yield* fs.writeFile(icon.targetPath, icon.original); - } - if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); - }), - ); + ); + } }), -).pipe(Command.withDescription("Publish the server package to npm.")); +).pipe( + Command.withDescription( + "Publish the @t3code/t3- tarballs and then the t3 launcher to npm.", + ), +); // --------------------------------------------------------------------------- // root command diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index ce4bb6c2f8eb..5c02281aabb1 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError()( - "ServerCliPublishIconSourceMissingError", - { - sourcePath: Schema.String, - }, -) { - override get message(): string { - return `Missing publish icon source: ${this.sourcePath}`; - } -} - -export class ServerCliPublishIconTargetMissingError extends Schema.TaggedError()( - "ServerCliPublishIconTargetMissingError", - { - targetPath: Schema.String, - }, -) { - override get message(): string { - return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`; - } -} - export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError()( "ServerCliDevelopmentIconSourceMissingError", { diff --git a/docs/operations/release.md b/docs/operations/release.md index 1b74c27b903f..a143411c4bf4 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` - - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes only a GitHub prerelease. Nothing ever selects preview on its own: it is not on the schedule, not published to npm, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle. + - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Nothing ever selects preview on its own: it is not on the schedule, no default npm dist-tag points at it, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `npx t3@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle. - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep landing while you verify and never leak into the stable build. @@ -36,14 +36,15 @@ This document covers the unified release workflow for stable and nightly desktop - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. + - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm packages exist for people who run `npx t3` or `npm install -g t3` themselves and carry the same archive contents; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. -- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: +- Publishes the CLI to npm with OIDC trusted publishing from the same workflow file, as the same bytes the GitHub Release carries: `scripts/build-npm-platform-packages.ts` unpacks the five CLI archives into `@t3code/t3--` packages (each with `os`/`cpu` set so npm installs only the matching one) and generates the `t3` launcher, whose `bin/t3.js` lists them as `optionalDependencies` and execs the installed executable. `npx t3` therefore needs Node only to run the launcher, never to run the server. `node apps/server/scripts/cli.ts publish` publishes the platform packages first and the launcher last, after a `--dry-run` pass over all of them so an auth or scope error fails before anything is live. - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` - - preview releases are not published to npm + - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name + - one-time setup: the `@t3code` npm scope (org) must exist, and `t3` and each `@t3code/t3--` package needs a trusted publisher registered for this workflow file (see below). - Deploys the hosted web app to Vercel only after a release is published: - stable releases are aliased to the `latest` hosted app channel - nightly releases are aliased to the `nightly` hosted app channel @@ -198,7 +199,7 @@ One-time Vercel dashboard setup: - `make_latest` is always `false` - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. -- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. +- Publishes the CLI npm packages (`t3` and `@t3code/t3--`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. ## Server self-update release invariant @@ -209,7 +210,7 @@ npm before users can receive that client. The workflow enforces this ordering: -1. `publish_cli` publishes the exact stable or nightly version to npm. +1. `publish_cli` publishes the exact release version to npm, on every channel. 2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. 3. `deploy_web` depends on `release` before moving the hosted channel to the new client. @@ -294,24 +295,33 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. ## 0) npm OIDC trusted publishing setup (CLI) -The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That -script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the -repository root so workspace publish configuration is applied correctly. +The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then +`node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on +each `@t3code/t3--.tgz` and finally on `t3.tgz`, the launcher. The script publishes +tarballs it built itself rather than directories: `npm publish ` strips `node_modules/` from the +tarball no matter what `files` says, and the executable loads its native addons from there. Seven +packages are published per release: `t3`, `@t3code/t3-darwin-arm64`, `@t3code/t3-darwin-x64`, +`@t3code/t3-linux-arm64`, `@t3code/t3-linux-x64`, `@t3code/t3-win32-arm64`, +`@t3code/t3-win32-x64`. Checklist: -1. Confirm npm org/user owns package `t3` (or rename package first if needed). -2. In npm package settings, configure Trusted Publisher: +1. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if + it does not). +2. For `t3` and each `@t3code/t3--` package, configure a Trusted Publisher in the + npm package settings (a package that has never been published needs a first publish or a + placeholder before the setting exists; the `--dry-run` step in `publish_cli` reports which + names are still rejected): - Provider: GitHub Actions - Repository: this repo - Workflow file: `.github/workflows/release.yml` - Environment (if used): match your npm trusted publishing config -3. Ensure npm account and org policies allow trusted publishing for the package. +3. Ensure npm account and org policies allow trusted publishing for every package. 4. Create release tag `vX.Y.Z` and push; workflow will: - - align the release package versions to `X.Y.Z` - - build web + server - - invoke the CLI publish script with npm dist-tag `latest` -5. Nightly runs invoke the same publish script with npm dist-tag `nightly`. + - build and smoke-test the five CLI archives + - build the npm packages from those archives + - publish them with npm dist-tag `latest` +5. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`. ## 1) Release validation and unsigned builds diff --git a/docs/user/install.md b/docs/user/install.md index e8045099fe67..a4ed171bd986 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -5,8 +5,10 @@ desktop, web, or mobile app. Set up the machine where the agents will work first ## Requirements -Command-line use, SSH hosts, and WSL backends need Node.js 22.16+ (22.x), 23.11+ -(23.x), or 24.10 and later. The native desktop app includes its server runtime. +`npx t3` needs Node.js only to run npm itself; the CLI it installs is a +self-contained executable. SSH hosts and WSL backends need Node.js 22.16+ +(22.x), 23.11+ (23.x), or 24.10 and later. The native desktop app includes its +server runtime. You need an installed, authenticated provider before starting a thread. You can launch T3 Code and configure providers afterwards. @@ -20,6 +22,22 @@ npx t3@latest This starts the server and opens the local web app. Run `npx t3@latest --help` for command-line options. +The executable is built for Apple Silicon Macs, Linux, and Windows. There is +no Intel Mac build of it, because Node cannot produce a single executable for +that platform; the Intel desktop app is unaffected. To run a standalone server +on an Intel Mac, build it from source. You need Node.js 24 and `vp` (see +[Install vp](https://github.com/pingdotgg/t3code#install-vp)): + +```bash +git clone https://github.com/pingdotgg/t3code +cd t3code && vp i && vp run build:desktop +node apps/server/dist/bin.mjs +``` + +A server run this way is a plain Node program: `t3 update` and the background +service do not apply, so update it with `git pull` and a rebuild, and start it +however you run other Node processes. + ## Desktop app Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 99339ba395a7..28f0d530bb29 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -19,7 +19,7 @@ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; */ // No darwin-x64: Node single-executables are unsupported on x64 macOS (the // SEA docs list macOS as arm64 only) and the binary segfaults on start. -const CLI_ARCHIVE_PLATFORM_KEYS = [ +export const CLI_ARCHIVE_PLATFORM_KEYS = [ "darwin-arm64", "linux-arm64", "linux-x64", diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 99bb7cf4396f..c70ffe4e0e08 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -23,6 +23,7 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu ]) { yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); } + // A hoisted install still leaves nested `node_modules/.bin` shim directories + // inside packages that declare bins (msgpackr-extract's). They are symlinks + // nothing runs, and the npm registry refuses a tarball that contains any + // symlink, so strip every `.bin` directory below node_modules. + yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules")); }); +const removeNestedBinDirectories = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, +): Effect.Effect => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + const child = path.join(root, entry); + if (entry === ".bin") { + yield* fs.remove(child, { recursive: true, force: true }); + continue; + } + const info = yield* fs.stat(child).pipe(Effect.option); + if (Option.isSome(info) && info.value.type === "Directory") { + yield* removeNestedBinDirectories(fs, path, child); + } + } + }); + /** Copies the web client without its sourcemaps, which nothing serves. */ const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { const fs = yield* FileSystem.FileSystem; @@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { "tar (zip)", ); } else { + // On Linux, pnpm hard-links identical files out of its store and node-gyp + // hard-links build outputs, and GNU tar records those as link entries. + // The npm registry rejects a tarball containing any, and the npm platform + // packages are re-packed from this archive's contents, so store every + // file as a file. macOS's bsdtar has no such flag; pnpm clones there. yield* runCommand( - ChildProcess.make("tar", ["-czf", archivePath, "-C", stageRoot, stem]), + ChildProcess.make("tar", [ + ...(input.platform === "linux" ? ["--hard-dereference"] : []), + "-czf", + archivePath, + "-C", + stageRoot, + stem, + ]), "tar (gzip)", ); } diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts new file mode 100644 index 000000000000..2e3a35a0e9c9 --- /dev/null +++ b/scripts/build-npm-platform-packages.test.ts @@ -0,0 +1,203 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildNpmPlatformPackages, + NpmPackagesArchivesMissingError, +} from "./build-npm-platform-packages.ts"; + +const VERSION = "1.2.3"; +const decodeManifest = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); +const KEYS = ["linux-x64", "darwin-arm64"] as const; + +const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const run = Effect.fn("test.run")(function* ( + command: string, + args: ReadonlyArray, + options: { readonly cwd: string; readonly env?: Record }, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(command, args, { cwd: options.cwd, env: options.env ?? {} }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; +}); + +/** A tar.gz laid out like build-cli-archive.ts writes, with a stub `t3` that echoes its args. */ +const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-packages-test-" }); + const archivesDir = path.join(root, "archives"); + yield* fs.makeDirectory(archivesDir); + for (const key of KEYS) { + const stem = `t3-${VERSION}-${key}`; + const stage = path.join(root, "stage", key); + const contentDir = path.join(stage, stem); + for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) { + yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true }); + } + yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "\n"); + yield* fs.writeFileString( + path.join(contentDir, "t3"), + `#!/bin/sh\necho "stub ${key} $*"\nexit 7\n`, + ); + yield* fs.chmod(path.join(contentDir, "t3"), 0o755); + const exit = yield* run("tar", ["-czf", path.join(archivesDir, `${stem}.tar.gz`), stem], { + cwd: stage, + }); + assert.equal(exit.exitCode, 0, exit.stderr); + } + yield* fs.writeFileString(path.join(archivesDir, "SHA256SUMS"), ""); + return { root, archivesDir, outputDir: path.join(root, "out") }; +}); + +it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { + it.effect("refuses a partial release unless --allow-missing is passed", () => + Effect.gen(function* () { + const fixture = yield* makeFakeArchives(); + const error = yield* buildNpmPlatformPackages({ + ...fixture, + version: VERSION, + allowMissing: false, + }).pipe(Effect.flip); + assert.instanceOf(error, NpmPackagesArchivesMissingError); + assert.deepStrictEqual((error as NpmPackagesArchivesMissingError).missing, [ + "linux-arm64", + "win32-arm64", + "win32-x64", + ]); + }), + ); + + it.effect("builds platform packages and a launcher that execs the installed one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFakeArchives(); + const outputs = yield* buildNpmPlatformPackages({ + ...fixture, + version: VERSION, + allowMissing: true, + }); + // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last. + assert.deepStrictEqual( + outputs.map((output) => output.name), + ["@t3code/t3-darwin-arm64", "@t3code/t3-linux-x64", "t3"], + ); + for (const output of outputs) { + assert.isTrue(yield* fs.exists(output.tarball), output.tarball); + } + + const linuxDir = path.join(fixture.outputDir, "@t3code/t3-linux-x64"); + const linuxManifest = yield* decodeManifest( + yield* fs.readFileString(path.join(linuxDir, "package.json")), + ); + assert.equal(linuxManifest.name, "@t3code/t3-linux-x64"); + assert.equal(linuxManifest.version, VERSION); + assert.deepStrictEqual(linuxManifest.os, ["linux"]); + assert.deepStrictEqual(linuxManifest.cpu, ["x64"]); + assert.deepStrictEqual(linuxManifest.files, [ + "t3", + "t3.exe", + "client", + "resource-monitor", + "node_modules", + ]); + assert.equal(linuxManifest.preferUnplugged, true); + assert.isUndefined(linuxManifest.bin); + // Archive contents sit at the package root, not under the archive stem. + assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); + // A root README, or npm would display a bundled dependency's. + assert.include( + yield* fs.readFileString(path.join(linuxDir, "README.md")), + "# @t3code/t3-linux-x64", + ); + assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty"))); + assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111); + + const darwinManifest = yield* decodeManifest( + yield* fs.readFileString( + path.join(fixture.outputDir, "@t3code/t3-darwin-arm64/package.json"), + ), + ); + assert.deepStrictEqual(darwinManifest.os, ["darwin"]); + assert.deepStrictEqual(darwinManifest.cpu, ["arm64"]); + + const launcherDir = path.join(fixture.outputDir, "t3"); + const launcherManifest = yield* decodeManifest( + yield* fs.readFileString(path.join(launcherDir, "package.json")), + ); + assert.equal(launcherManifest.name, "t3"); + assert.equal(launcherManifest.version, VERSION); + assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); + assert.deepStrictEqual(launcherManifest.files, ["bin"]); + assert.deepStrictEqual(launcherManifest.optionalDependencies, { + "@t3code/t3-darwin-arm64": VERSION, + "@t3code/t3-linux-x64": VERSION, + }); + assert.isUndefined(launcherManifest.engines); + assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js"))); + + // The scratch dirs must not be left behind next to the packages. + const outputEntries = yield* fs.readDirectory(fixture.outputDir); + assert.deepStrictEqual(outputEntries.sort(), ["@t3code", "t3", "t3.tgz"]); + + // The tarball is what gets published: it must carry node_modules (which + // `npm publish ` would strip) under npm's `package/` root, with the + // executable bit intact. + const listing = yield* run( + "tar", + ["-tzvf", path.join(fixture.outputDir, "@t3code/t3-linux-x64.tgz")], + { cwd: fixture.outputDir }, + ); + assert.equal(listing.exitCode, 0, listing.stderr); + const lines = listing.stdout.split("\n"); + assert.isTrue(lines.some((line) => line.endsWith(" package/node_modules/node-pty/"))); + assert.isTrue(lines.some((line) => line.endsWith(" package/package.json"))); + assert.isTrue( + lines.some((line) => /^-rwxr-xr-x .* package\/t3$/.test(line)), + listing.stdout, + ); + + // NODE_PATH stands in for node_modules: require.resolve finds the + // platform package there exactly as it would after `npm install`. + const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; + const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { + cwd: launcherDir, + env, + }); + assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234"); + assert.equal(passthrough.exitCode, 7); + + const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { + cwd: launcherDir, + env: { ...env, NODE_PATH: path.join(fixture.root, "nowhere") }, + }); + assert.equal(unsupported.exitCode, 1); + assert.include(unsupported.stderr, "linux-x64"); + assert.include(unsupported.stderr, "win32-arm64"); + assert.include(unsupported.stderr, "https://github.com/pingdotgg/t3code/releases"); + }), + ); +}); diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts new file mode 100644 index 000000000000..f9417426b82b --- /dev/null +++ b/scripts/build-npm-platform-packages.ts @@ -0,0 +1,443 @@ +#!/usr/bin/env node +/** + * Turns the per-platform CLI archives of one release into the npm packages + * behind `npx t3` / `npm i -g t3`: one `@t3code/t3-` package per + * archive holding the archive's contents verbatim, plus the `t3` launcher + * that lists them as optionalDependencies and execs the one npm installed. + * The bytes a user gets from npm are therefore the release archive's, and + * running them needs neither a Node runtime, npm, nor a native build. + * + * Output layout under `--output-dir`: + * + * @t3code/t3-/ archive contents flattened + package.json + * @t3code/t3-.tgz the same tree as an npm tarball + * t3/ launcher: package.json, bin/t3.js, README.md + * t3.tgz the launcher as an npm tarball + * + * The tarballs are what gets published. `npm publish ` always drops + * `node_modules/` (npm-packlist ignores it whatever `files` says, and + * bundleDependencies needs an arborist tree these flattened installs are + * not), whereas `npm publish ` uploads the bytes as given. + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + CLI_ARCHIVE_PLATFORM_KEYS, + cliArchiveFileName, + type CliArchivePlatformKey, +} from "@t3tools/shared/cliRelease"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { isCommandAvailable } from "@t3tools/shared/shell"; +import serverPackageJson from "../apps/server/package.json" with { type: "json" }; + +import { windowsSystemTar } from "./build-cli-archive.ts"; + +export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code"; +export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; + +const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); + +export class NpmPackagesCommandFailedError extends Schema.TaggedError()( + "NpmPackagesCommandFailedError", + { command: Schema.String, exitCode: Schema.Int }, +) { + override get message(): string { + return `${this.command} exited with code ${this.exitCode}.`; + } +} + +export class NpmPackagesToolMissingError extends Schema.TaggedError()( + "NpmPackagesToolMissingError", + { tool: Schema.String, purpose: Schema.String }, +) { + override get message(): string { + return `\`${this.tool}\` is not on PATH; it is needed to ${this.purpose}.`; + } +} + +export class NpmPackagesArchivesMissingError extends Schema.TaggedError()( + "NpmPackagesArchivesMissingError", + { archivesDir: Schema.String, missing: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `${this.archivesDir} lacks archives for ${this.missing.join(", ")}. A launcher published without them would silently skip those platforms; pass --allow-missing for a deliberately partial build.`; + } +} + +export class NpmPackagesArchiveLayoutError extends Schema.TaggedError()( + "NpmPackagesArchiveLayoutError", + { archive: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `${this.archive}: ${this.detail}`; + } +} + +export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): string { + return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`; +} + +/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */ +export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) { + const [os, cpu] = platformKey.split("-") as [string, string]; + return { + name: npmPlatformPackageName(platformKey), + version, + description: `T3 Code CLI executable for ${platformKey}`, + license: serverPackageJson.license, + repository: serverPackageJson.repository, + os: [os], + cpu: [cpu], + files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"], + preferUnplugged: true, + }; +} + +/** + * README for one platform package. Without one at the package root, npm + * shows the first README it finds in the tarball, which is a bundled + * dependency's (ffi-rs). + */ +export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): string { + return [ + `# ${npmPlatformPackageName(platformKey)}`, + "", + `The T3 Code CLI executable for ${platformKey}. Do not install this package directly:`, + `it is an optional dependency of \`${NPM_LAUNCHER_PACKAGE_NAME}\`, which picks the package for the`, + "current platform and runs the executable inside it.", + "", + "```sh", + `npx ${NPM_LAUNCHER_PACKAGE_NAME}@latest`, + "```", + "", + "Source and documentation: https://github.com/pingdotgg/t3code", + "", + ].join("\n"); +} + +/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */ +export function npmLauncherPackageManifest( + version: string, + platformKeys: ReadonlyArray, +) { + return { + name: NPM_LAUNCHER_PACKAGE_NAME, + version, + description: "T3 Code CLI. Installs the self-contained executable for this platform.", + license: serverPackageJson.license, + repository: serverPackageJson.repository, + bin: { t3: "./bin/t3.js" }, + files: ["bin"], + optionalDependencies: Object.fromEntries( + platformKeys.map((key) => [npmPlatformPackageName(key), version]), + ), + }; +} + +/** + * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it + * loads on any Node that npm itself runs on; the real work happens in the + * single-executable it execs. + */ +export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node +"use strict"; +const { spawnSync } = require("node:child_process"); +const { constants } = require("node:os"); +const { dirname, join } = require("node:path"); + +const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}]; +const key = process.platform + "-" + process.arch; + +let packageDir; +try { + packageDir = dirname(require.resolve("${NPM_PLATFORM_PACKAGE_SCOPE}/t3-" + key + "/package.json")); +} catch { + process.stderr.write( + [ + "t3: no T3 Code CLI build is available for this platform (" + key + ").", + "Supported platforms: " + SUPPORTED.join(", ") + ".", + "If yours is listed, reinstall t3 so npm fetches its optional dependency.", + "The desktop app and release archives are at https://github.com/pingdotgg/t3code/releases", + "", + ].join("\\n"), + ); + process.exit(1); +} + +const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3"); +const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) { + process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n"); + process.exit(1); +} +// A child killed by a signal has no status; report it the way a shell would. +process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1)); +`; + +const runCommand = Effect.fn("runCommand")(function* ( + command: ChildProcess.StandardCommand, + label: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(command.command, command.args, { + ...command.options, + stdout: "inherit", + stderr: "inherit", + }), + ); + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new NpmPackagesCommandFailedError({ command: label, exitCode }); + } +}); + +/** + * Extracts an archive and returns its single top-level directory. `.tar.gz` + * goes through tar everywhere; `.zip` through the bsdtar Windows ships or, + * elsewhere, `unzip`, since GNU tar cannot read zip. + */ +const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (!archive.endsWith(".zip")) { + yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf"); + } else if (platform === "win32") { + yield* runCommand( + ChildProcess.make(windowsSystemTar(), ["-xf", archive, "-C", into]), + "tar.exe -xf (zip)", + ); + } else { + if (!(yield* isCommandAvailable("unzip"))) { + return yield* new NpmPackagesToolMissingError({ + tool: "unzip", + purpose: `extract ${path.basename(archive)} (GNU tar cannot read zip)`, + }); + } + yield* runCommand(ChildProcess.make("unzip", ["-q", archive, "-d", into]), "unzip"); + } + const entries = yield* fs.readDirectory(into); + const [root] = entries; + if (root === undefined || entries.length !== 1) { + return yield* new NpmPackagesArchiveLayoutError({ + archive: path.basename(archive), + detail: `expected exactly one top-level directory, found ${String(entries.length)} entries`, + }); + } + return path.join(into, root); +}); + +/** Tar to build npm tarballs with; see build-cli-archive.ts for why Windows names bsdtar by path. */ +const hostTar = Effect.map(HostProcessPlatform, (platform) => + platform === "win32" ? windowsSystemTar() : "tar", +); + +/** + * Writes `stageDir/package` as a gzipped npm tarball and then moves the tree + * to `packageDir` so the contents stay inspectable beside the tarball. + */ +const packAndPlace = Effect.fn("packAndPlace")(function* (input: { + readonly stageDir: string; + readonly packageDir: string; + readonly tarball: string; +}) { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(input.tarball, { force: true }); + yield* runCommand( + ChildProcess.make(yield* hostTar, ["-czf", input.tarball, "-C", input.stageDir, "package"]), + `tar (${input.tarball})`, + ); + yield* fs.remove(input.packageDir, { recursive: true, force: true }); + yield* fs.rename(`${input.stageDir}/package`, input.packageDir); +}); + +export interface NpmPackageOutput { + readonly name: string; + readonly packageDir: string; + readonly tarball: string; +} + +/** + * Extracts one archive, adds its package.json, and emits the package dir and + * tarball. The scratch dir lives inside the output dir so the extracted tree + * is renamed into place rather than copied across filesystems. + */ +const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: { + readonly key: CliArchivePlatformKey; + readonly archive: string; + readonly outputDir: string; + readonly version: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.log(`[npm-packages] Extracting ${path.basename(input.archive)}...`); + const scratch = yield* fs.makeTempDirectoryScoped({ + directory: input.outputDir, + prefix: ".extract-", + }); + const extractDir = path.join(scratch, "extract"); + yield* fs.makeDirectory(extractDir); + const contentDir = yield* extractArchive(input.archive, extractDir); + const executableName = input.key.startsWith("win32") ? "t3.exe" : "t3"; + const executable = path.join(contentDir, executableName); + if (!(yield* fs.exists(executable))) { + return yield* new NpmPackagesArchiveLayoutError({ + archive: path.basename(input.archive), + detail: `missing ${executableName} at the archive root`, + }); + } + // The tarball carries the on-disk mode, so the bit must be set before packing. + if (executableName === "t3") { + yield* fs.chmod(executable, 0o755); + } + yield* fs.writeFileString( + path.join(contentDir, "package.json"), + `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`, + ); + yield* fs.writeFileString( + path.join(contentDir, "README.md"), + npmPlatformPackageReadme(input.key), + ); + // npm tarballs root everything under `package/`. + yield* fs.rename(contentDir, path.join(scratch, "package")); + const name = npmPlatformPackageName(input.key); + const output: NpmPackageOutput = { + name, + packageDir: path.join(input.outputDir, name), + tarball: path.join(input.outputDir, `${name}.tgz`), + }; + yield* packAndPlace({ stageDir: scratch, ...output }); + return output; +}, Effect.scoped); + +/** Writes the launcher package (package.json, bin/t3.js, README) and its tarball. */ +const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: { + readonly outputDir: string; + readonly version: string; + readonly platformKeys: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scratch = yield* fs.makeTempDirectoryScoped({ + directory: input.outputDir, + prefix: ".launcher-", + }); + const stageDir = path.join(scratch, "package"); + yield* fs.makeDirectory(path.join(stageDir, "bin"), { recursive: true }); + yield* fs.writeFileString( + path.join(stageDir, "package.json"), + `${yield* encodePackageJson(npmLauncherPackageManifest(input.version, input.platformKeys))}\n`, + ); + const launcherScript = path.join(stageDir, "bin/t3.js"); + yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT); + yield* fs.chmod(launcherScript, 0o755); + const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url)); + if (yield* fs.exists(readme)) { + yield* fs.copyFile(readme, path.join(stageDir, "README.md")); + } + const output: NpmPackageOutput = { + name: NPM_LAUNCHER_PACKAGE_NAME, + packageDir: path.join(input.outputDir, NPM_LAUNCHER_PACKAGE_NAME), + tarball: path.join(input.outputDir, `${NPM_LAUNCHER_PACKAGE_NAME}.tgz`), + }; + yield* packAndPlace({ stageDir: scratch, ...output }); + return output; +}, Effect.scoped); + +export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(function* (input: { + readonly archivesDir: string; + readonly version: string; + readonly outputDir: string; + readonly allowMissing: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const present = yield* fs.readDirectory(input.archivesDir); + const archives = CLI_ARCHIVE_PLATFORM_KEYS.flatMap((key) => { + const fileName = cliArchiveFileName(input.version, key); + return present.includes(fileName) + ? [{ key, archive: path.join(input.archivesDir, fileName) }] + : []; + }); + const missing = CLI_ARCHIVE_PLATFORM_KEYS.filter( + (key) => !archives.some((entry) => entry.key === key), + ); + if (missing.length > 0 && (!input.allowMissing || archives.length === 0)) { + return yield* new NpmPackagesArchivesMissingError({ archivesDir: input.archivesDir, missing }); + } + + yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), { + recursive: true, + }); + const outputs: Array = []; + for (const { key, archive } of archives) { + outputs.push( + yield* stagePlatformPackage({ + key, + archive, + outputDir: input.outputDir, + version: input.version, + }), + ); + } + outputs.push( + yield* stageLauncherPackage({ + outputDir: input.outputDir, + version: input.version, + platformKeys: archives.map((entry) => entry.key), + }), + ); + + for (const output of outputs) { + yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`); + } + if (missing.length > 0) { + yield* Effect.logWarning( + `[npm-packages] Launcher omits ${missing.join(", ")} (--allow-missing).`, + ); + } + return outputs; +}); + +const command = Command.make( + "build-npm-platform-packages", + { + archivesDir: Flag.string("archives-dir").pipe( + Flag.withDescription("Directory holding the release's t3-- archives."), + ), + version: Flag.string("version").pipe( + Flag.withDescription( + "Exact release version; selects the archives and versions the packages.", + ), + ), + outputDir: Flag.string("output-dir").pipe(Flag.withDefault("npm-packages")), + allowMissing: Flag.boolean("allow-missing").pipe( + Flag.withDefault(false), + Flag.withDescription("Build a launcher that lists only the platforms present."), + ), + }, + buildNpmPlatformPackages, +).pipe( + Command.withDescription( + "Build the t3 launcher and @t3code/t3- npm packages from CLI release archives.", + ), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, + ); +} From b70015b6db8ffafd902174f78cb260cc8082fc6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:24 -0700 Subject: [PATCH 19/27] feat(cli): add t3 uninstall for self-contained installs (#11659) Co-authored-by: Claude Fable 5 --- apps/server/src/bin.ts | 2 + apps/server/src/cli/uninstall.test.ts | 37 +++++ apps/server/src/cli/uninstall.ts | 222 +++++++++++++++++++++++++ apps/server/src/cli/update.ts | 18 +- apps/server/src/cloud/pinnedRuntime.ts | 6 +- docs/user/background-service.md | 6 + 6 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/cli/uninstall.test.ts create mode 100644 apps/server/src/cli/uninstall.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index d037a6687738..1ec78ff2e173 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { uninstallCommand } from "./cli/uninstall.ts"; import { updateCommand } from "./cli/update.ts"; import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => projectCommand, serviceCommand, updateCommand, + uninstallCommand, serviceLauncherCommand, claudeHistoryCommand, servicePreflightCommand, diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts new file mode 100644 index 000000000000..02860f529440 --- /dev/null +++ b/apps/server/src/cli/uninstall.test.ts @@ -0,0 +1,37 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { findOwnedLauncher } from "./uninstall.ts"; + +it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { + it.effect("claims only a launcher that points into this home's runtime tree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" }); + const versionsDir = path.join(root, "runtime/versions"); + const exe = path.join(versionsDir, "1.0.0/t3"); + const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3"); + const copy = path.join(root, "copy/t3"); + for (const file of [exe, otherExe, copy]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + const ours = path.join(root, "bin/t3"); + const theirs = path.join(root, "other/bin/t3"); + yield* fs.makeDirectory(path.dirname(ours), { recursive: true }); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.symlink(exe, ours); + yield* fs.symlink(otherExe, theirs); + + assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); +}); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts new file mode 100644 index 000000000000..655f02b8c815 --- /dev/null +++ b/apps/server/src/cli/uninstall.ts @@ -0,0 +1,222 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The Windows cleanup shell must outlive this process (it deletes the +// directory this executable runs from), which Effect's scoped ChildProcess +// cannot express: it kills the child when the scope closes. +import * as NodeChildProcess from "node:child_process"; + +import { + HostProcessEnvironment, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import * as BootService from "../cloud/bootService.ts"; +import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { bootServiceLayer } from "./service.ts"; +import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts"; + +export class CliUninstallError extends Schema.TaggedError()( + "CliUninstallError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +/** + * What `t3 uninstall` would remove for one T3 home. Computed before anything + * is touched so the user sees the whole plan in one place. + */ +export interface UninstallPlan { + /** The background service serves this home and will be stopped and removed. */ + readonly service: boolean; + /** The `t3` launcher (symlink or `.cmd` shim) that points into this home's runtime tree. */ + readonly launcher: string | undefined; + /** `/runtime`, holding every downloaded version, when it exists. */ + readonly runtimeDir: string | undefined; + /** `/userdata`, which is never removed; shown so the user knows where it is. */ + readonly userdataDir: string; +} + +/** + * Finds the launcher this install left on PATH. Only a launcher that points + * into this home's `runtime/versions` is claimed: a plain copy of the + * executable, or a launcher for another home, is not ours to delete. + */ +export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: { + readonly launchedAs: string | undefined; + readonly versionsDir: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (input.launchedAs === undefined) return undefined; + if (platform === "win32") { + const shimPath = yield* findWindowsShim(input.launchedAs); + if (shimPath === undefined) return undefined; + const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); + const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined; + return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target) + ? shimPath + : undefined; + } + const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); + if (Option.isNone(linkTarget)) return undefined; + const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value); + return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined; +}); + +const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { + readonly baseDir: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* BootService.BootService; + const status = yield* service.status; + const servesThisHome = + status.installedBaseDir !== undefined && + path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); + const runtimeDir = path.dirname(versionsDir); + const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const plan: UninstallPlan = { + service: status.supported && status.installed && servesThisHome, + launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }), + runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false))) + ? runtimeDir + : undefined, + userdataDir: path.join(input.baseDir, "userdata"), + }; + return plan; +}); + +export const uninstallCommand = Command.make("uninstall", { + ...projectLocationFlags, + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription( + "Remove everything without asking. Required from a script, where there is no prompt.", + ), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription( + "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe( + Effect.provide(bootServiceLayer(config)), + ); + }), + ), +); + +const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { + readonly baseDir: string; + readonly assumeYes: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + const service = yield* BootService.BootService; + const plan = yield* planUninstall({ baseDir: input.baseDir }); + + if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) { + yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); + if (!(yield* HostProcessIsExecutable)) { + yield* Console.log( + " This t3 runs from a Node script, so it was installed by npm or built from source. Remove it the same way (`npm uninstall -g t3`, or delete the checkout).", + ); + } + return; + } + + yield* Console.log("This will remove:"); + if (plan.service) yield* Console.log(" the background service (stopping it first)"); + if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`); + if (plan.runtimeDir !== undefined) { + yield* Console.log(` every downloaded version under ${plan.runtimeDir}`); + } + yield* Console.log( + `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`, + ); + + if (!input.assumeYes) { + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return yield* new CliUninstallError({ + reason: + "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.", + }); + } + const confirmed = yield* Prompt.run( + Prompt.confirm({ message: "Remove t3 from this machine?", initial: false }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + if (!confirmed) { + yield* Console.log("Left as is."); + return; + } + } + + if (plan.service) { + yield* service.uninstall; + yield* Console.log("Removed the background service."); + } + if (plan.launcher !== undefined) { + yield* fs + .remove(plan.launcher, { force: true }) + .pipe( + Effect.mapError( + () => + new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }), + ), + ); + yield* Console.log(`Removed ${plan.launcher}.`); + } + if (plan.runtimeDir !== undefined) { + // This process runs from inside runtimeDir. POSIX unlinks a running + // executable fine; Windows refuses, so the tree is removed after this + // process exits by a detached shell, and the user is told either way. + if (platform === "win32") { + const runtimeDir = plan.runtimeDir; + const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe"; + yield* Effect.try({ + try: () => { + const child = NodeChildProcess.spawn( + comspec, + ["/d", "/c", `ping -n 3 127.0.0.1 >nul & rmdir /s /q "${runtimeDir}"`], + { detached: true, stdio: "ignore", windowsHide: true }, + ); + child.unref(); + }, + catch: () => + new CliUninstallError({ + reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`, + }), + }); + yield* Console.log(`${runtimeDir} will be removed once t3 exits.`); + } else { + yield* fs + .remove(plan.runtimeDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }), + ), + ); + yield* Console.log(`Removed ${plan.runtimeDir}.`); + } + } + yield* Console.log(""); + yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code."); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 62a66b985022..9edbc30d96b0 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -99,6 +99,16 @@ const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); }); +/** Whether a launcher target lives inside `/runtime/versions`. */ +export function launcherOwnsVersionsDir( + path: Path.Path, + versionsDir: string, + candidate: string, +): boolean { + const relative = path.relative(versionsDir, path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); +} + /** * The launcher the install scripts leave behind: a symlink at `/t3` on * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3` @@ -117,10 +127,8 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function const path = yield* Path.Path; const platform = yield* HostProcessPlatform; if (input.launchedAs === undefined) return Option.none(); - const ownsTarget = (candidate: string) => { - const relative = path.relative(input.versionsDir, path.resolve(candidate)); - return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); - }; + const ownsTarget = (candidate: string) => + launcherOwnsVersionsDir(path, input.versionsDir, candidate); if (platform === "win32") { // The shim runs the executable by absolute path, so the executable sees @@ -189,7 +197,7 @@ export const resolveLauncherPath = Effect.gen(function* () { * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the * running executable; that is the launcher the install script wrote. */ -const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( +export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( executablePath: string, ) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 686d5cec9d2a..680d80e46cd1 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -51,13 +51,17 @@ export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { return { command: paths.entryPath, args: [] }; } +export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string { + return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, version: string, platform: NodeJS.Platform, ): PinnedRuntimePaths { - const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version); return { versionDir, entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 86b022cf1245..7eca6329f325 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -59,6 +59,12 @@ yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or `--allow-downgrade` to move backwards. +`t3 uninstall` reverses the install script: it shows what it found (the +background service, the `t3` launcher, every downloaded version under +`~/.t3/runtime`), asks once, and removes them. Your projects, threads, and +settings under `~/.t3/userdata` are kept; delete that directory yourself if +you want them gone too. Pass `--yes` from a script. + ## Platform support Linux needs systemd user services. Setup enables lingering so T3 Code starts at From 73b206f4bf99e7e98ae144a9d9edbf7cb2700fc1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 21:28:59 -0700 Subject: [PATCH 20/27] feat(web): show each worktree setup step and let users cancel it (#11372) Starting a thread in a new worktree showed one static "Setting up worktree" line while git fetched, checked out files, and the setup script ran. Users could not tell which step was slow, see setup script output without hunting for the terminal, or stop a setup that was going wrong. The timeline now shows a card with each step and its elapsed time. Check out files has a percent bar fed by git's own progress output. The setup script step shows the last lines of its terminal inline and links to the full terminal. Cancel stops the bootstrap and removes the half built worktree. Work locally cancels, switches the draft to the project checkout, and resends. The server keeps an in-memory per-thread snapshot of the bootstrap stages and streams it over a new subscribeWorktreeSetup RPC. The bootstrap runs as a child fiber so worktreeSetup.cancel can interrupt it, and the turn handoff is uninterruptible. The setup script's exit code comes from a per-run sentinel echoed after the command in the setup PTY. Created with Claude Fable 5.1 in Claude Code. --- apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/git/GitWorkflowService.ts | 5 +- .../project/ProjectSetupScriptRunner.test.ts | 218 ++++++++++- .../src/project/ProjectSetupScriptRunner.ts | 217 ++++++++++- .../src/project/WorktreeSetupTracker.test.ts | 226 +++++++++++ .../src/project/WorktreeSetupTracker.ts | 364 ++++++++++++++++++ apps/server/src/server.test.ts | 35 +- apps/server/src/server.ts | 2 + apps/server/src/vcs/GitVcsDriver.ts | 31 ++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 67 +++- apps/server/src/vcs/GitVcsDriverCore.ts | 101 ++++- apps/server/src/ws.ts | 356 +++++++++++++++-- .../web/src/components/ChatView.logic.test.ts | 24 ++ apps/web/src/components/ChatView.logic.ts | 5 + apps/web/src/components/ChatView.tsx | 156 ++++++++ .../chat/MessagesTimeline.logic.test.ts | 78 ++++ .../components/chat/MessagesTimeline.logic.ts | 43 ++- .../src/components/chat/MessagesTimeline.tsx | 48 ++- .../src/components/chat/WorktreeSetupCard.tsx | 296 ++++++++++++++ packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/vcs.ts | 18 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 23 ++ packages/contracts/src/worktreeSetup.ts | 114 ++++++ 24 files changed, 2352 insertions(+), 79 deletions(-) create mode 100644 apps/server/src/project/WorktreeSetupTracker.test.ts create mode 100644 apps/server/src/project/WorktreeSetupTracker.ts create mode 100644 apps/web/src/components/chat/WorktreeSetupCard.tsx create mode 100644 packages/contracts/src/worktreeSetup.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2e2bdea73fc7..ca55e4e95e01 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -111,6 +111,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, + [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5e3e5b0420f2..9f3231beb490 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -69,6 +69,7 @@ export class GitWorkflowService extends Context.Service< ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, ) => Effect.Effect; readonly fetchRemote: (input: { readonly cwd: string; @@ -338,9 +339,9 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), - createWorktree: (input) => + createWorktree: (input, options) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen(git.createWorktree(input)), + Effect.andThen(git.createWorktree(input, options)), ), fetchRemote: (input) => ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index fc582ef35516..c0d15f105af7 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import { type OrchestrationProject, ProjectId, type TerminalEvent } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -54,11 +55,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => searchThreads: () => Effect.succeed({ matches: [] }), }); -const makeTerminalManagerLayer = ( - overrides: Pick, -) => +type TerminalOverrides = Pick & + Partial>; + +const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { - ...overrides, attachStream: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, @@ -66,11 +67,12 @@ const makeTerminalManagerLayer = ( close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), + ...overrides, }); const testLayer = ( project: OrchestrationProject, - terminal: Pick, + terminal: TerminalOverrides, settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( @@ -198,6 +200,7 @@ describe("ProjectSetupScriptRunner", () => { status: "started", scriptId: "setup", scriptName: "Setup", + scriptCommand: "bun install", terminalId: "setup-setup", cwd: "/repo/worktrees/a", }); @@ -220,6 +223,209 @@ describe("ProjectSetupScriptRunner", () => { }, ); + it.effect( + "wraps the command with a completion sentinel and resolves the exit code from terminal output", + () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const writes: string[] = []; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void writes.push(input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + const emit = (data: string) => + Effect.suspend(() => + listener + ? listener({ threadId: "thread-1", terminalId: "setup-setup", type: "output", data }) + : Effect.void, + ); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const seen: string[] = []; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: { + onOutputLine: (line) => Effect.sync(() => void seen.push(line)), + }, + }); + expect(result.status).toBe("started"); + if (result.status !== "started") return; + expect(result.completion).toBeDefined(); + + // The subscription is attached before the command is written. + expect(subscribe).toHaveBeenCalledTimes(1); + expect(writes).toHaveLength(1); + // The block closes on its own line so a trailing comment in the + // command cannot swallow the sentinel, and the sentinel carries a + // per-run token so script output cannot spoof it. + const written = writes[0] ?? ""; + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + expect(sentinel).toBeDefined(); + expect(written).toBe(`( bun install\r); printf '\\n${sentinel}%s\\n' "$?"\r`); + + // Output arrives in chunks; partial lines are buffered until a newline, + // control sequences are stripped, and the echoed wrapper is hidden. + yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); + yield* emit("\u001b[32mResolving"); + yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n"); + // A spoofed sentinel from the script itself must not settle completion. + yield* emit("__T3_SETUP_DONE__:0\r\n"); + yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); + yield* emit(`${sentinel}3\r\n`); + + const completion = yield* result.completion!; + expect(completion.exitCode).toBe(3); + expect(seen).toEqual([ + "Resolving deps", + "Done in 2s", + "__T3_SETUP_DONE__:0", + `__T3_SETUP_DONE___${"0".repeat(32)}:0`, + ]); + // The subscription is torn down once the sentinel arrives. + expect(listener).toBeNull(); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }, + ); + + it.effect("unsubscribes from terminal output when the command cannot be written", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => + Effect.fail( + new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", cause: {} }), + ), + ); + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => Effect.succeed(unsubscribe)); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner + .runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(testLayer(project, { open, write, subscribe }))); + }); + + it.effect.each([ + { + shell: "/usr/bin/fish", + expected: + /^begin\rbun install\rend; printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' \$status\r$/, + }, + { + shell: "/bin/bash", + expected: /^\( bun install\r\); printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' "\$\?"\r$/, + }, + ])("wraps the command for the $shell syntax", ({ shell, expected }) => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const writes: string[] = []; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void writes.push(input.data)), + ); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatch(expected); + }).pipe( + Effect.provide(testLayer(project, { open, write })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: shell }), + ); + }); + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3bbb0daa7994..c80dd535f514 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,10 +1,15 @@ import { ProjectId } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { projectScriptRuntimeEnv, resolveProjectScripts, setupProjectScript, } from "@t3tools/shared/projectScripts"; +import * as NodeCrypto from "node:crypto"; + +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -22,8 +27,24 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly status: "started"; readonly scriptId: string; readonly scriptName: string; + readonly scriptCommand: string; readonly terminalId: string; readonly cwd: string; + /** + * Resolves when the script's shell prints the completion sentinel. The + * exit code is null when the terminal exited or was closed before the + * sentinel arrived. Only present when `observeCompletion` was requested. + */ + readonly completion?: Effect.Effect; +} + +export interface ProjectSetupScriptCompletion { + readonly exitCode: number | null; + readonly durationMs: number; +} + +export interface ProjectSetupScriptOutputLine { + readonly line: string; } export type ProjectSetupScriptRunnerResult = @@ -36,6 +57,14 @@ export interface ProjectSetupScriptRunnerInput { readonly projectCwd?: string; readonly worktreePath: string; readonly preferredTerminalId?: string; + /** + * Wrap the command so the shell reports its exit code back through the + * terminal stream, and forward cleaned output lines while it runs. The + * bootstrap flow uses this to drive the worktree setup card. + */ + readonly observeCompletion?: { + readonly onOutputLine?: (line: string) => Effect.Effect; + }; } export class ProjectSetupScriptOperationError extends Schema.TaggedError()( @@ -83,11 +112,170 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} +/** @public Service construction is part of the canonical Effect module API. */ +/** + * Marker the wrapped setup command echoes so the exit code can be read from + * the PTY stream. Each run gets its own random token so script output cannot + * spoof completion, and the sentinel pattern is built per run from it. + */ +const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; +const OUTPUT_LINE_MAX_LENGTH = 400; +/** A partial line longer than this is a byte stream, not a line. Keep only the tail. */ +const PARTIAL_LINE_MAX_LENGTH = 4_096; + +function completionSentinel(token: string): string { + return `${COMPLETION_SENTINEL_PREFIX}_${token}:`; +} + +function completionSentinelPattern(token: string): RegExp { + return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`); +} + +/** Removes ANSI escape sequences and cursor controls so lines can be shown as plain text. */ +function stripTerminalControl(text: string): string { + return ( + text + .replace( + // eslint-disable-next-line no-control-regex + /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, + "", + ) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + ); +} + +type CompletionShell = "posix" | "fish" | "powershell"; + +/** + * Predicts the shell TerminalManager will spawn for the setup terminal. The + * manager takes `$SHELL` on POSIX and PowerShell on Windows, falling back to + * other shells only when that one fails to spawn. + */ +function resolveCompletionShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): CompletionShell { + if (platform === "win32") return "powershell"; + const shell = env.SHELL ?? ""; + const name = shell.split("/").at(-1) ?? shell; + if (name === "fish") return "fish"; + if (name === "pwsh" || name === "powershell") return "powershell"; + return "posix"; +} + +/** + * Builds the shell input for the setup script. The command runs inside a + * block and the block closes on its own line, so a trailing `# comment` or a + * heredoc terminator in the command cannot swallow the sentinel. The shell + * reads the whole block before running any of it, so a script that reads + * stdin cannot consume the sentinel line either. Lines are separated by `\r` + * because that is the Enter key for every shell's line editor. + */ +function wrapCommandForCompletion( + command: string, + shell: CompletionShell, + sentinel: string, +): string { + const body = command.replace(/\r?\n/g, "\r"); + switch (shell) { + case "powershell": + return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; + case "fish": + return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; + case "posix": + return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; + } +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; + const completionShell = resolveCompletionShell( + yield* HostProcessPlatform, + yield* HostProcessEnvironment, + ); + + /** + * Watches the setup terminal for the completion sentinel. Terminal output is + * a byte stream, so partial lines are buffered until a newline. The + * subscription is torn down once the sentinel, an exit, or a close arrives. + */ + const observeTerminalCompletion = (input: { + readonly threadId: string; + readonly terminalId: string; + /** Per-run sentinel, so only this run's wrapper can settle completion. */ + readonly sentinel: string; + readonly sentinelPattern: RegExp; + /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ + readonly echoedWrapperLines: ReadonlyArray; + readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; + }) => + Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const done = yield* Deferred.make(); + let lineBuffer = ""; + let settled = false; + + const settle = (exitCode: number | null) => + Effect.suspend(() => { + if (settled) return Effect.void; + settled = true; + return Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => + Deferred.succeed(done, { exitCode, durationMs: nowMs - startedAtMs }), + ), + Effect.asVoid, + ); + }); + + const handleLine = (rawLine: string) => + Effect.suspend(() => { + const sentinel = input.sentinelPattern.exec(rawLine); + if (sentinel) { + const parsed = Number(sentinel[1]); + return settle(Number.isFinite(parsed) ? parsed : null); + } + const cleaned = stripTerminalControl(rawLine).trimEnd(); + if ( + cleaned.length === 0 || + cleaned.includes(input.sentinel) || + input.echoedWrapperLines.some((echoed) => cleaned.endsWith(echoed)) || + input.onOutputLine === undefined + ) { + return Effect.void; + } + return input.onOutputLine(cleaned.slice(0, OUTPUT_LINE_MAX_LENGTH)); + }); + + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + if (event.type === "output") { + lineBuffer += event.data; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() ?? ""; + // A script that never prints a newline must not grow this forever. + // The sentinel is always on its own line, so keeping the tail is safe. + if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { + lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); + } + return Effect.forEach(lines, handleLine, { discard: true }); + } + if (event.type === "exited" || event.type === "closed") { + return settle(null); + } + return Effect.void; + }); + + const completion = Deferred.await(done).pipe( + Effect.ensuring(Effect.sync(() => unsubscribe())), + ); + return { completion, unsubscribe }; + }); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -154,6 +342,16 @@ export const make = Effect.gen(function* () { project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); + const observe = input.observeCompletion; + const completionToken = observe ? NodeCrypto.randomUUID().replaceAll("-", "") : null; + const commandLine = + observe && completionToken + ? wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ) + : script.command; yield* terminalManager .open({ @@ -173,11 +371,24 @@ export const make = Effect.gen(function* () { }), ), ); + // Subscribe before writing so the sentinel cannot race past the listener. + const observed = + observe && completionToken + ? yield* observeTerminalCompletion({ + threadId: input.threadId, + terminalId, + sentinel: completionSentinel(completionToken), + sentinelPattern: completionSentinelPattern(completionToken), + echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + onOutputLine: observe.onOutputLine, + }) + : undefined; + yield* terminalManager .write({ threadId: input.threadId, terminalId, - data: `${script.command}\r`, + data: `${commandLine}\r`, }) .pipe( Effect.mapError( @@ -188,14 +399,18 @@ export const make = Effect.gen(function* () { cause, }), ), + // Nothing will ever settle the completion if the command never ran. + Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); return { status: "started", scriptId: script.id, scriptName: script.name, + scriptCommand: script.command, terminalId, cwd, + ...(observed ? { completion: observed.completion } : {}), } as const; }); diff --git a/apps/server/src/project/WorktreeSetupTracker.test.ts b/apps/server/src/project/WorktreeSetupTracker.test.ts new file mode 100644 index 000000000000..16e7b6aa64c2 --- /dev/null +++ b/apps/server/src/project/WorktreeSetupTracker.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts"; + +const threadId = ThreadId.make("thread-1"); + +describe("WorktreeSetupTracker", () => { + it.effect("records stage transitions, checkout progress, and the final phase", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: "feature", + baseRef: "main", + stages: ["checkout", "fetch", "agent"], + fiber: null, + }); + + const initial = yield* tracker.get(threadId); + // Stages are reordered into the canonical setup order. + expect(initial?.stages.map((stage) => stage.id)).toEqual(["fetch", "checkout", "agent"]); + expect(initial?.phase).toBe("running"); + + yield* tracker.stageStatus(threadId, "fetch", "running"); + yield* tracker.stageStatus(threadId, "fetch", "done", "origin/main at abc1234"); + yield* tracker.stageStatus(threadId, "checkout", "running"); + yield* tracker.stage(threadId, "checkout", { percent: 42, detail: "42 / 100 files" }); + yield* tracker.finish(threadId, "failed", "boom"); + + const final = yield* tracker.get(threadId); + expect(final?.phase).toBe("failed"); + expect(final?.error).toBe("boom"); + const [fetch, checkout, agent] = final?.stages ?? []; + expect(fetch).toMatchObject({ status: "done", detail: "origin/main at abc1234" }); + expect(fetch?.startedAt).not.toBeNull(); + expect(fetch?.endedAt).not.toBeNull(); + // A stage still running when the setup fails is marked failed. + expect(checkout).toMatchObject({ status: "failed", percent: 42 }); + expect(agent?.status).toBe("pending"); + expect(final?.sequence).toBeGreaterThan(initial?.sequence ?? 0); + }), + ); + + it.effect("stream emits the current snapshot first and then only newer ones", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.sequence === 2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.stageStatus(threadId, "agent", "running"); + yield* tracker.appendTail(threadId, "agent", "line 1"); + + const snapshots = yield* Fiber.join(collected); + const sequences = snapshots.map((snapshot) => snapshot?.sequence ?? -1); + expect(sequences.at(-1)).toBe(2); + // Delivery is latest-value per subscriber, so intermediates may be + // skipped but never delivered out of order. + expect(sequences).toEqual([...sequences].toSorted((a, b) => a - b)); + expect(snapshots.at(-1)?.stages[0]?.tail).toEqual(["line 1"]); + }), + ); + + it.effect("stream never steps back behind the snapshot it started from", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.stageStatus(threadId, "agent", "running"); + yield* tracker.stageStatus(threadId, "agent", "done"); + + // A late subscriber starts at sequence 2 and must never see 0 or 1. + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.phase === "done"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.finish(threadId, "done"); + + const snapshots = yield* Fiber.join(collected); + expect(snapshots.length).toBeGreaterThan(0); + expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true); + expect(snapshots.at(-1)?.phase).toBe("done"); + }), + ); + + it.effect("a new setup on the same thread keeps sequences increasing", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: "first", + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.finish(threadId, "failed", "boom"); + const failedSequence = (yield* tracker.get(threadId))?.sequence ?? -1; + + // A stream opened on the failed setup must still receive the next one. + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.branch === "second"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.begin({ + threadId, + branch: "second", + baseRef: null, + stages: ["agent"], + fiber: null, + }); + + const snapshots = yield* Fiber.join(collected); + const last = snapshots.at(-1); + expect(last?.phase).toBe("running"); + expect(last?.sequence).toBeGreaterThan(failedSequence); + }), + ); + + it.effect("finished setups are dropped after the retention window", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.finish(threadId, "done"); + expect((yield* tracker.get(threadId))?.phase).toBe("done"); + + yield* TestClock.adjust(Duration.seconds(31)); + expect(yield* tracker.get(threadId)).toBeNull(); + }), + ); + + it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + const started = yield* Deferred.make(); + const fiber = yield* Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); + + expect(yield* tracker.cancel(threadId)).toBe(true); + // cancel returns only after the bootstrap fiber has unwound. + const exit = yield* Fiber.await(fiber); + expect(Exit.hasInterrupts(exit)).toBe(true); + + yield* tracker.finish(threadId, "cancelled"); + expect(yield* tracker.cancel(threadId)).toBe(false); + expect(yield* tracker.cancel(ThreadId.make("unknown"))).toBe(false); + }), + ); + + it.effect("markUncancellable makes a later cancel a no-op while the setup keeps running", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + const fiber = yield* Effect.forkChild(Effect.never); + yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); + + yield* tracker.markUncancellable(threadId); + expect(yield* tracker.cancel(threadId)).toBe(false); + expect((yield* tracker.get(threadId))?.phase).toBe("running"); + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("clamps free text to the contract limits before publishing", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["checkout", "setup-script"], + fiber: null, + }); + const long = "x".repeat(2_000); + + yield* tracker.stageStatus(threadId, "checkout", "done", long); + yield* tracker.stage(threadId, "setup-script", { detail: long }); + yield* tracker.appendTail(threadId, "setup-script", long); + yield* tracker.finish(threadId, "failed", long); + + const snapshot = yield* tracker.get(threadId); + expect(snapshot?.stages[0]?.detail?.length).toBe(200); + expect(snapshot?.stages[1]?.detail?.length).toBe(200); + expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400); + expect(snapshot?.error?.length).toBe(1000); + // The wire schema must accept what the tracker publishes. + expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true); + }), + ); +}); diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts new file mode 100644 index 000000000000..41c84f2d649d --- /dev/null +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -0,0 +1,364 @@ +import type { + ThreadId, + WorktreeSetupSnapshot, + WorktreeSetupStage, + WorktreeSetupStageId, + WorktreeSetupStageStatus, +} from "@t3tools/contracts"; +import { + WORKTREE_SETUP_DETAIL_MAX_LENGTH, + WORKTREE_SETUP_ERROR_MAX_LENGTH, + WORKTREE_SETUP_STAGE_ORDER, + WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +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 Stream from "effect/Stream"; + +/** + * Tracks the live stages of a bootstrap worktree setup per thread so clients + * can render a progress card while the first turn is still being prepared. + * + * State is memory only. It exists from the first `begin` until the turn starts + * or the setup fails, plus a short grace window so a client that subscribes + * late still sees the final state. Nothing here is persisted or event-sourced: + * the durable record of a setup is the thread's worktree path and the setup + * script activities, both of which already exist. + */ +export class WorktreeSetupTracker extends Context.Service< + WorktreeSetupTracker, + { + /** Creates a fresh running snapshot for the thread, replacing any prior one. */ + readonly begin: (input: { + readonly threadId: ThreadId; + readonly branch: string | null; + readonly baseRef: string | null; + readonly stages: ReadonlyArray; + /** Interrupting this fiber cancels the bootstrap. */ + readonly fiber: Fiber.Fiber | null; + }) => Effect.Effect; + readonly update: ( + threadId: ThreadId, + mutate: (snapshot: WorktreeSetupSnapshot) => WorktreeSetupSnapshot, + ) => Effect.Effect; + readonly stage: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + patch: Partial>, + ) => Effect.Effect; + readonly stageStatus: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + status: WorktreeSetupStageStatus, + detail?: string | null, + ) => Effect.Effect; + readonly appendTail: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + line: string, + ) => Effect.Effect; + readonly finish: ( + threadId: ThreadId, + phase: "done" | "failed" | "cancelled", + error?: string | null, + ) => Effect.Effect; + /** + * Drops the cancel handle. Called right before the turn is dispatched so a + * late cancel cannot roll back a thread whose agent has already started. + */ + readonly markUncancellable: (threadId: ThreadId) => Effect.Effect; + /** + * Interrupts the running bootstrap and waits for it to unwind, so the + * caller's dispatch has already failed and rolled back when this returns. + * Returns false when nothing is running or the setup is past cancellation. + */ + readonly cancel: (threadId: ThreadId) => Effect.Effect; + readonly get: (threadId: ThreadId) => Effect.Effect; + /** Emits the current snapshot (or null) first, then every change until unsubscribed. */ + readonly stream: (threadId: ThreadId) => Stream.Stream; + } +>()("t3/project/WorktreeSetupTracker") {} + +const TAIL_LINE_LIMIT = 4; + +/** Keeps free text inside the contract limit, ending in an ellipsis when cut. */ +function clampText(text: string, maxLength: number): string { + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`; +} + +const clampDetail = (detail: string | null): string | null => + detail === null ? null : clampText(detail, WORKTREE_SETUP_DETAIL_MAX_LENGTH); +/** Finished snapshots stay visible this long so a late subscriber sees the outcome. */ +const FINISHED_RETENTION = "30 seconds"; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +interface TrackedSetup { + readonly snapshot: WorktreeSetupSnapshot; + readonly fiber: Fiber.Fiber | null; +} + +function emptyStage(id: WorktreeSetupStageId): WorktreeSetupStage { + return { + id, + status: "pending", + startedAt: null, + endedAt: null, + percent: null, + detail: null, + tail: [], + }; +} + +export const make = Effect.gen(function* () { + const setups = yield* Ref.make(new Map()); + const changes = yield* PubSub.unbounded<{ + readonly threadId: ThreadId; + readonly snapshot: WorktreeSetupSnapshot | null; + }>(); + const retentionFibers = new Map>(); + // Sequences keep increasing across setups of the same thread so a stream + // opened during a previous setup still accepts the next one's first snapshot. + const lastSequenceByThread = new Map(); + + const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => + PubSub.publish(changes, { threadId, snapshot }).pipe(Effect.asVoid); + + const modify = ( + threadId: ThreadId, + mutate: (tracked: TrackedSetup) => TrackedSetup, + ): Effect.Effect => + Ref.modify(setups, (current) => { + const existing = current.get(threadId); + if (!existing) return [null, current] as const; + const nextTracked = mutate(existing); + const nextSnapshot = { + ...nextTracked.snapshot, + sequence: existing.snapshot.sequence + 1, + }; + lastSequenceByThread.set(threadId, nextSnapshot.sequence); + const next = new Map(current); + next.set(threadId, { ...nextTracked, snapshot: nextSnapshot }); + return [nextSnapshot, next] as const; + }).pipe(Effect.tap((snapshot) => (snapshot ? publish(threadId, snapshot) : Effect.void))); + + const clearRetention = (threadId: ThreadId) => { + const fiber = retentionFibers.get(threadId); + retentionFibers.delete(threadId); + return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void; + }; + + const remove = (threadId: ThreadId) => + Ref.update(setups, (current) => { + if (!current.has(threadId)) return current; + const next = new Map(current); + next.delete(threadId); + return next; + }).pipe( + Effect.andThen(publish(threadId, null)), + // A subscriber that outlives retention sees `null` here and accepts any + // sequence after it, so the counter can start over for this thread. + Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), + ); + + const begin: WorktreeSetupTracker["Service"]["begin"] = (input) => + Effect.gen(function* () { + yield* clearRetention(input.threadId); + const startedAt = yield* nowIso; + const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id)); + const snapshot: WorktreeSetupSnapshot = { + threadId: input.threadId, + phase: "running", + startedAt, + endedAt: null, + branch: input.branch, + baseRef: input.baseRef, + worktreePath: null, + setupScript: null, + stages: ordered.map(emptyStage), + error: null, + sequence: (lastSequenceByThread.get(input.threadId) ?? -1) + 1, + }; + lastSequenceByThread.set(input.threadId, snapshot.sequence); + yield* Ref.update(setups, (current) => { + const next = new Map(current); + next.set(input.threadId, { snapshot, fiber: input.fiber }); + return next; + }); + yield* publish(input.threadId, snapshot); + }); + + const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) => + modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe( + Effect.asVoid, + ); + + const stage: WorktreeSetupTracker["Service"]["stage"] = (threadId, stageId, patch) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => + entry.id === stageId + ? { + ...entry, + ...patch, + ...(patch.detail === undefined ? {} : { detail: clampDetail(patch.detail ?? null) }), + } + : entry, + ), + })); + + const stageStatus: WorktreeSetupTracker["Service"]["stageStatus"] = ( + threadId, + stageId, + status, + detail, + ) => + nowIso.pipe( + Effect.flatMap((at) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => { + if (entry.id !== stageId) return entry; + const startedAt = entry.startedAt ?? (status === "pending" ? null : at); + const endedAt = + status === "running" || status === "pending" ? null : (entry.endedAt ?? at); + return { + ...entry, + status, + startedAt, + endedAt, + ...(detail === undefined ? {} : { detail: clampDetail(detail ?? null) }), + }; + }), + })), + ), + ); + + const appendTail: WorktreeSetupTracker["Service"]["appendTail"] = (threadId, stageId, line) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => + entry.id === stageId + ? { + ...entry, + tail: [...entry.tail, clampText(line, WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH)].slice( + -TAIL_LINE_LIMIT, + ), + } + : entry, + ), + })); + + const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) => + Effect.gen(function* () { + const endedAt = yield* nowIso; + const snapshot = yield* modify(threadId, (tracked) => ({ + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + error: + error === undefined || error === null + ? null + : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), + stages: tracked.snapshot.stages.map((entry) => + entry.status === "running" + ? { + ...entry, + status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", + endedAt, + } + : entry, + ), + }, + })); + if (!snapshot) return; + yield* clearRetention(threadId); + const fiber = yield* remove(threadId).pipe( + Effect.delay(FINISHED_RETENTION), + Effect.ensuring( + Effect.sync(() => { + // Only drop our own entry: a newer setup may have replaced it. + if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(threadId, fiber); + }); + + const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) => + Ref.update(setups, (current) => { + const existing = current.get(threadId); + if (!existing || existing.fiber === null) return current; + const next = new Map(current); + next.set(threadId, { ...existing, fiber: null }); + return next; + }); + + const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) => + Effect.gen(function* () { + const current = yield* Ref.get(setups); + const tracked = current.get(threadId); + if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { + return false; + } + yield* Fiber.interrupt(tracked.fiber); + return true; + }); + + const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => + Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); + + /** + * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only + * ever holds the newest snapshot, so a chatty setup script cannot grow the + * server heap. Snapshots are whole states, so skipping intermediates is safe. + */ + const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => + Stream.callback( + (mailbox) => + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const initial = yield* get(threadId); + // Changes published between subscribing and reading `initial` are + // already folded into it. Drop them so the client never steps back. + let lastSequence = initial?.sequence ?? -1; + Queue.offerUnsafe(mailbox, initial); + yield* Stream.fromSubscription(subscription).pipe( + Stream.runForEach((change) => + Effect.sync(() => { + if (change.threadId !== threadId) return; + if (change.snapshot !== null && change.snapshot.sequence <= lastSequence) return; + lastSequence = change.snapshot?.sequence ?? -1; + Queue.offerUnsafe(mailbox, change.snapshot); + }), + ), + Effect.forkScoped, + ); + }), + { bufferSize: 1, strategy: "sliding" }, + ); + + return WorktreeSetupTracker.of({ + begin, + update, + stage, + stageStatus, + appendTail, + finish, + markUncancellable, + cancel, + get, + stream, + }); +}); + +export const layer = Layer.effect(WorktreeSetupTracker, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index af934c59d480..3eb40a1b9906 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 WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; @@ -924,9 +925,12 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(TerminalManager.TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + ...options?.layers?.terminalManager, + }), + WorktreeSetupTracker.layer, + ), ), Layer.provide( Layer.mergeAll( @@ -10558,6 +10562,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: "started" as const, scriptId: "setup", scriptName: "Setup", + scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", }), @@ -10673,12 +10678,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "resolve-remote-commit", "create-worktree", ]); - assert.deepEqual(runForThread.mock.calls[0]?.[0], { - threadId: ThreadId.make("thread-bootstrap"), - projectId: defaultProjectId, - projectCwd: "/tmp/project", - worktreePath: "/tmp/bootstrap-worktree", - }); + const runForThreadInput = runForThread.mock.calls[0]?.[0]; + assert.deepEqual( + runForThreadInput && { + threadId: runForThreadInput.threadId, + projectId: runForThreadInput.projectId, + projectCwd: runForThreadInput.projectCwd, + worktreePath: runForThreadInput.worktreePath, + }, + { + threadId: ThreadId.make("thread-bootstrap"), + projectId: defaultProjectId, + projectCwd: "/tmp/project", + worktreePath: "/tmp/bootstrap-worktree", + }, + ); + // Worktree bootstraps observe script completion so the setup card can show the exit code. + assert.isDefined(runForThreadInput?.observeCompletion); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); const setupActivities = dispatchedCommands.filter( @@ -11111,6 +11127,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: "started" as const, scriptId: "setup", scriptName: "Setup", + scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6148bed9bdd5..189d62dd8362 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -110,6 +110,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; @@ -332,6 +333,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), + Layer.provideMerge(WorktreeSetupTracker.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 9b25e915973c..4d63a447d63f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -102,6 +102,36 @@ export interface ExecuteGitProgress { }) => Effect.Effect; } +/** + * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)` + * to stderr during checkout, and `Submodule path 'x': checked out` during + * submodule init. The tracker uses these to drive the worktree setup card. + */ +export interface CreateWorktreeProgress { + /** + * Fires once `git worktree add` has created and registered the directory, + * before the (possibly long) submodule step. Git refuses an existing path, + * so a path reported here belongs to this call and is safe to remove on + * cancel. + */ + readonly onWorktreeClaimed?: (path: string) => Effect.Effect; + readonly onCheckoutProgress?: (input: { + percent: number; + completed: number; + total: number; + }) => Effect.Effect; + readonly onSubmodulesStarted?: () => Effect.Effect; + readonly onSubmoduleLine?: (line: string) => Effect.Effect; + readonly onSubmodulesFinished?: (input: { + ok: boolean; + detail: string | null; + }) => Effect.Effect; +} + +export interface CreateWorktreeOptions { + readonly progress?: CreateWorktreeProgress; +} + export interface GitCommitProgress { readonly onOutputLine?: (input: { stream: "stdout" | "stderr"; @@ -280,6 +310,7 @@ export class GitVcsDriver extends Context.Service< readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, + options?: CreateWorktreeOptions, ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4f1f0204a4ed..bc8a12700997 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -20,7 +20,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + makeGitVcsDriverCore, + parseGitCheckoutProgressLine, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -1553,6 +1557,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it("parses checkout progress lines from git's stderr", () => { + assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { + percent: 78, + completed: 2104, + total: 2700, + }); + // Progress lines arrive carriage-return separated and end with a done marker. + assert.deepStrictEqual( + parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."), + { percent: 100, completed: 2700, total: 2700 }, + ); + assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null); + }); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", @@ -1668,6 +1686,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports checkout progress while creating a worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + for (let index = 0; index < 5; index += 1) { + yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "progress-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const seen = yield* Ref.make>( + [], + ); + const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); + + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, + { + progress: { + onWorktreeClaimed: (path) => + Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), + onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), + }, + }, + ); + // Claimed only once git has registered the directory. + assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); + + // Git separates live progress updates with `\r`, so the driver must + // surface every intermediate percentage, not just the final line. + const updates = yield* Ref.get(seen); + assert.isAbove(updates.length, 1); + assert.equal(updates.at(-1)?.percent, 100); + assert.equal(updates.at(-1)?.total, 6); + const completed = updates.map((update) => update.completed); + assert.deepEqual( + completed, + completed.toSorted((a, b) => a - b), + ); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d371e63617f6..86a3e2ebd812 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -641,6 +641,25 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); +const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/; + +/** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */ +export function parseGitCheckoutProgressLine( + line: string, +): { percent: number; completed: number; total: number } | null { + const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line); + if (!match) return null; + const percent = Number(match[1]); + const completed = Number(match[2]); + const total = Number(match[3]); + if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) { + return null; + } + return { percent: Math.max(0, Math.min(100, percent)), completed, total }; +} + +const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/; + const collectOutput = Effect.fnUntraced(function* ( input: Pick, stream: Stream.Stream, @@ -654,19 +673,21 @@ const collectOutput = Effect.fnUntraced(function* ( let lineBuffer = ""; let truncated = false; + // 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. const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { - let newlineIndex = lineBuffer.indexOf("\n"); - while (newlineIndex >= 0) { - const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); - lineBuffer = lineBuffer.slice(newlineIndex + 1); + let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); + while (separator) { + const line = lineBuffer.slice(0, separator.index); + lineBuffer = lineBuffer.slice(separator.index + separator[0].length); if (line.length > 0 && onLine) { yield* onLine(line); } - newlineIndex = lineBuffer.indexOf("\n"); + separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); } if (flush) { - const trailing = lineBuffer.replace(/\r$/, ""); + const trailing = lineBuffer; lineBuffer = ""; if (trailing.length > 0 && onLine) { yield* onLine(trailing); @@ -3010,7 +3031,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", - )(function* (input) { + )(function* (input, options) { const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); @@ -3018,12 +3039,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; + const progress = options?.progress; + const onCheckoutProgress = progress?.onCheckoutProgress; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorDetail: "git worktree add failed", timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, + }, + } + : {}), }); + if (progress?.onWorktreeClaimed) { + yield* progress.onWorktreeClaimed(worktreePath); + } + // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's @@ -3033,18 +3073,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); if (hasSubmodules) { - yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ - "submodule", - "update", - "--init", - "--recursive", - ]).pipe( - Effect.catch((cause) => - Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { - worktreePath, - cause, - }), - ), + if (progress?.onSubmodulesStarted) { + yield* progress.onSubmodulesStarted(); + } + const onSubmoduleLine = progress?.onSubmoduleLine; + yield* runGit( + "GitVcsDriver.createWorktree.updateSubmodules", + worktreePath, + ["submodule", "update", "--init", "--recursive"], + onSubmoduleLine + ? { + env: { LC_ALL: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}, + ).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }).pipe( + Effect.andThen( + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) + : Effect.void, + ), + ), + onSuccess: () => + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: true, detail: null }) + : Effect.void, + }), ); } @@ -3496,7 +3556,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, - createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + createWorktree: (input, options) => + withListRefsInvalidation(input.cwd, createWorktree(input, options)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), fetchPullRequestHeadCommit, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f960c488d8a..6ddd8c821c9f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -7,11 +7,13 @@ import * as Crypto from "effect/Crypto"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { @@ -129,6 +131,7 @@ 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 WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -597,6 +600,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -991,6 +995,9 @@ const makeWsRpcLayer = ( let targetProjectId = bootstrap?.createThread?.projectId; let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + // The setup script's terminal, once started. Cancel closes only this + // one so terminals the user opened meanwhile survive. + let setupTerminalId: string | null = null; const cleanupCreatedThread = () => createdThread @@ -1083,19 +1090,37 @@ const makeWsRpcLayer = ( ); }); + const tracked = bootstrap?.prepareWorktree !== undefined; + const threadId = command.threadId; + const track = (effect: Effect.Effect) => (tracked ? effect : Effect.void); + + // Runs the setup script and, for tracked bootstraps, waits for it to + // exit so the card can show the exit code and the agent stage never + // starts on a half-installed tree. Untracked callers keep the old + // fire-and-forget behavior. const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { + yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); return; } const worktreePath = targetWorktreePath; const requestedAt = yield* nowIso; - yield* projectSetupScriptRunner + yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "running")); + const setupResult = yield* projectSetupScriptRunner .runForThread({ - threadId: command.threadId, + threadId, ...(targetProjectId ? { projectId: targetProjectId } : {}), ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), worktreePath, + ...(tracked + ? { + observeCompletion: { + onOutputLine: (line) => + worktreeSetupTracker.appendTail(threadId, "setup-script", line), + }, + } + : {}), }) .pipe( Effect.matchEffect({ @@ -1104,21 +1129,71 @@ const makeWsRpcLayer = ( error, requestedAt, worktreePath, - }), + }).pipe( + Effect.andThen( + track( + worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + "failed to start", + ), + ), + ), + Effect.as(null), + ), onSuccess: (setupResult) => { if (setupResult.status !== "started") { - return Effect.void; + return track( + worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "skipped", + "no setup script", + ), + ).pipe(Effect.as(null)); } + setupTerminalId = setupResult.terminalId; return recordSetupScriptStarted({ requestedAt, worktreePath, scriptId: setupResult.scriptId, scriptName: setupResult.scriptName, terminalId: setupResult.terminalId, - }); + }).pipe( + Effect.andThen( + track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + setupScript: { + name: setupResult.scriptName, + command: setupResult.scriptCommand, + terminalId: setupResult.terminalId, + }, + })), + ), + ), + Effect.as(setupResult), + ); }, }), ); + if (!tracked || !setupResult?.completion) { + return; + } + // The setup script is best effort, like the untracked path: a + // failed install must not throw away the worktree the user just + // waited for. The card keeps the failed stage and its terminal. + const completion = yield* setupResult.completion; + if (completion.exitCode === 0) { + yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); + return; + } + const detail = + completion.exitCode === null + ? "terminal closed before the script finished" + : `exit ${completion.exitCode}`; + yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail); }); const bootstrapProgram = Effect.gen(function* () { @@ -1138,6 +1213,7 @@ const makeWsRpcLayer = ( remoteName: "origin", })); if (startFromOrigin) { + yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "running")); yield* gitWorkflow.fetchRemote({ cwd: prepareWorktree.projectCwd, remoteName: "origin", @@ -1154,7 +1230,26 @@ const makeWsRpcLayer = ( fallbackRemoteName: "origin", }); worktreeBaseRef = resolvedRemoteBase.commitSha; + yield* track( + worktreeSetupTracker.stageStatus( + threadId, + "fetch", + "done", + `origin/${prepareWorktree.baseBranch} at ${resolvedRemoteBase.commitSha.slice(0, 7)}`, + ), + ); + } else { + yield* track( + worktreeSetupTracker.stageStatus( + threadId, + "fetch", + "warning", + `origin/${prepareWorktree.baseBranch} not found, using local branch`, + ), + ); } + } else { + yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "skipped")); } const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch; @@ -1163,6 +1258,27 @@ const makeWsRpcLayer = ( refName: resolvedWorktreeBaseRef, }); worktreeBaseRef = resolvedWorktreeBaseRef; + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + baseRef: resolvedWorktreeBaseRef, + })), + ); + } + + if (prepareWorktree && !shouldPrepareWorktree) { + // Not a git repo, or the base has no commit: the thread runs in + // the project checkout instead. The card says so and moves on. + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((stage) => + stage.id === "fetch" || stage.id === "checkout" || stage.id === "submodules" + ? { ...stage, status: "skipped", detail: "using project checkout" } + : stage, + ), + })), + ); } if (bootstrap?.createThread) { @@ -1188,18 +1304,92 @@ const makeWsRpcLayer = ( } if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { - const worktree = yield* gitWorkflow.createWorktree({ - cwd: prepareWorktree.projectCwd, - refName: worktreeBaseRef, - newRefName: prepareWorktree.branch, - baseRefName: prepareWorktree.baseBranch, - path: null, - }); + yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); + let checkoutTotal: number | null = null; + const worktree = yield* gitWorkflow.createWorktree( + { + cwd: prepareWorktree.projectCwd, + refName: worktreeBaseRef, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, + path: null, + }, + { + progress: { + // Git has registered the directory at this point, so a + // cancel during the submodule step can still remove it. + onWorktreeClaimed: (path) => + Effect.sync(() => { + targetWorktreePath = path; + }), + onCheckoutProgress: ({ percent, completed, total }) => { + checkoutTotal = total; + return worktreeSetupTracker.stage(threadId, "checkout", { + percent, + detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`, + }); + }, + onSubmodulesStarted: () => + worktreeSetupTracker + .stageStatus( + threadId, + "checkout", + "done", + checkoutTotal === null + ? null + : `${checkoutTotal.toLocaleString("en-US")} files`, + ) + .pipe( + Effect.andThen( + worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), + ), + ), + onSubmoduleLine: (line) => { + const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; + return submodulePath === undefined + ? Effect.void + : worktreeSetupTracker.stage(threadId, "submodules", { + detail: submodulePath, + }); + }, + onSubmodulesFinished: ({ ok, detail }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), + ), + }, + }, + ); + const checkoutEndedAt = yield* nowIso; + yield* worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath: worktree.worktree.path, + stages: snapshot.stages.map((stage) => { + if (stage.id === "checkout" && stage.status === "running") { + return { + ...stage, + status: "done", + percent: 100, + endedAt: checkoutEndedAt, + detail: + checkoutTotal === null + ? stage.detail + : `${checkoutTotal.toLocaleString("en-US")} files`, + }; + } + if (stage.id === "submodules" && stage.status === "pending") { + return { ...stage, status: "skipped", detail: "none" }; + } + return stage; + }), + })); targetWorktreePath = worktree.worktree.path; yield* dispatchFromClient({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, + threadId, branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); @@ -1208,36 +1398,114 @@ const makeWsRpcLayer = ( yield* runSetupProgram(); - return yield* dispatchFromClient(finalTurnStartCommand); + yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running")); + // Past this point a cancel would roll back a thread whose turn has + // started. Drop the cancel handle and make the handoff atomic. + yield* track(worktreeSetupTracker.markUncancellable(threadId)); + const started = yield* Effect.uninterruptible( + dispatchFromClient(finalTurnStartCommand), + ); + yield* track( + worktreeSetupTracker + .stageStatus(threadId, "agent", "done") + .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))), + ); + return started; }); - return yield* bootstrapProgram.pipe( + const runBootstrap = tracked + ? Effect.gen(function* () { + const fiber = yield* Effect.forkChild(bootstrapProgram); + yield* worktreeSetupTracker.begin({ + threadId, + branch: bootstrap?.prepareWorktree?.branch ?? null, + baseRef: bootstrap?.prepareWorktree?.baseBranch ?? null, + stages: ["fetch", "checkout", "submodules", "setup-script", "agent"], + fiber, + }); + return yield* Fiber.join(fiber); + }) + : bootstrapProgram; + + const cleanupAndFail = ( + cause: Cause.Cause, + dispatchError: OrchestrationDispatchCommandError, + ) => + Effect.uninterruptible(cleanupCreatedThread()).pipe( + Effect.matchCauseEffect({ + onFailure: (cleanupCause) => + Effect.logWarning("bootstrap thread cleanup failed", { + threadId, + detail: Cause.pretty(cleanupCause), + }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), + onSuccess: (threadDeleted) => + Effect.fail( + threadDeleted + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, + ), + }), + ); + + return yield* runBootstrap.pipe( Effect.catchCause((cause) => { const dispatchError = toBootstrapDispatchCommandCauseError(cause); if (Cause.hasInterruptsOnly(cause)) { - return Effect.fail(dispatchError); + // A user cancel interrupts the forked bootstrap fiber. The + // created thread is rolled back like any other failure so the + // draft returns to the composer. The setup terminal is closed + // first so a still-running script cannot hold files open in + // the worktree while git removes it. Closing kills the + // process asynchronously, so the removal retries briefly. + const closeSetupTerminal = setupTerminalId + ? terminalManager.close({ + threadId, + terminalId: setupTerminalId, + deleteHistory: true, + }) + : Effect.void; + const removeCreatedWorktree = + tracked && targetWorktreePath && bootstrap?.prepareWorktree + ? closeSetupTerminal.pipe( + Effect.ignoreCause({ log: true }), + Effect.andThen( + gitWorkflow + .removeWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + path: targetWorktreePath, + force: true, + }) + .pipe( + Effect.retry({ times: 4, schedule: Schedule.spaced("500 millis") }), + ), + ), + Effect.ignoreCause({ log: true }), + Effect.uninterruptible, + ) + : Effect.void; + return track(worktreeSetupTracker.finish(threadId, "cancelled")).pipe( + Effect.andThen(removeCreatedWorktree), + Effect.andThen( + tracked + ? cleanupAndFail( + cause, + new OrchestrationDispatchCommandError({ + message: "Worktree setup cancelled.", + }), + ) + : Effect.fail(dispatchError), + ), + ); } - return Effect.uninterruptible(cleanupCreatedThread()).pipe( - Effect.matchCauseEffect({ - onFailure: (cleanupCause) => - Effect.logWarning("bootstrap thread cleanup failed", { - threadId: command.threadId, - detail: Cause.pretty(cleanupCause), - }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), - onSuccess: (threadDeleted) => - Effect.fail( - threadDeleted - ? new OrchestrationDispatchCommandError({ - message: dispatchError.message, - ...(dispatchError.cause !== undefined - ? { cause: dispatchError.cause } - : {}), - bootstrapThreadDisposition: "deleted", - }) - : dispatchError, - ), - }), - ); + return track( + worktreeSetupTracker.finish(threadId, "failed", dispatchError.message), + ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError))); }), ); }); @@ -2614,6 +2882,20 @@ const makeWsRpcLayer = ( "rpc.aggregate": "vcs", }, ), + [WS_METHODS.subscribeWorktreeSetup]: (input) => + observeRpcStream( + WS_METHODS.subscribeWorktreeSetup, + worktreeSetupTracker.stream(input.threadId), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.worktreeSetupCancel]: (input) => + observeRpcEffect( + WS_METHODS.worktreeSetupCancel, + worktreeSetupTracker + .cancel(input.threadId) + .pipe(Effect.map((cancelled) => ({ cancelled }))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index be279f220e7e..6f2a9e1199f8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -597,6 +597,30 @@ describe("draft hero submission transition", () => { ).toBe(false); }); + it("leaves the hero layout while a worktree setup card is on the timeline", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: true, + hasTimelineEntries: false, + isWorking: false, + draftHeroDockRequested: false, + backgroundSubmissionPending: false, + hasWorktreeSetupCard: true, + }), + ).toBe(false); + // A background submission normally pins the hero, but never over the card. + expect( + resolveDraftHeroState({ + isLocalDraftThread: true, + hasTimelineEntries: false, + isWorking: false, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + hasWorktreeSetupCard: true, + }), + ).toBe(false); + }); + it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ed1422a71109..eae137201d37 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -257,7 +257,12 @@ export function resolveDraftHeroState(input: { isWorking: boolean; draftHeroDockRequested: boolean; backgroundSubmissionPending: boolean; + /** A worktree setup card is on the timeline, so the timeline must stay visible. */ + hasWorktreeSetupCard?: boolean; }): boolean { + if (input.hasWorktreeSetupCard) { + return false; + } if (input.backgroundSubmissionPending) { return true; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9e11132269e6..574ed0f372a1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -43,6 +43,7 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; @@ -1645,6 +1646,27 @@ export default function ChatView(props: ChatViewProps) { return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + // The bootstrap worktree setup this composer last dispatched. Set when a + // worktree send starts and cleared once the turn starts or the next send + // begins, so a failed or cancelled card stays until the user acts. + const [worktreeSetupRef, setWorktreeSetupRef] = useState<{ + environmentId: EnvironmentId; + threadId: ThreadId; + ownerKey: string; + } | null>(null); + const [heldWorktreeSetup, setHeldWorktreeSetup] = useState(null); + // Set by "Work locally": the draft whose restored message should be resent + // once the cancelled dispatch has settled and the draft is in local mode. + // Keyed by draft id so a bootstrap rotating the thread id keeps it, while + // moving to another draft drops it without an effect. + const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState(null); + // The draft route reuses this component across drafts, so a resend recorded + // for one draft must not fire when the user comes back to it later. + useEffect(() => { + if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) { + setWorkLocallyResendDraftId(null); + } + }, [draftId, workLocallyResendDraftId]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> >({}); @@ -3337,6 +3359,64 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, ); const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); + // Live stages of a bootstrap worktree setup. The subscription follows the + // thread that was set up, not the route: a deleted bootstrap thread rotates + // the draft's thread id, and the failed card must survive that. + const worktreeSetupOwnerKey = draftId ?? routeThreadKey; + const worktreeSetupActive = + worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; + // The setup runs on the environment that received the dispatch, so both + // the subscription and cancel target that one even if the draft's machine + // picker changes underneath. + const worktreeSetupQuery = useEnvironmentQuery( + worktreeSetupActive + ? vcsEnvironment.worktreeSetup({ + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetupRef.threadId }, + }) + : null, + ); + const latestWorktreeSetup = worktreeSetupQuery.data; + useEffect(() => { + // The server drops finished snapshots after a grace period and emits null. + // Hold the last real snapshot so a settled card does not vanish. + if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); + }, [latestWorktreeSetup]); + const worktreeSetup = + worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId + ? heldWorktreeSetup + : null; + // A finished card is dropped once the agent's turn shows in the timeline: + // the card belongs to the send, and the agent takes over from there. + const worktreeSetupDoneAndTurnVisible = + worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null; + useEffect(() => { + if (!worktreeSetupDoneAndTurnVisible) return; + setWorktreeSetupRef(null); + setHeldWorktreeSetup(null); + }, [worktreeSetupDoneAndTurnVisible]); + const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { + reportFailure: false, + }); + const onCancelWorktreeSetup = useCallback(() => { + if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; + void cancelWorktreeSetup({ + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }); + }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); + // The setup terminal belongs to the thread that was set up. A failed + // bootstrap deletes that thread and closes its terminals, so only offer the + // terminal while the setup thread is still the active one. + const onOpenWorktreeSetupTerminal = useMemo(() => { + if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) { + return null; + } + const setupThreadRef = activeThreadRef; + return (terminalId: string) => { + storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true }); + }; + }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3346,6 +3426,9 @@ export default function ChatView(props: ChatViewProps) { isWorking, draftHeroDockRequested, backgroundSubmissionPending, + // A cancelled or failed setup card stays on the draft's timeline; the + // hero headline would paint over it. + hasWorktreeSetupCard: worktreeSetup !== null, }); const [ attachDraftHeroTransitionGroupRef, @@ -7279,6 +7362,11 @@ export default function ChatView(props: ChatViewProps) { preparingWorktree: Boolean(baseBranchForWorktree), submissionIntent: resolvedSubmissionIntent, }); + setWorktreeSetupRef( + baseBranchForWorktree + ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } + : null, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -8365,6 +8453,70 @@ export default function ChatView(props: ChatViewProps) { ], ); + // "Work locally" on the setup card: cancel the bootstrap and remember the + // draft. The cancelled dispatch deletes the half-made thread and puts the + // message back in the composer; the effect below then flips the draft to + // local mode and resends. The draft is a server thread for the whole + // setup (the bootstrap created it), so this keys off the route, not + // `isLocalDraftThread`. + const onWorktreeSetupWorkLocally = useCallback(() => { + if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running" || !draftId) { + return; + } + const target = { + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }; + void (async () => { + const result = await cancelWorktreeSetup(target); + if (result._tag !== "Success" || !result.value.cancelled) return; + setWorkLocallyResendDraftId(draftId); + })(); + }, [cancelWorktreeSetup, draftId, worktreeSetup, worktreeSetupRef]); + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + // Resend once the cancelled dispatch has settled and the composer is free. + // Every state that makes `onSend` bail and wait is part of the readiness + // check, so the flag survives a reconnect, a reverting checkpoint, or a + // feedback upload in between. What remains inside `onSend` are the checks + // that need the user to change something, and those should not auto retry. + const workLocallyResendReady = + workLocallyResendDraftId !== null && + workLocallyResendDraftId === draftId && + isLocalDraftThread && + !isSendBusy && + !isConnecting && + !isRevertingCheckpoint && + !threadDetailLoading && + clientSettingsHydrated && + !needsLoadBalancing && + !activeEnvironmentUnavailable && + !activePendingProgress && + !feedbackUploading; + useEffect(() => { + if ( + !workLocallyResendReady || + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) + ) { + return; + } + if (sendEnvMode !== "local") { + // The draft is back; switch it to the project checkout and let the next + // render resend. + setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false }); + return; + } + setWorkLocallyResendDraftId(null); + void onSendRef.current(); + }, [ + composerDraftTarget, + routeThreadKey, + sendEnvMode, + setDraftThreadContext, + workLocallyResendReady, + ]); + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { setPendingServerThreadStartFromOriginByThreadId((current) => @@ -8808,6 +8960,10 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} isCompacting={!paintOnlyDisplayedTimeline && isCompacting} activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} + worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} + onCancelWorktreeSetup={onCancelWorktreeSetup} + {...(draftId ? { onWorktreeSetupWorkLocally } : {})} + {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} listRef={legendListRef} timelineEntries={displayedTimeline.entries} latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5fbc686227d9..59b4a0c9856b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -10,6 +10,7 @@ import { ThreadId, TurnId, type OrchestrationThread, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { applyThreadDetailEvent, @@ -31,6 +32,7 @@ import { shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, type MessagesTimelineRowsProjection, + WORKTREE_SETUP_ROW_ID, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; import { @@ -1090,6 +1092,82 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("shows the worktree setup card instead of the working placeholder", () => { + const snapshot: WorktreeSetupSnapshot = { + threadId: ThreadId.make("thread-setup"), + phase: "running", + startedAt: "2026-01-01T00:00:00Z", + endedAt: null, + branch: "feature", + baseRef: "main", + worktreePath: null, + setupScript: null, + stages: [], + error: null, + sequence: 3, + }; + const userEntry = { + id: "user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "Build it", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + } as const; + const assistantEntry = { + id: "assistant-entry", + kind: "message", + createdAt: "2026-01-01T00:00:30Z", + message: { + id: "assistant-1" as never, + role: "assistant", + text: "On it", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: true, + }, + } as const; + const withoutMessages = deriveMessagesTimelineRows({ + timelineEntries: [], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(withoutMessages).toEqual([ + { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: "2026-01-01T00:00:00Z", + snapshot, + }, + ]); + + // Once the agent has replied the finished card stays under the send. + const withMessages = deriveMessagesTimelineRows({ + timelineEntries: [userEntry, assistantEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { ...snapshot, phase: "done" }, + }); + expect(withMessages.map((row) => row.kind)).toEqual([ + "message", + "worktree-setup", + "working", + "message", + ]); + }); + it("keeps context compaction visible outside folded work", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8dcb539fb024..3d7b1e12284e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -28,7 +28,12 @@ import { type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; +import { + type MessageId, + type OrchestrationLatestTurn, + type TurnId, + type WorktreeSetupSnapshot, +} from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; const TIMELINE_MINIMAP_ITEM_SPACING = 8; @@ -389,6 +394,12 @@ export type MessagesTimelineRow = kind: "thinking"; id: string; createdAt: string | null; + } + | { + kind: "worktree-setup"; + id: string; + createdAt: string | null; + snapshot: WorktreeSetupSnapshot; }; export interface StableMessagesTimelineRowsState { @@ -857,6 +868,8 @@ export function deriveMessagesTimelineRows(input: { supportsConversationRollback: boolean; /** Task ids of subagents still working, used by the active tool indicator. */ liveAgentTaskIds?: ReadonlySet | undefined; + /** Live bootstrap progress. Renders a stage card under the first user message. */ + worktreeSetup?: WorktreeSetupSnapshot | null; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -1247,6 +1260,30 @@ export function deriveMessagesTimelineRows(input: { }); } + // The setup card takes the place of the working and thinking placeholders + // while a worktree is being prepared. It stays after the setup settles so a + // failure and its actions remain visible until the thread state moves on. + if (input.worktreeSetup) { + const setupRow = { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: input.worktreeSetup.startedAt, + snapshot: input.worktreeSetup, + } as const; + // Sit directly under the first user message: a finished snapshot can + // outlive the first assistant reply, and it belongs to the send, not the + // end of the thread. + const firstUserRowIndex = nextRows.findIndex( + (row) => row.kind === "message" && row.message.role === "user", + ); + if (firstUserRowIndex >= 0) { + nextRows.splice(firstUserRowIndex + 1, 0, setupRow); + } else { + nextRows.push(setupRow); + } + return attachTrailingToolGroupsToAssistant(nextRows); + } + if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } @@ -1261,6 +1298,8 @@ export function deriveMessagesTimelineRows(input: { return attachTrailingToolGroupsToAssistant(nextRows); } +export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; + type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { @@ -1363,6 +1402,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "working": case "thinking": return a.createdAt === (b as typeof a).createdAt; + case "worktree-setup": + return a.snapshot === (b as typeof a).snapshot; case "assistant-meta": { const bm = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 084287080bd9..272ad320ad2b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -19,6 +19,7 @@ import { type ServerProviderSkill, type ToolActivityIcon, type TurnId, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -188,6 +189,7 @@ import { } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { ContextChipPopover as UserMessageContextPopover, ContextChipShell, @@ -275,6 +277,9 @@ interface TimelineRowSharedState { agentPanelModel: AgentPanelModel; expandedSpawnEntryIds: ReadonlySet; onOpenAgents: () => void; + onCancelWorktreeSetup: (() => void) | null; + onWorktreeSetupWorkLocally: (() => void) | null; + onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; } interface TimelineRowActivityState { @@ -374,6 +379,11 @@ interface MessagesTimelineProps { isPreparingWorktree?: boolean; isCompacting?: boolean; activeTurnStartedAt: string | null; + /** Live bootstrap progress for this thread, or null when none is tracked. */ + worktreeSetup?: WorktreeSetupSnapshot | null; + onCancelWorktreeSetup?: () => void; + onWorktreeSetupWorkLocally?: () => void; + onOpenWorktreeSetupTerminal?: (terminalId: string) => void; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; @@ -433,6 +443,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ citationHistoryLoading = false, onCiteAssistantText, isWorking, + worktreeSetup = null, + onCancelWorktreeSetup, + onWorktreeSetupWorkLocally, + onOpenWorktreeSetupTerminal, isPreparingWorktree = false, isCompacting = false, activeTurnStartedAt, @@ -692,6 +706,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, + worktreeSetup, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -713,6 +728,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, + worktreeSetup, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -905,6 +921,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL, expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, onOpenAgents, + onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, + onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, + onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, }), [ readyCitationRequest, @@ -932,6 +951,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ agentPanelModel, paintedExpandedSpawnEntryIds, onOpenAgents, + onCancelWorktreeSetup, + onWorktreeSetupWorkLocally, + onOpenWorktreeSetupTerminal, ], ); const activityState = useMemo( @@ -1386,7 +1408,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work" || row.kind === "work-live" || row.kind === "work-toggle" || - row.kind === "thinking" + row.kind === "thinking" || + row.kind === "worktree-setup" ? "pb-2" : "pb-4", (row.kind === "message" && row.message.role === "assistant") || @@ -1421,10 +1444,33 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} + {row.kind === "worktree-setup" ? : null}
      ); }); +function WorktreeSetupTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const terminalId = row.snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = useMemo( + () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), + [openTerminal, terminalId], + ); + return ( + + ); +} + function ContextCompactionTimelineRow({ row, }: { diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx new file mode 100644 index 000000000000..8b838fb81cc9 --- /dev/null +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -0,0 +1,296 @@ +import { + worktreeSetupStageLabel, + type WorktreeSetupSnapshot, + type WorktreeSetupStage, +} from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + CircleAlertIcon, + CircleIcon, + GitBranchIcon, + LaptopIcon, + MinusIcon, + TerminalIcon, + XIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Spinner } from "~/components/ui/spinner"; +import { cn } from "~/lib/utils"; + +interface WorktreeSetupCardProps { + snapshot: WorktreeSetupSnapshot; + /** Interrupts the server-side bootstrap. Hidden once the setup has settled. */ + onCancel: (() => void) | null; + /** Restarts the same message in the project checkout instead of a worktree. */ + onWorkLocally: (() => void) | null; + /** Reveals the setup script terminal tab. Null when no script ran. */ + onOpenTerminal: (() => void) | null; +} + +function stageElapsedMs(stage: WorktreeSetupStage, nowMs: number): number | null { + if (!stage.startedAt) return null; + const start = Date.parse(stage.startedAt); + const end = stage.endedAt ? Date.parse(stage.endedAt) : nowMs; + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return Math.max(0, end - start); +} + +/** + * Ticks once a second while any stage runs so elapsed labels stay live + * without pushing a React commit through the timeline for every second. + */ +function useNowWhile(active: boolean): number { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + const id = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(id); + }, [active]); + return nowMs; +} + +function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) { + const className = "size-4 shrink-0 stroke-[1.8]"; + switch (status) { + case "done": + return ; + case "running": + return ; + case "failed": + return ; + case "warning": + return ; + case "skipped": + return ; + case "pending": + return ; + } +} + +function stageRowClassName(status: WorktreeSetupStage["status"]): string { + switch (status) { + case "running": + return "text-foreground"; + case "failed": + return "text-destructive-foreground"; + case "warning": + return "text-warning-foreground"; + case "pending": + case "skipped": + return "text-secondary-label opacity-50"; + case "done": + return "text-secondary-label"; + } +} + +function StageRow({ + stage, + nowMs, + scriptName, +}: { + stage: WorktreeSetupStage; + nowMs: number; + scriptName: string | null; +}) { + const elapsed = stageElapsedMs(stage, nowMs); + const label = + stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id); + const showBar = stage.id === "checkout" && stage.status === "running" && stage.percent !== null; + const trailing = + stage.status === "pending" + ? null + : stage.status === "skipped" + ? (stage.detail ?? "skipped") + : stage.detail; + + return ( +
      + + + + {label} + + {showBar ? ( + <> + + + + {stage.percent}% + + ) : null} + {!showBar && trailing ? {trailing} : null} + {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( + {formatDuration(elapsed)} + ) : null} + +
      + ); +} + +function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: boolean }) { + if (lines.length === 0) return null; + return ( +
      +      {lines.join("\n")}
      +    
      + ); +} + +function headerLabel(snapshot: WorktreeSetupSnapshot): string { + switch (snapshot.phase) { + case "running": + return "Creating worktree"; + case "done": + return snapshot.stages.some((stage) => stage.status === "failed") + ? "Worktree ready, setup script failed" + : "Worktree ready"; + case "failed": + return "Worktree setup failed"; + case "cancelled": + return "Worktree setup cancelled"; + } +} + +export function WorktreeSetupCard({ + snapshot, + onCancel, + onWorkLocally, + onOpenTerminal, +}: WorktreeSetupCardProps) { + const running = snapshot.phase === "running"; + const nowMs = useNowWhile(running); + const [detailsOpen, setDetailsOpen] = useState(false); + const totalElapsed = (() => { + const start = Date.parse(snapshot.startedAt); + const end = snapshot.endedAt ? Date.parse(snapshot.endedAt) : nowMs; + return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; + })(); + const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); + const failed = snapshot.phase === "failed"; + const finishedWithFailedStage = + snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); + const headerClassName = failed + ? "text-destructive-foreground" + : finishedWithFailedStage + ? "text-warning-foreground" + : snapshot.phase === "cancelled" + ? "text-muted-foreground" + : "text-secondary-label"; + + return ( +
      +
      + + + + {headerLabel(snapshot)} + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
      + + {snapshot.stages.map((stage) => ( +
      + + {stage.id === "setup-script" && + (stage.status === "running" || stage.status === "failed") ? ( + + ) : null} +
      + ))} + + {failed && snapshot.error ? ( +

      {snapshot.error}

      + ) : null} + + {detailsOpen ? ( +
      + {snapshot.branch ? ( + <> +
      Branch
      +
      {snapshot.branch}
      + + ) : null} + {snapshot.baseRef ? ( + <> +
      Base
      +
      {snapshot.baseRef}
      + + ) : null} + {snapshot.worktreePath ? ( + <> +
      Path
      +
      {snapshot.worktreePath}
      + + ) : null} + {snapshot.setupScript ? ( + <> +
      Setup
      +
      {snapshot.setupScript.command}
      + + ) : null} +
      + ) : null} + +
      + + + {onOpenTerminal && setupStage && setupStage.status !== "pending" ? ( + + ) : null} + {onWorkLocally ? ( + + ) : null} + {onCancel && running ? ( + + ) : null} +
      +
      + ); +} diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index cfabaa00c0b7..af140ef2fcde 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.terminalAttach; export type EnvironmentStreamCommandRpcTag = diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 6c93e6204dc8..bedcb751216f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -15,7 +15,11 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; @@ -311,6 +315,18 @@ export function createVcsEnvironmentAtoms( concurrency: vcsCommandConcurrency, onSettled: invalidateRefs, }), + // Live stages of a bootstrap worktree setup. Null until the server begins + // tracking, then a snapshot per change, then null again after the setup + // is dropped. Short TTL so a closed thread releases its subscription. + worktreeSetup: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:worktree-setup", + tag: WS_METHODS.subscribeWorktreeSetup, + idleTtlMs: VCS_STATUS_IDLE_TTL_MS, + }), + cancelWorktreeSetup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:cancel-worktree-setup", + tag: WS_METHODS.worktreeSetupCancel, + }), removeWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:remove-worktree", tag: WS_METHODS.vcsRemoveWorktree, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 77d72144697a..007120dcba05 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -42,3 +42,4 @@ export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; export * from "./rpc.ts"; +export * from "./worktreeSetup.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7ec895137eb4..d792a31885c1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -46,6 +46,12 @@ import { AttachmentDeleteInput, AttachmentUploadSigningKeyError, } from "./assets.ts"; +import { + WorktreeSetupCancelInput, + WorktreeSetupCancelResult, + WorktreeSetupStreamEvent, + WorktreeSetupSubscribeInput, +} from "./worktreeSetup.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -404,6 +410,8 @@ export const WS_METHODS = { // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", + subscribeWorktreeSetup: "subscribeWorktreeSetup", + worktreeSetupCancel: "worktreeSetup.cancel", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", @@ -940,6 +948,19 @@ const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); +const WsSubscribeWorktreeSetupRpc = Rpc.make(WS_METHODS.subscribeWorktreeSetup, { + payload: WorktreeSetupSubscribeInput, + success: WorktreeSetupStreamEvent, + error: EnvironmentAuthorizationError, + stream: true, +}); + +const WsWorktreeSetupCancelRpc = Rpc.make(WS_METHODS.worktreeSetupCancel, { + payload: WorktreeSetupCancelInput, + success: WorktreeSetupCancelResult, + error: EnvironmentAuthorizationError, +}); + const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, @@ -1372,6 +1393,8 @@ export const WsRpcGroup = RpcGroup.make( WsAttachmentsDeleteRpc, WsProviderUploadFeedbackRpc, WsSubscribeVcsStatusRpc, + WsSubscribeWorktreeSetupRpc, + WsWorktreeSetupCancelRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, diff --git a/packages/contracts/src/worktreeSetup.ts b/packages/contracts/src/worktreeSetup.ts new file mode 100644 index 000000000000..9f3a9f33be23 --- /dev/null +++ b/packages/contracts/src/worktreeSetup.ts @@ -0,0 +1,114 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Live progress for a thread whose first turn is creating a worktree. The + * server keeps this in memory only; a client that reconnects mid-setup gets a + * fresh snapshot, and a finished setup is dropped once its turn starts. + */ +/** Producers clamp free text to these before publishing so encoding never fails. */ +export const WORKTREE_SETUP_DETAIL_MAX_LENGTH = 200; +export const WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH = 400; +export const WORKTREE_SETUP_ERROR_MAX_LENGTH = 1000; + +export const WorktreeSetupStageId = Schema.Literals([ + "fetch", + "checkout", + "submodules", + "setup-script", + "agent", +]); +export type WorktreeSetupStageId = typeof WorktreeSetupStageId.Type; + +export const WorktreeSetupStageStatus = Schema.Literals([ + "pending", + "running", + "done", + "skipped", + "warning", + "failed", +]); +export type WorktreeSetupStageStatus = typeof WorktreeSetupStageStatus.Type; + +export const WorktreeSetupStage = Schema.Struct({ + id: WorktreeSetupStageId, + status: WorktreeSetupStageStatus, + startedAt: Schema.NullOr(IsoDateTime), + endedAt: Schema.NullOr(IsoDateTime), + /** Only the checkout stage reports a real percentage, parsed from git's `Updating files` lines. */ + percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), + /** Short trailing text for the row: a file count, an exit code, a submodule name. */ + detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_DETAIL_MAX_LENGTH))), + /** Last few output lines from the setup script, ANSI stripped, newest last. */ + tail: Schema.Array(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH))), +}); +export type WorktreeSetupStage = typeof WorktreeSetupStage.Type; + +export const WorktreeSetupPhase = Schema.Literals(["running", "done", "failed", "cancelled"]); +export type WorktreeSetupPhase = typeof WorktreeSetupPhase.Type; + +export const WorktreeSetupSnapshot = Schema.Struct({ + threadId: ThreadId, + phase: WorktreeSetupPhase, + startedAt: IsoDateTime, + endedAt: Schema.NullOr(IsoDateTime), + branch: Schema.NullOr(TrimmedNonEmptyString), + baseRef: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + /** Display name plus command of the setup script from t3.json, when one runs. */ + setupScript: Schema.NullOr( + Schema.Struct({ + name: TrimmedNonEmptyString, + command: TrimmedNonEmptyString, + terminalId: TrimmedNonEmptyString, + }), + ), + stages: Schema.Array(WorktreeSetupStage), + /** Human readable reason when phase is failed. */ + error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_ERROR_MAX_LENGTH))), + sequence: NonNegativeInt, +}); +export type WorktreeSetupSnapshot = typeof WorktreeSetupSnapshot.Type; + +export const WorktreeSetupSubscribeInput = Schema.Struct({ + threadId: ThreadId, +}); +export type WorktreeSetupSubscribeInput = typeof WorktreeSetupSubscribeInput.Type; + +/** Null means no setup is tracked for that thread. Sent first, then after every change. */ +export const WorktreeSetupStreamEvent = Schema.NullOr(WorktreeSetupSnapshot); +export type WorktreeSetupStreamEvent = typeof WorktreeSetupStreamEvent.Type; + +export const WorktreeSetupCancelInput = Schema.Struct({ + threadId: ThreadId, +}); +export type WorktreeSetupCancelInput = typeof WorktreeSetupCancelInput.Type; + +export const WorktreeSetupCancelResult = Schema.Struct({ + cancelled: Schema.Boolean, +}); +export type WorktreeSetupCancelResult = typeof WorktreeSetupCancelResult.Type; + +export const WORKTREE_SETUP_STAGE_ORDER: ReadonlyArray = [ + "fetch", + "checkout", + "submodules", + "setup-script", + "agent", +]; + +export function worktreeSetupStageLabel(id: WorktreeSetupStageId): string { + switch (id) { + case "fetch": + return "Fetch base branch"; + case "checkout": + return "Check out files"; + case "submodules": + return "Init submodules"; + case "setup-script": + return "Run setup script"; + case "agent": + return "Start agent"; + } +} From 1ced38a6647b7f466b535b4b413ca4a74303da8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:13:34 -0700 Subject: [PATCH 21/27] fix(server): skip device hosts that resolve to the local machine (#11698) --- apps/server/src/device/DeviceService.ts | 16 +++- .../src/device/localSshDeviceHost.test.ts | 87 +++++++++++++++++++ apps/server/src/device/localSshDeviceHost.ts | 71 +++++++++++++++ apps/server/src/ws.ts | 18 +++- 4 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/device/localSshDeviceHost.test.ts create mode 100644 apps/server/src/device/localSshDeviceHost.ts diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index a60cda76c648..32de0b69a33f 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -58,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerSettings from "../serverSettings.ts"; +import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -892,11 +893,17 @@ export const make = Effect.gen(function* () { }; const probeContext = yield* Effect.context>>(); + const localTargetContext = + yield* Effect.context>>(); const service = yield* makeWithHosts( hosts, (host) => - SshDeviceHost.probe(host).pipe( - Effect.provide(probeContext), + Effect.gen(function* () { + if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) { + return yield* localHost.summary; + } + return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext)); + }).pipe( Effect.mapError( (error) => new DeviceOperationError({ @@ -911,8 +918,11 @@ export const make = Effect.gen(function* () { const hostContext = yield* Effect.context>>(); const configured = new Map(); - const reconcile = (next: ReadonlyArray) => + const reconcile = (configuredHosts: ReadonlyArray) => Effect.gen(function* () { + const next = yield* remoteSshDeviceHosts(configuredHosts).pipe( + Effect.provide(localTargetContext), + ); const removed = yield* service.withLifecycleLock( Effect.gen(function* () { const removed: Array<{ id: string; scope: Scope.Closeable }> = []; diff --git a/apps/server/src/device/localSshDeviceHost.test.ts b/apps/server/src/device/localSshDeviceHost.test.ts new file mode 100644 index 000000000000..d4d202950a46 --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { + isLocalSshDeviceHost, + LocalDeviceHostAddresses, + remoteSshDeviceHosts, +} from "./localSshDeviceHost.ts"; + +const host = (target: string, port?: number) => ({ + id: target, + label: target, + target, + ...(port ? { port } : {}), +}); +const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + // Any attempt to actually connect fails this test. + expect(command.args).toContain("-G"); + const target = command.args.at(-1); + const configs: Record = { + "mac-mini": "hostname 100.65.180.100\nport 22\n", + remote: "hostname 192.0.2.1\nport 22\n", + loopback: "hostname 127.0.1.1\nport 22\n", + ipv6: "hostname ::1\nport 22\n", + forwarded: "hostname 127.0.0.1\nport 2222\n", + proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n", + command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n", + unresolved: "hostname example.invalid\nport 22\n", + }; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), +); +const provide = ( + effect: Effect.Effect>>, +) => + effect.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])), + Effect.provide(NodeServices.layer), + ); + +it.effect("skips SSH aliases resolving to this machine, including loopback", () => + provide( + Effect.gen(function* () { + for (const target of ["mac-mini", "loopback", "ipv6"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(true); + } + }), + ), +); + +it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () => + provide( + Effect.gen(function* () { + for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(false); + } + }), + ), +); + +it.effect("removes only self targets from a fanned-out host list", () => + provide( + Effect.gen(function* () { + expect( + yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]), + ).toEqual([host("remote"), host("forwarded")]); + }), + ), +); diff --git a/apps/server/src/device/localSshDeviceHost.ts b/apps/server/src/device/localSshDeviceHost.ts new file mode 100644 index 000000000000..0d1f615e1775 --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.ts @@ -0,0 +1,71 @@ +import * as NodeDnsPromises from "node:dns/promises"; +import * as NodeNet from "node:net"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Context from "effect/Context"; +import { runSshCommand } from "@t3tools/ssh/command"; +import * as Effect from "effect/Effect"; + +export const LocalDeviceHostAddresses = Context.Reference>( + "LocalDeviceHostAddresses", + { + defaultValue: () => + new Set( + Object.values(NodeOS.networkInterfaces()).flatMap( + (entries) => entries?.map((entry) => entry.address) ?? [], + ), + ), + }, +); + +/** Resolve aliases on the owning environment without opening an SSH connection. */ +export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* ( + host: SshDeviceHostConfig, +) { + const result = yield* runSshCommand( + { alias: host.target, hostname: host.target, username: null, port: host.port ?? null }, + { + preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])], + timeoutMs: 5000, + }, + ).pipe(Effect.result); + if (result._tag === "Failure") return false; + const config = new Map( + result.success.stdout.split("\n").map((line) => { + const separator = line.indexOf(" "); + return [line.slice(0, separator), line.slice(separator + 1).trim()]; + }), + ); + // A local forwarded port or a proxy can lead to a different machine. + if ( + config.get("port") !== "22" || + ["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none") + ) + return false; + const hostname = config.get("hostname")?.replace(/^\[|\]$/g, ""); + if (!hostname) return false; + const addresses = NodeNet.isIP(hostname) + ? [hostname] + : yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe( + Effect.map((entries) => entries.map((entry) => entry.address)), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => [] as string[]), + ); + const localAddresses = yield* LocalDeviceHostAddresses; + return ( + addresses.length > 0 && + addresses.every( + (address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."), + ) + ); +}); + +export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* ( + hosts: ReadonlyArray, +) { + return yield* Effect.filter( + hosts, + (host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)), + { concurrency: 4 }, + ); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6ddd8c821c9f..0e628879712a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -117,6 +117,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; @@ -548,6 +549,8 @@ const makeWsRpcLayer = ( const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const deviceService = yield* DeviceService.DeviceService; + const deviceHostContext = + yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; @@ -2305,9 +2308,18 @@ const makeWsRpcLayer = ( [WS_METHODS.serverUpdateSettings]: ({ patch }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, - serverSettings - .updateSettings(patch) - .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + Effect.gen(function* () { + const deviceHosts = patch.deviceHosts + ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( + Effect.provide(deviceHostContext), + ) + : undefined; + const settings = yield* serverSettings.updateSettings({ + ...patch, + ...(deviceHosts ? { deviceHosts } : {}), + }); + return ServerSettings.redactServerSettingsForClient(settings); + }), { "rpc.aggregate": "server", }, From 8984f8103d0836c0b340fe70cd053428d622a8c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:13:34 -0700 Subject: [PATCH 22/27] fix(web): test device hosts across selected environments (#11699) --- .../components/settings/DeviceHostEditor.tsx | 224 +++++++++++++++++ .../settings/DeviceHostsSettings.tsx | 235 +++++------------- .../deviceHostConnectionChecks.test.ts | 85 +++++++ .../settings/deviceHostConnectionChecks.ts | 61 +++++ .../settings/useHostConnectionChecks.ts | 46 ++++ docs/user/devices.md | 8 +- 6 files changed, 487 insertions(+), 172 deletions(-) create mode 100644 apps/web/src/components/settings/DeviceHostEditor.tsx create mode 100644 apps/web/src/components/settings/deviceHostConnectionChecks.test.ts create mode 100644 apps/web/src/components/settings/deviceHostConnectionChecks.ts create mode 100644 apps/web/src/components/settings/useHostConnectionChecks.ts diff --git a/apps/web/src/components/settings/DeviceHostEditor.tsx b/apps/web/src/components/settings/DeviceHostEditor.tsx new file mode 100644 index 000000000000..4ab03ad3c156 --- /dev/null +++ b/apps/web/src/components/settings/DeviceHostEditor.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import * as Option from "effect/Option"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import { CheckIcon, MonitorIcon, XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, +} from "../ui/dialog"; +import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; +import { + deviceHostConnectionKey, + parseDeviceHostDraft, + type DeviceHostCheckTarget, +} from "./deviceHostConnectionChecks"; + +export function DeviceHostEditor({ + host, + isNew, + targets, + busy, + onSave, + onClose, +}: { + host: SshDeviceHostConfig; + isNew: boolean; + targets: ReadonlyArray; + busy: boolean; + onSave: (host: SshDeviceHostConfig) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(host); + const { checks, testConnection } = useHostConnectionChecks(targets); + const results = checks[deviceHostConnectionKey(draft)]; + const checking = Object.values(results ?? {}).some((check) => check.status === "pending"); + const input = parseDeviceHostDraft({ ...draft, label: draft.label.trim() || draft.target }); + const valid = Option.isSome(input); + const failed = Object.values(results ?? {}).filter((check) => check.status === "failed").length; + return ( + { + if (!open && !busy) onClose(); + }} + > + { + event.preventDefault(); + if (Option.isSome(input) && draft.label.trim() && !busy && !checking) + onSave(input.value); + }} + /> + } + > + + {isNew ? "Add device host" : "Edit device host"} + + {targets.length === 1 + ? `Connect from ${targets[0]?.label}.` + : `Connect from ${targets.length} selected environments.`}{" "} + Hosts on the same machine are skipped. + + + + + +
      + SSH options +
      + + +
      +

      + Optional. Resolved separately on each environment. +

      +
      +
      +
      +

      + {checking + ? "Checking environments…" + : results + ? failed + ? `${failed} of ${targets.length} failed` + : "Connection checks passed" + : "Check access before saving"} +

      + +
      + {results ? ( +
        + {targets.map((target) => { + const result = results[target.environmentId]; + if (!result) return null; + return ( +
      • +
        + {target.label} + + {result.status === "pending" ? ( + <> + Checking… + + ) : result.status === "local" ? ( + <> + Already available locally + + ) : result.status === "failed" ? ( + <> + Failed + + ) : ( + <> + Connected + + )} + +
        + {result.status === "connected" ? ( +
        + +
        + ) : null} + {result.status === "failed" ? ( +
        + Show error +

        + {result.error} +

        +
        + ) : null} +
      • + ); + })} +
      + ) : null} +
      +
      + + + + +
      +
      + ); +} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index c5a5f194d7cd..50841ee5b53a 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -1,20 +1,13 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { AppleIcon, AndroidIcon } from "../Icons"; -import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; import { Spinner } from "../ui/spinner"; -import type { - DevicePlatformAvailability, - EnvironmentId, - SshDeviceHostConfig, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; import { randomUUID } from "../../lib/utils"; import { useState } from "react"; -import { deviceEnvironment, useDeviceState } from "../../state/device"; +import { useDeviceState } from "../../state/device"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; -import { Input } from "../ui/input"; import { MoreVertical, PlusIcon } from "lucide-react"; import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; @@ -22,17 +15,23 @@ import { SettingsRow } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; import { toastManager } from "../ui/toast"; import { updateDeviceHosts } from "./deviceHostsSettings.logic"; +import { DeviceHostEditor } from "./DeviceHostEditor"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; +import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope(); + const { scope, environments, connectedEnvironments } = useSettingsScope(); const projectScope = scope.kind === "project" || scope.kind === "checkout"; const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const [editing, setEditing] = useState(null); const [originalHost, setOriginalHost] = useState(null); const [busy, setBusy] = useState(false); - const validPort = (port: number | undefined) => - port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); - const { checks, testConnection } = useHostConnectionChecks(props.environmentId); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: environment.connection.phase === "connected", + })); + const { checks, testConnection } = useHostConnectionChecks(targets); const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { if (!props.environmentId || projectScope) return; setBusy(true); @@ -113,6 +112,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null environmentId={environment.environmentId} hosts={environment.serverConfig?.settings.deviceHosts ?? []} busy={projectScope || busy} + checks={checks} + testConnection={async (host) => { + const results = await testConnection(host); + if (!results) return; + const failed = targets.filter( + (target) => results[target.environmentId]?.status === "failed", + ); + toastManager.add({ + type: failed.length ? "error" : "success", + title: failed.length + ? `${host.label}: ${failed.length} of ${targets.length} environments failed` + : `${host.label}: connection checks passed`, + description: failed.length + ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.` + : "Connected or already available locally on each selected environment.", + }); + return results; + }} onEdit={(host) => { setOriginalHost(host); setEditing(host); @@ -122,123 +139,15 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
      ))} {editing ? ( -
      { - event.preventDefault(); - void save(editing, false, originalHost ?? editing); - }} - > - - - - -
      - - - -
      - {checks[editing.id]?.pending ? ( - - - Checking connection… - - ) : null} - {checks[editing.id]?.platforms ? ( - - ) : null} - {checks[editing.id]?.error ? ( -

      - {checks[editing.id]?.error} -

      - ) : null} - + void save(host, false, originalHost ?? host)} + onClose={() => setEditing(null)} + /> ) : null} )} @@ -247,49 +156,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null ); } -function useHostConnectionChecks(environmentId: EnvironmentId | null) { - const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); - const [checks, setChecks] = useState< - Record< - string, - { pending?: boolean; platforms?: ReadonlyArray; error?: string } - > - >({}); - const setCheck = (id: string, value: (typeof checks)[string]) => - setChecks((current) => ({ ...current, [id]: value })); - const testConnection = async (host: SshDeviceHostConfig) => { - if (!environmentId || checks[host.id]?.pending) return; - setCheck(host.id, { pending: true }); - try { - const summary = await test({ environmentId: environmentId, input: host }); - setCheck( - host.id, - summary._tag === "Failure" - ? { error: Cause.pretty(summary.cause) } - : { platforms: summary.value.platforms }, - ); - } catch (error) { - setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); - } - }; - return { checks, testConnection }; -} - function DeviceHostList({ environmentId, hosts, busy, onEdit, onRemove, + checks, + testConnection, }: { environmentId: EnvironmentId; hosts: ReadonlyArray; busy: boolean; onEdit: (host: SshDeviceHostConfig) => void; onRemove: (host: SshDeviceHostConfig) => void; + checks: ReturnType["checks"]; + testConnection: ReturnType["testConnection"]; }) { const { state } = useDeviceState(environmentId); - const { checks, testConnection } = useHostConnectionChecks(environmentId); return ( <> {hosts.length === 0 ? ( @@ -297,17 +181,27 @@ function DeviceHostList({ ) : null} {hosts.map((host) => { const status = state.hostStatuses[host.id]; - const check = checks[host.id]; + const check = checks[deviceHostConnectionKey(host)]?.[environmentId]; const platforms = - check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? []; - const progress = check?.pending - ? "Checking connection…" - : status?.status === "installing" - ? "Installing device support…" - : status?.status === "starting" - ? "Connecting…" - : null; - const error = check?.error ?? (status?.status === "failed" ? status.detail : undefined); + (check?.status === "connected" ? check.platforms : undefined) ?? + state.hosts.find((value) => value.id === host.id)?.platforms ?? + []; + const progress = + check?.status === "pending" + ? "Checking connection…" + : status?.status === "installing" + ? "Installing device support…" + : status?.status === "starting" + ? "Connecting…" + : null; + const error = + check?.status === "failed" + ? check.error + : check?.status === "local" + ? undefined + : status?.status === "failed" + ? status.detail + : undefined; return (
      @@ -342,6 +236,9 @@ function DeviceHostList({ ))}

      {host.target}

      + {check?.status === "local" ? ( +

      Already available locally

      + ) : null} {error ? (
      diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts new file mode 100644 index 000000000000..f9b003812bfe --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts @@ -0,0 +1,85 @@ +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, type DeviceHostSummary } from "@t3tools/contracts"; +import { + checkDeviceHostConnections, + parseDeviceHostDraft, + deviceHostConnectionKey, + type DeviceHostCheck, +} from "./deviceHostConnectionChecks"; + +const host = { id: "mac", label: "Mac mini", target: "user@mac" }; +const ids = ["a", "b", "c", "d"].map((id) => EnvironmentId.make(id)); +const targets = ids.map((environmentId, index) => ({ + environmentId, + label: environmentId, + connected: index !== 3, +})); +const summary: DeviceHostSummary = { + id: "mac", + label: "Mac mini", + kind: "ssh", + platforms: [{ platform: "ios", available: true }], + hubInstalled: false, + agentDeviceInstalled: false, +}; + +describe("device host connection checks", () => { + it("starts all connected environments and retains success, local, failure, and offline results", async () => { + const calls: string[] = []; + const pending = new Map< + string, + { resolve: (value: DeviceHostSummary) => void; reject: (error: Error) => void } + >(); + const results = new Map(); + const run = checkDeviceHostConnections( + targets, + host, + (environmentId) => { + calls.push(environmentId); + return new Promise((resolve, reject) => pending.set(environmentId, { resolve, reject })); + }, + (environmentId, result) => results.set(environmentId, result), + ); + expect(calls).toEqual(ids.slice(0, 3)); + expect(results.get(ids[0]!)).toEqual({ status: "pending" }); + pending.get(ids[0]!)!.resolve(summary); + pending.get(ids[1]!)!.resolve({ ...summary, id: "local", kind: "local" }); + pending.get(ids[2]!)!.reject(new Error("SSH key rejected")); + await run; + expect([...results.values()]).toEqual([ + { status: "connected", platforms: summary.platforms }, + { status: "local" }, + { status: "failed", error: "SSH key rejected" }, + { status: "failed", error: "Environment disconnected" }, + ]); + }); + + it("does not reuse results after editing a destination or SSH options", () => { + const key = deviceHostConnectionKey(host); + for (const changed of [ + { ...host, target: "other" }, + { ...host, port: 2222 }, + { ...host, identityFile: "~/.ssh/other" }, + ]) { + expect(deviceHostConnectionKey(changed)).not.toBe(key); + } + expect( + deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }), + ).toBe(key); + }); + it("validates SSH targets and normalizes optional identity files through the host contract", () => { + for (const target of ["-invalid", "user@bad host", " "]) { + expect(parseDeviceHostDraft({ ...host, target })._tag).toBe("None"); + } + for (const port of [0, 65536, 1.5]) { + expect(parseDeviceHostDraft({ ...host, port })._tag).toBe("None"); + } + expect(parseDeviceHostDraft({ ...host, target: " user@mac ", identityFile: " " })).toEqual( + Option.some(host), + ); + expect(parseDeviceHostDraft({ ...host, identityFile: " ~/.ssh/device " })).toEqual( + Option.some({ ...host, identityFile: "~/.ssh/device" }), + ); + }); +}); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts new file mode 100644 index 000000000000..97312b671513 --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -0,0 +1,61 @@ +import { + type DeviceHostSummary, + type DevicePlatformAvailability, + type EnvironmentId, + SshDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface DeviceHostCheckTarget { + environmentId: EnvironmentId; + label: string; + connected: boolean; +} +export type DeviceHostCheck = + | { status: "pending" } + | { status: "local" } + | { status: "connected"; platforms: ReadonlyArray } + | { status: "failed"; error: string }; + +const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); + +export function parseDeviceHostDraft(host: SshDeviceHostConfig) { + const { identityFile, ...rest } = host; + return decodeDeviceHostDraft({ + ...rest, + ...(identityFile?.trim() ? { identityFile: identityFile.trim() } : {}), + }); +} + +export function deviceHostConnectionKey(host: SshDeviceHostConfig) { + return JSON.stringify([host.target.trim(), host.port, host.identityFile?.trim() || undefined]); +} + +/** Each environment settles independently so one failure cannot hide the other results. */ +export async function checkDeviceHostConnections( + targets: ReadonlyArray, + host: SshDeviceHostConfig, + probe: (environmentId: EnvironmentId, host: SshDeviceHostConfig) => Promise, + report: (environmentId: EnvironmentId, result: DeviceHostCheck) => void, +) { + await Promise.all( + targets.map(async (target) => { + report(target.environmentId, { status: "pending" }); + try { + if (!target.connected) throw new Error("Environment disconnected"); + const result = await probe(target.environmentId, host); + report( + target.environmentId, + result.kind === "local" + ? { status: "local" } + : { status: "connected", platforms: result.platforms }, + ); + } catch (error) { + report(target.environmentId, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); +} diff --git a/apps/web/src/components/settings/useHostConnectionChecks.ts b/apps/web/src/components/settings/useHostConnectionChecks.ts new file mode 100644 index 000000000000..7d642fa62a60 --- /dev/null +++ b/apps/web/src/components/settings/useHostConnectionChecks.ts @@ -0,0 +1,46 @@ +import { useRef, useState } from "react"; +import * as Cause from "effect/Cause"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import { deviceEnvironment } from "../../state/device"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + checkDeviceHostConnections, + deviceHostConnectionKey, + type DeviceHostCheck, + type DeviceHostCheckTarget, +} from "./deviceHostConnectionChecks"; + +export function useHostConnectionChecks(targets: ReadonlyArray) { + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); + const [checks, setChecks] = useState>>({}); + const running = useRef(new Set()); + const testConnection = async (host: SshDeviceHostConfig) => { + const key = deviceHostConnectionKey(host); + if (running.current.has(key)) return; + running.current.add(key); + setChecks((current) => ({ ...current, [key]: {} })); + const results: Record = {}; + try { + await checkDeviceHostConnections( + targets, + host, + async (environmentId, input) => { + const result = await test({ environmentId, input }); + if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause)); + return result.value; + }, + (environmentId, result) => { + results[environmentId] = result; + setChecks((current) => ({ + ...current, + [key]: { ...current[key], [environmentId]: result }, + })); + }, + ); + return results; + } finally { + running.current.delete(key); + } + }; + return { checks, testConnection }; +} diff --git a/docs/user/devices.md b/docs/user/devices.md index 2dbe0e26007a..f40d11904459 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -75,14 +75,16 @@ still-image stream and Android cannot show video. ## SSH device hosts -In Settings → Integrations → Devices, select one connected environment -and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with +In Settings → Integrations → Devices, choose the environments that should use +the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. **Test connection** checks SSH, Node, npm, and platform tools without installing -anything. The first device listing installs pinned device tools on the host. +anything, with a result for each selected environment. Targets that resolve to +the environment’s own machine are skipped, since its devices are already local. +The first device listing installs pinned device tools on the host. Node 22 or newer and npm must be available to non-interactive SSH commands. T3 checks common Homebrew and Android SDK locations; custom installations need the appropriate PATH and ANDROID_HOME on the host. From 0b54e00f9ba9d86b05b462955e84c9944349d55b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:25:17 -0700 Subject: [PATCH 23/27] Change input type from 'full_diff' to 'incremental' --- .macroscope/check-run-agents/effect-service-conventions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 99d7f3cd22ba..8ed88559cfd8 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -2,7 +2,7 @@ title: Effect Service Conventions model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr From e3792a53f7d7f9ebba37abfca99073f3f1ead2a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:25:32 -0700 Subject: [PATCH 24/27] Update model and input type in ui-consistency.md --- .macroscope/check-run-agents/ui-consistency.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index d2e450235baa..87285d7b0881 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,8 +1,8 @@ --- title: UI Consistency -model: gpt-5-6-terra +model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr From cba7dd77817df2a458d86977f1daf4606c67dfc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:59:47 -0700 Subject: [PATCH 25/27] feat(desktop): allow disabling the local environment (#9194) Co-authored-by: Claude Code Co-authored-by: Julius Marminge --- apps/desktop/src/app/DesktopApp.ts | 57 ++++++---- .../src/app/DesktopEnvironment.test.ts | 4 + apps/desktop/src/app/DesktopEnvironment.ts | 3 + .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/electron/ElectronProtocol.test.ts | 61 ++++++++-- apps/desktop/src/electron/ElectronProtocol.ts | 64 +++++++++-- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/localEnvironment.test.ts | 63 +++++++++++ .../src/ipc/methods/localEnvironment.ts | 30 +++++ apps/desktop/src/ipc/methods/window.test.ts | 41 +++++-- apps/desktop/src/ipc/methods/window.ts | 10 +- apps/desktop/src/ipc/methods/wsl.test.ts | 38 ++++++- apps/desktop/src/ipc/methods/wsl.ts | 2 +- apps/desktop/src/preload.ts | 4 + .../src/settings/DesktopAppSettings.test.ts | 25 ++++ .../src/settings/DesktopAppSettings.ts | 23 ++++ .../desktop/src/updates/updatesTestHarness.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 32 ++++++ apps/desktop/src/window/DesktopWindow.ts | 14 ++- .../desktop/src/wsl/DesktopWslBackend.test.ts | 23 ++++ apps/desktop/src/wsl/DesktopWslBackend.ts | 1 + apps/web/src/components/CommandPalette.tsx | 34 +++--- .../components/onboarding/FirstRunGate.tsx | 2 + .../settings/ConnectionsSettings.tsx | 107 ++++++++++-------- .../settings/LocalEnvironmentSetting.tsx | 103 +++++++++++++++++ .../settings/settingsSearch.test.ts | 22 ++++ .../src/components/settings/settingsSearch.ts | 13 +++ .../useAvailableSettingsSearchItems.ts | 24 +++- apps/web/src/connection/platform.ts | 3 +- .../environments/primary/bootstrap.test.ts | 20 +++- .../src/environments/primary/sessionState.ts | 7 +- apps/web/src/environments/primary/target.ts | 21 +++- apps/web/src/localEnvironment.ts | 9 ++ .../web/src/onboarding/firstRun.logic.test.ts | 12 ++ apps/web/src/onboarding/firstRun.logic.ts | 5 +- apps/web/src/routes/__root.tsx | 3 +- apps/web/src/routes/_chat.index.tsx | 18 ++- docs/internals/remote.md | 12 ++ docs/user/remote-access.md | 11 ++ packages/contracts/src/ipc.ts | 2 + 41 files changed, 794 insertions(+), 139 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/localEnvironment.test.ts create mode 100644 apps/desktop/src/ipc/methods/localEnvironment.ts create mode 100644 apps/web/src/components/settings/LocalEnvironmentSetting.tsx create mode 100644 apps/web/src/localEnvironment.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e6abaab03251..365363881f5b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances" ); const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); + const settings = yield* desktopSettings.get; + // The renderer is served from the bundled client (or Vite in development) + // rather than through the local backend, so the window can open without one. + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + ...(environment.isDevelopment + ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } + : { assetDirectory: environment.clientAssetsDir }), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + + yield* snapShot.initialize; + + if (!settings.localEnvironmentEnabled) { + yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; + } + return; + } + + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } @@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () { }, ); - const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -194,16 +218,6 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - const rendererTarget = environment.isDevelopment - ? Option.getOrThrow(environment.devServerUrl) - : backendConfig.httpBaseUrl; - yield* electronProtocol.registerDesktopProtocol({ - scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), - targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, - clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, - }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () { "bootstrap fell back to local-only because no advertised network host was available", ); } - yield* snapShot.initialize; - - yield* installDesktopIpcHandlers(); - yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - // In wsl-only mode the renderer is served by the WSL backend, which can be - // slow to cold-boot — show a "Connecting to WSL" splash immediately so the - // app feels responsive instead of presenting no window until WSL is ready. - // (Dual mode opens fast off the Windows primary, so no splash there.) + // The main window waits for the primary backend. In wsl-only mode that is + // the WSL backend, which can be slow to cold-boot — show a "Connecting to + // WSL" splash immediately so the app feels responsive instead of presenting + // no window until WSL is ready. (Dual mode opens fast off the Windows + // primary, so no splash there.) if (settings.wslOnly === true && settings.wslBackendEnabled === true) { yield* desktopWindow.showConnectingSplash; } diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 262097ca78ea..1ebd5dae56c2 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => { environment.backendEntryPath, "/install/resources/server.asar/apps/server/dist/bin.mjs", ); + assert.equal( + environment.clientAssetsDir, + "/install/resources/server.asar/apps/server/dist/client", + ); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9d7f00c3ee69..e604cb767f3f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service< // extracts on demand (see DesktopWslServerTree). readonly serverRoot: string; readonly backendEntryPath: string; + // Built web client the packaged renderer is served from over t3code://app. + readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; readonly appUpdateYmlPath: string; @@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, serverRoot, backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), + clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee0981..0914167cffb0 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 0d204fb3ad42..508a5c296898 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ @@ -16,6 +19,8 @@ vi.mock("electron", () => ({ import * as ElectronProtocol from "./ElectronProtocol.ts"; +const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer)); + describe("ElectronProtocol", () => { beforeEach(() => { handleMock.mockReset(); @@ -23,6 +28,46 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it.effect("serves the bundled client from disk without a backend", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.writeFileString(`${directory}/index.html`, "app"); + yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;"); + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + assetDirectory: directory, + clerkFrontendApiHostname: undefined, + }); + const request = (pathname: string, init?: RequestInit) => + Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); + + // SPA routes fall back to index.html, including ones containing dots. + const page = yield* request("/settings/connections"); + assert.equal(yield* Effect.promise(() => page.text()), "app"); + assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); + const dottedRoute = yield* request("/environment/thread.with.dots", { + headers: { accept: "text/html" }, + }); + assert.equal(yield* Effect.promise(() => dottedRoute.text()), "app"); + + const script = yield* request("/app.js?v=1"); + assert.equal(yield* Effect.promise(() => script.text()), "export default 1;"); + assert.include(script.headers.get("content-type") ?? "", "javascript"); + + assert.equal((yield* request("/missing.js")).status, 404); + assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404); + assert.equal((yield* request("/%invalid")).status, 400); + assert.equal((yield* request("/", { method: "POST" })).status, 405); + assert.equal(netFetchMock.mock.calls.length, 0); + }).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped), + ); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -37,7 +82,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -85,7 +129,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("rejects custom protocol requests for another host", () => @@ -101,7 +145,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -110,7 +153,7 @@ describe("ElectronProtocol", () => { assert.equal(response.status, 404); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("retries transient renderer target failures", () => @@ -129,7 +172,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -138,7 +180,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ready"); assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol registration failures", () => @@ -153,7 +195,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -162,7 +203,7 @@ describe("ElectronProtocol", () => { assert.equal(error.scheme, "t3code-dev"); assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol unregistration failures", () => @@ -178,7 +219,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -192,14 +232,13 @@ describe("ElectronProtocol", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); } - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index af0366f93f47..ed35bbc7952f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,7 +1,10 @@ +import Mime from "@effect/platform-node/Mime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as NodeTimersPromises from "node:timers/promises"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError decodeURIComponent(url.pathname)).pipe( + Effect.orElseSucceed(() => null), + ); + if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); + const root = path.resolve(assetDirectory); + const assetPath = path.resolve(root, `.${pathname}`); + if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { + return new Response(null, { status: 404 }); + } + const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); + let filePath = assetPath; + if (stat?.type !== "File") { + const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; + if (path.extname(assetPath) !== "" && !wantsHtml) { + return new Response(null, { status: 404 }); + } + filePath = path.join(root, "index.html"); + } + const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); + if (contents === null) return new Response(null, { status: 404 }); + return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { + headers: { "content-type": Mime.getType(filePath) ?? "application/octet-stream" }, + }); +}); + async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { let lastError: unknown; @@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( function* (input: DesktopProtocolRegistrationInput) { @@ -220,9 +264,15 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, async (request) => { + if ("assetDirectory" in input) { + return withContentSecurityPolicy( + await runPromise(serveDesktopAsset(request, input.assetDirectory)), + contentSecurityPolicy, + ); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6f9bac7333f4..c97c602552f4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -8,6 +8,10 @@ import { getConnectionCatalog, setConnectionCatalog, } from "./methods/connectionCatalog.ts"; +import { + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./methods/localEnvironment.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); + yield* ipc.handleSync(getLocalEnvironmentEnabled); + yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 7106c45af8e8..226793657848 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -25,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; +export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; +export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts new file mode 100644 index 000000000000..e17c48948098 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -0,0 +1,63 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; +import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; + +// `relaunch` declares the lifecycle runtime services as requirements even +// though the mocked relaunch never touches them. +const unusedLifecycleRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.mock(DesktopWindow.DesktopWindow, {}), + Layer.mock(ElectronApp.ElectronApp, {}), + Layer.mock(ElectronTheme.ElectronTheme, {}), +); + +describe("local environment IPC", () => { + it.effect("relaunches only when the setting changes and keeps other settings", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + }), + Layer.mock(DesktopLifecycle.DesktopLifecycle, { + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + }), + unusedLifecycleRuntimeLayer, + ); + return Effect.gen(function* () { + yield* setLocalEnvironmentEnabled.handler(false); + assert.isFalse(yield* getLocalEnvironmentEnabled.handler()); + yield* setLocalEnvironmentEnabled.handler(false); + assert.deepEqual(relaunchReasons, ["localEnvironmentEnabled=false"]); + + yield* setLocalEnvironmentEnabled.handler(true); + assert.isTrue(yield* getLocalEnvironmentEnabled.handler()); + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + assert.isTrue((yield* appSettings.get).wslBackendEnabled); + assert.deepEqual(relaunchReasons, [ + "localEnvironmentEnabled=false", + "localEnvironmentEnabled=true", + ]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts new file mode 100644 index 000000000000..74cccd04a0c5 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +export const getLocalEnvironmentEnabled = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.localEnvironment.getEnabled")(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + return (yield* appSettings.get).localEnvironmentEnabled; + }), +}); + +export const setLocalEnvironmentEnabled = makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.localEnvironment.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setLocalEnvironmentEnabled(enabled); + if (change.changed) { + yield* lifecycle.relaunch(`localEnvironmentEnabled=${enabled}`); + } + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 6fcf5e813749..eca3db4ddf85 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -19,6 +19,8 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, @@ -208,19 +210,21 @@ describe("pasteAsText", () => { }); describe("pickProjectFavicon", () => { + const pickerLayer = (pickFiles: () => Effect.Effect>, settings?: DesktopSettings) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + DesktopAppSettings.layerTest(settings), + ); + it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon.handler("/project").pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); assert.strictEqual(result, "/pictures/icon.png"); assert.deepEqual(pickFiles.mock.calls, [ @@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => { ]); }), ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 284b62ad31ac..81361db37303 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + // A picked path only means something to a backend on this machine. + if (!settings.localEnvironmentEnabled) { + return null; + } // Three picker modes: // - targetEnvironmentId omitted: default to the primary picker. Keeps // the historical behavior unchanged for users who never enabled the @@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - const settings = yield* appSettings.get; // Fall back to the persisted wslDistro when the id is the // "wsl:default" sentinel; the orchestrator uses the same fallback // for the actual backend. @@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + if (!(yield* appSettings.get).localEnvironmentEnabled) { + return null; + } const paths = yield* dialog.pickFiles({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: Option.fromNullishOr(initialPath), diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 38435e286fa7..bfd1a6e679d9 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -18,7 +18,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ); describe("WSL IPC", () => { + it.effect("does not probe WSL when local execution is disabled", () => + Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const state = yield* getWslState.handler(undefined).pipe( + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { + ...wsl, + isAvailable: Effect.die("must not probe WSL"), + listDistros: Effect.die("must not enumerate distros"), + }), + Effect.flatMap(decodeWslState), + ); + assert.deepEqual(state, { + enabled: true, + distro: "Ubuntu", + available: false, + wslOnly: true, + distros: [], + preflightError: null, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: true, + }), + DesktopWslEnvironment.layerTest(), + makeWslBackendLayer(), + ), + ), + ), + ); + it.effect("stages dual-backend preferences before enabling without relaunching", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 1d0dc262baea..37cd992bb641 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -21,7 +21,7 @@ const readWslState: Effect.Effect< const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; - const available = yield* wslEnvironment.isAvailable; + const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); // Only enumerate distros when WSL is actually available — listDistros on a // non-WSL host would spawn wsl.exe and hit the timeout for nothing. const distros = available ? yield* wslEnvironment.listDistros : []; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4c9a8199de68..453879d37afe 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), + getLocalEnvironmentEnabled: () => + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, + setLocalEnvironmentEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..f7db3c277810 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -91,6 +91,24 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect( + "persists disabling and re-enabling local execution without clearing backend settings", + () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslDistro("Ubuntu"); + yield* settings.setServerExposureMode("network-accessible"); + const before = yield* settings.get; + assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed); + assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false }); + assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed); + yield* settings.setLocalEnvironmentEnabled(true); + assert.deepEqual(yield* settings.load, before); + }), + ), + ); it.effect("loads defaults when no settings file exists", () => withSettings( Effect.gen(function* () { @@ -106,6 +124,7 @@ describe("DesktopSettings", () => { DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -135,6 +154,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -242,6 +262,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -298,6 +319,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -346,6 +368,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -374,6 +397,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -401,6 +425,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022..19fcf0e75962 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly localEnvironmentEnabled: boolean; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + localEnvironmentEnabled: true, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setLocalEnvironmentEnabled: ( + enabled: boolean, + ) => Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, @@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +254,10 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) { + document.localEnvironmentEnabled = settings.localEnvironmentEnabled; + } + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.localEnvironmentEnabled === enabled + ? settings + : { ...settings, localEnvironmentEnabled: enabled }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -545,6 +562,10 @@ export const make = Effect.gen(function* () { persist((settings) => setWslOnly(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), ), + setLocalEnvironmentEnabled: (enabled) => + persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -586,6 +607,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), + setLocalEnvironmentEnabled: (enabled) => + update((settings) => setLocalEnvironmentEnabled(settings, enabled)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cd1404a50464..fbcbb349f9e7 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bbb5c1da024..338a02b26a1f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -253,6 +253,7 @@ function makeTestLayer(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); @@ -629,6 +630,37 @@ describe("DesktopWindow", () => { }), ); + it.effect( + "opens and reopens the window without backend readiness when local execution is disabled", + () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions: [], + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.createMainIfBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.dispatchMenuAction("new-thread"); + assert.equal(yield* Ref.get(createCount), 3); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..e19a75962126 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service< readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only - // mode), before the WSL backend that serves the renderer is ready. It is + // mode), before the WSL backend that acts as the primary is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the @@ -838,9 +838,15 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + // With the local environment disabled there is no backend to wait for: the + // renderer is served from bundled assets and only talks to remote environments. + const waitingForBackend = Effect.gen(function* () { + if (yield* Ref.get(backendReadyRef)) return false; + return (yield* desktopSettings.get).localEnvironmentEnabled; + }); + const createMainIfBackendReady = Effect.gen(function* () { - const backendReady = yield* Ref.get(backendReadyRef); - if (!backendReady) return; + if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; yield* createMain; @@ -898,7 +904,7 @@ export const make = Effect.gen(function* () { { reveal = true }: { readonly reveal?: boolean } = {}, ) { const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; - if (Option.isNone(existingWindow) && (!reveal || !(yield* Ref.get(backendReadyRef)))) return; + if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; if (targetWindow.isDestroyed()) return; const send = Effect.sync(() => { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075..c2daf352837f 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, { } satisfies NetService.NetService["Service"]); describe("DesktopWslBackend", () => { + it.effect("does not discover or start WSL when local execution is disabled", () => + Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + yield* backend.reconcile; + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})), + Layer.provide(backendConfigurationLayer), + Layer.provide(serverExposureLayer), + Layer.provide(netLayer), + Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})), + Layer.provide( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + }), + ), + ), + ), + ), + ); it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; const primary = makeStubInstance({ diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 605f4e7a477f..3f20e58aa680 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -188,6 +188,7 @@ export const layer = Layer.effect( const reconcileBody = Effect.gen(function* () { const settings = yield* appSettings.get; + if (!settings.localEnvironmentEnabled) return; const available = yield* wslEnvironment.isAvailable; const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8af4419fca31..f33a9cce63b0 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -953,8 +953,13 @@ function OpenCommandPaletteDialog(props: { ) : ""; const browsePath = useMemo( - () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), - [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + () => + getFilesystemBrowsePath( + query, + browseEnvironmentPlatform, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + ), + [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1553,6 +1558,14 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { + // With no environment at all there is nothing to browse, so the only + // useful next step is connecting one. + if (addProjectEnvironmentOptions.length === 0) { + setOpen(false); + void navigate({ to: "/settings/connections" }); + return; + } + if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { pushPaletteView({ addonIcon: , @@ -1561,24 +1574,14 @@ function OpenCommandPaletteDialog(props: { return; } - const environmentId = defaultAddProjectEnvironmentId; - if (!environmentId) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to browse projects", - description: "No environment is available.", - }), - ); - return; - } - - void startAddProjectSourceSelection(environmentId); + void startAddProjectSourceSelection(defaultAddProjectEnvironmentId); }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + navigate, pushPaletteView, + setOpen, startAddProjectSourceSelection, ]); @@ -1775,7 +1778,6 @@ function OpenCommandPaletteDialog(props: { "environment", ], title: "Add project", - disabled: defaultAddProjectEnvironmentId === null, icon: , keepOpen: true, run: async () => { diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index b7c378537fc9..0fda2ee2cecc 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -9,6 +9,7 @@ import { useClientSettings, useClientSettingsHydrationStatus, } from "../../hooks/useSettings"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { isFirstRunWorkspaceProvenanceAuthoritative, @@ -126,6 +127,7 @@ export function FirstRunGate({ const { decision: nextDecision, persistCompletion } = hostedStatic ? resolveHostedFirstRunDecision({ + localEnvironmentDisabled: isLocalEnvironmentDisabled(), hydrated, completed: onboardingCompletedAt !== null, catalogReady: environmentCatalogReady, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c450e849307b..f76e6ef10d64 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -51,6 +51,7 @@ import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { @@ -65,6 +66,7 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; +import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { @@ -1985,7 +1987,9 @@ export function ConnectionsSettings() { const setDefaultAdvertisedEndpointKey = useUiStateStore( (state) => state.setDefaultAdvertisedEndpointKey, ); - const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canManageLocalBackend = + !isLocalEnvironmentDisabled() && + (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false); const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null @@ -3234,15 +3238,21 @@ export function ConnectionsSettings() { const primarySettings = ( <> - {canManageLocalBackend ? ( + {desktopBridge || canManageLocalBackend ? ( <> } @@ -3272,46 +3282,51 @@ export function ConnectionsSettings() { ) : null } > - - ) : ( - [ - primaryServerConfig?.environment.serverVersion ?? null, - primaryEnvironment?.displayUrl ?? null, - ] - .filter((value): value is string => value !== null) - .join(" · ") || "Loading…" - ) - } - control={ - primaryVersionMismatch && - primaryEnvironmentId !== null && - primaryServerUpdateState.status !== "running" ? ( - - ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( - Up to date - ) : undefined - } - /> - {desktopBridge ? ( + + {canManageLocalBackend ? ( + + ) : ( + [ + primaryServerConfig?.environment.serverVersion ?? null, + primaryEnvironment?.displayUrl ?? null, + ] + .filter((value): value is string => value !== null) + .join(" · ") || "Loading…" + ) + } + control={ + primaryVersionMismatch && + primaryEnvironmentId !== null && + primaryServerUpdateState.status !== "running" ? ( + + ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( + Up to date + ) : undefined + } + /> + ) : null} + {canManageLocalBackend && desktopBridge ? ( <> {renderNetworkAccessRow()} {renderEndpointRows("endpoint-rail")} @@ -3319,12 +3334,12 @@ export function ConnectionsSettings() { {renderWslRow()} - ) : ( + ) : canManageLocalBackend ? ( <> {renderDisabledNetworkAccessRow()} - )} + ) : null} {isLocalBackendRemotelyReachable ? ( diff --git a/apps/web/src/components/settings/LocalEnvironmentSetting.tsx b/apps/web/src/components/settings/LocalEnvironmentSetting.tsx new file mode 100644 index 000000000000..d32e28030074 --- /dev/null +++ b/apps/web/src/components/settings/LocalEnvironmentSetting.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; + +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +// Toggling relaunches the desktop app, so the switch only reflects the value +// this process started with; there is no live state to keep in sync. +export function LocalEnvironmentSetting() { + const setEnabled = window.desktopBridge?.setLocalEnvironmentEnabled; + const [enabled] = useState(() => !isLocalEnvironmentDisabled()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + if (!setEnabled) return null; + + const applyChange = async () => { + setIsUpdating(true); + setError(null); + try { + await setEnabled(!enabled); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn't change this setting."); + setIsUpdating(false); + } + }; + + return ( + <> + setConfirmOpen(true)} + aria-label="Local environment" + /> + } + /> + { + if (isUpdating) return; + setConfirmOpen(open); + if (!open) setError(null); + }} + > + + + + {enabled ? "Turn off local environment?" : "Turn on local environment?"} + + + {enabled + ? "T3 Code will restart without running a server on this computer. Any agents and terminals running here will stop, and other devices will no longer be able to connect to this computer. Your projects, history, and remote environments are unaffected." + : "T3 Code will restart and start running a server on this computer again."} + + + {error ?

      {error}

      : null} + + }> + Cancel + + + +
      +
      + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index ef1c684118eb..ea9e1a88ec30 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -173,6 +173,28 @@ describe("searchSettings", () => { expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("keeps the local toggle searchable without offering hidden host publishing controls", () => { + const availability = { + hasCloudPublicConfig: true, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const remoteOnly = filterAvailableSettingsSearchItems({ + ...availability, + localEnvironmentDisabled: true, + }).map((item) => item.id); + expect(remoteOnly).toContain("local-environment"); + expect(remoteOnly).not.toContain("t3-connect"); + expect(remoteOnly).not.toContain("publish-agent-activity"); + expect(remoteOnly).not.toContain("wsl-backend"); + // Browsers without access:write still render CloudLinkRow for their host. + const browser = filterAvailableSettingsSearchItems(availability).map((item) => item.id); + expect(browser).toContain("publish-agent-activity"); + }); + it("shows automatic settlement settings when the server supports them", () => { const available = filterAvailableSettingsSearchItems({ hasCloudPublicConfig: false, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 837aa7b47ce3..19268e2cdaed 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -52,11 +52,13 @@ export interface SettingsSearchItem { readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; + readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; readonly requiresThreadAutoSettlement?: boolean; } export interface SettingsSearchAvailability { + readonly localEnvironmentDisabled?: boolean; readonly hasCloudPublicConfig: boolean; readonly hasEnvironment: boolean; readonly hasProviderSettingsEnvironment: boolean; @@ -626,6 +628,14 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], localBackendManagementOnly: true, }, + { + id: "local-environment", + title: "Local environment", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["turn off on disable enable local server agents remote only restart"], + desktopOnly: true, + }, { id: "network-access", title: "Network access", @@ -657,6 +667,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "t3-connect", + localEnvironmentOnly: true, title: "T3 Connect", to: "/settings/connections", targetId: "connections-environment", @@ -666,6 +677,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "publish-agent-activity", + localEnvironmentOnly: true, title: "Publish agent activity", to: "/settings/connections", targetId: "connections-environment", @@ -840,6 +852,7 @@ export function filterAvailableSettingsSearchItems( (!item.environmentOnly || availability.hasEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && + (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), ); diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index b4a892f45270..a0601e41729f 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -3,6 +3,7 @@ import { AuthAccessWriteScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; +import { isLocalEnvironmentDisabled } from "~/localEnvironment"; import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; @@ -17,16 +18,21 @@ import { export function useAvailableSettingsSearchItems() { const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); + const localEnvironmentDisabled = isLocalEnvironmentDisabled(); + const desktopWsl = useEnvironmentQuery( + isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null, + ); const canManageLocalBackend = - isElectron || - ((primarySessionState.data?.authenticated && - primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? - false); + !localEnvironmentDisabled && + (isElectron || + ((primarySessionState.data?.authenticated && + primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? + false)); return useMemo( () => filterAvailableSettingsSearchItems({ + localEnvironmentDisabled, hasCloudPublicConfig: hasCloudPublicConfig(), hasEnvironment: environments.some((environment) => environment.serverConfig !== null), hasProviderSettingsEnvironment: environments.some((environment) => @@ -43,6 +49,12 @@ export function useAvailableSettingsSearchItems() { hasThreadAutoSettlement: getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0, }), - [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments], + [ + canManageLocalBackend, + desktopWsl.data, + desktopWsl.error, + environments, + localEnvironmentDisabled, + ], ); } diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 7e88c4aae3c7..bcc2849dd041 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -51,6 +51,7 @@ import { } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; import { @@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( const platformConnectionSourceLayer = Layer.effect( PlatformConnectionSource, Effect.gen(function* () { - if (isHostedStaticApp()) { + if (isHostedStaticApp() || isLocalEnvironmentDisabled()) { return PlatformConnectionSource.of({ registrations: Stream.empty, }); diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index b08717d7c413..c9da4dab7051 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -158,7 +158,7 @@ describe("environmentBootstrap", () => { it("keeps an uppercase wss scheme secure when deriving the http url", () => { vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com"); - expect(readPrimaryEnvironmentTarget().target).toEqual({ + expect(readPrimaryEnvironmentTarget()?.target).toEqual({ httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", }); @@ -167,7 +167,7 @@ describe("environmentBootstrap", () => { it("keeps an uppercase https scheme secure when deriving the websocket url", () => { vi.stubEnv("VITE_HTTP_URL", "HTTPS://remote.example.com"); - expect(readPrimaryEnvironmentTarget().target).toEqual({ + expect(readPrimaryEnvironmentTarget()?.target).toEqual({ httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", }); @@ -257,6 +257,22 @@ describe("environmentBootstrap", () => { }); }); + it("has no primary target when the desktop local environment is disabled", () => { + vi.stubGlobal("window", { + location: new URL("t3code://app/"), + desktopBridge: { + getLocalEnvironmentEnabled: () => false, + getLocalEnvironmentBootstraps: () => [], + }, + }); + + expect(readPrimaryEnvironmentTarget()).toBeNull(); + expect(getPrimaryKnownEnvironment()).toBeNull(); + expect(() => resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toThrow( + "The local environment is disabled.", + ); + }); + it("preserves an unsupported window-origin protocol", () => { vi.stubGlobal("window", { location: { origin: "file:///tmp/t3code/" }, diff --git a/apps/web/src/environments/primary/sessionState.ts b/apps/web/src/environments/primary/sessionState.ts index 971f6811b8eb..5912a4865f9c 100644 --- a/apps/web/src/environments/primary/sessionState.ts +++ b/apps/web/src/environments/primary/sessionState.ts @@ -5,10 +5,15 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { fetchSessionState } from "./auth"; -const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe( +const primarySessionStateAtom = Atom.make( + Effect.suspend(() => + isLocalEnvironmentDisabled() ? Effect.succeed(null) : Effect.promise(fetchSessionState), + ), +).pipe( Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), Atom.withLabel("primary-environment:session"), diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index face3fa1e4fe..7e574bb7cd20 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,6 +1,8 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; + const PrimaryEnvironmentTargetSource = Schema.Literals([ "configured", "window-origin", @@ -57,6 +59,15 @@ export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErr } } +export class PrimaryEnvironmentDisabledError extends Schema.TaggedError()( + "PrimaryEnvironmentDisabledError", + {}, +) { + override get message(): string { + return "The local environment is disabled."; + } +} + export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( PrimaryEnvironmentProtocolUnsupportedError, @@ -276,6 +287,9 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); + if (!primaryTarget) { + throw new PrimaryEnvironmentDisabledError(); + } const url = parseTargetUrl({ rawValue: resolveHttpRequestBaseUrl(primaryTarget), @@ -289,7 +303,12 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { +// Null only when the desktop app runs with its local environment disabled; +// every other host has a primary (falling back to the page origin). +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { + if (isLocalEnvironmentDisabled()) { + return null; + } return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ?? diff --git a/apps/web/src/localEnvironment.ts b/apps/web/src/localEnvironment.ts new file mode 100644 index 000000000000..e277d3ebd4ce --- /dev/null +++ b/apps/web/src/localEnvironment.ts @@ -0,0 +1,9 @@ +/** + * True when the desktop app runs without its local server. The renderer then + * has no primary environment: it skips primary auth and discovery and only + * connects to saved remote environments. Always false in browsers and on + * desktop builds predating the setting. + */ +export function isLocalEnvironmentDisabled(): boolean { + return window.desktopBridge?.getLocalEnvironmentEnabled?.() === false; +} diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index ca35f6322e3b..e8d74bfbd7ff 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -316,6 +316,18 @@ describe("resolveHostedFirstRunDecision", () => { }); }); + it("keeps Connections reachable for a remote-only desktop predating onboarding", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + localEnvironmentDisabled: true, + }), + ).toEqual({ decision: "app", persistCompletion: true }); + }); + it("backfills onboarding for a hosted install with saved environments", () => { expect( resolveHostedFirstRunDecision({ diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 013dbb02d527..924c7b5b8f1f 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -49,6 +49,7 @@ interface FirstRunDecisionInput { } interface HostedFirstRunDecisionInput { + readonly localEnvironmentDisabled?: boolean; readonly hydrated: boolean; readonly completed: boolean; readonly catalogReady: boolean; @@ -178,7 +179,9 @@ export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput return { decision: "pending", persistCompletion: false }; } - return input.environmentCount === 0 + // An existing desktop may have disabled its server before onboarding existed. + // Keep Connections accessible so it can turn local execution back on. + return input.environmentCount === 0 && !input.localEnvironmentDisabled ? { decision: "wizard", persistCompletion: false } : { decision: "app", persistCompletion: true }; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 982d81420445..89a1ff23cc83 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -54,6 +54,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; import { useAtomCommand } from "../state/use-atom-command"; @@ -83,7 +84,7 @@ export const Route = createRootRoute({ }; } - if (isHostedStaticApp(new URL(window.location.href))) { + if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) { return { authGateState: { status: "hosted-static", diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index e6bc867e9820..b32524bdde1e 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,6 +4,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; @@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); + const localEnvironmentOff = isLocalEnvironmentDisabled(); + const description = localEnvironmentOff + ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." + : cloudEnabled + ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." + : "Open Connections and add that machine using its pairing link. This app must be able to reach it."; return (
      - +
      {APP_DISPLAY_NAME} @@ -135,13 +143,11 @@ function HostedStaticOnboardingState() { Connect to a computer running T3 Code - This browser connects to T3 Code running on your computer or a server. Start the T3 - Code desktop app or command-line server on that machine and keep it running. + This app connects to T3 Code running on your computer or a server. Start the T3 Code + desktop app or command-line server on that machine and keep it running. - {cloudEnabled - ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." - : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."} + {description}