1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Hex from "effect/encoding/Hex";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7import * as Option from "effect/Option";
8import * as Stream from "effect/Stream";
9import * as Semaphore from "effect/Semaphore";
10import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
11
12import {
13 CLI_RELEASE_CHECKSUMS_FILE,
14 cliArchiveFileName,
15 cliArchivePlatformKey,
16 cliArchiveTarCommand,
17 cliReleaseDownloadBaseUrl,
18 parseChecksums,
19} from "@t3tools/shared/cliRelease";
20
21import * as ProcessRunner from "../processRunner.ts";
22
23/**
24 * A pinned runtime is an exact t3 release archive unpacked into
25 * <baseDir>/runtime/versions/<version>: the self-contained executable, the
26 * web client, and the native packages beside it. The boot service points its
27 * unit or launch agent at the executable, and server self-update installs the
28 * target version here before switching over. The runtime never depends on a
29 * Node or npm on the machine; the only npm involvement in T3 Code is the `t3`
30 * package for people who prefer `npx t3` or `npm install -g t3`, and even a
31 * CLI installed that way pins an archive when it sets up the service.
32 */
33const PINNED_RUNTIME_DIR = "runtime";
34const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
35const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
36// Boot-service setup and remote update can construct separate layers. Serialize
37// the complete install transaction across every caller in this process.
38const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
39
40export interface PinnedRuntimePaths {
41 readonly versionDir: string;
42 /** The executable. Its existence is what marks a runtime as present. */
43 readonly entryPath: string;
44 readonly sentinelPath: string;
45}
46
47/** The exact command that runs a pinned runtime. */
48export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): {
49 readonly command: string;
50 readonly args: ReadonlyArray<string>;
51} {
52 return { command: paths.entryPath, args: [] };
53}
54
55export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string {
56 return path.join(baseDir, PINNED_RUNTIME_DIR, "versions");
57}
58
59export function pinnedRuntimePaths(
60 path: Path.Path,
61 baseDir: string,
62 version: string,
63 platform: NodeJS.Platform,
64): PinnedRuntimePaths {
65 const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version);
66 return {
67 versionDir,
68 entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"),
69 sentinelPath: path.join(versionDir, ".install-complete"),
70 };
71}
72
73export class PinnedRuntimeInstallError extends Schema.TaggedError<PinnedRuntimeInstallError>()(
74 "PinnedRuntimeInstallError",
75 {
76 step: Schema.String,
77 exitCode: Schema.optional(Schema.Number),
78 stdoutLength: Schema.optional(Schema.Number),
79 stderrLength: Schema.optional(Schema.Number),
80 cause: Schema.optional(Schema.Defect()),
81 },
82) {
83 override get message(): string {
84 return this.exitCode === undefined
85 ? `Pinned runtime install failed while ${this.step}.`
86 : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`;
87 }
88}
89
90export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<PinnedRuntimePreflightBlockedError>()(
91 "PinnedRuntimePreflightBlockedError",
92 {
93 version: Schema.String,
94 reason: Schema.String,
95 },
96) {
97 override get message(): string {
98 return this.reason;
99 }
100}
101
102export type PinnedRuntimeProgress =
103 | { readonly stage: "download"; readonly received: number; readonly total: number | undefined }
104 | { readonly stage: "verify" | "extract" | "validate" | "cached" };
105
106/**
107 * Installs the t3 release archive for `version` into the pinned runtime
108 * directory unless a complete install is already there, and returns its
109 * paths. The sentinel is written only after extraction and validation
110 * succeed; checking the entry file alone is not enough, since tar writes the
111 * executable before the last native package and a killed install leaves a
112 * plausible-looking but broken tree behind.
113 */
114
115interface PinnedRuntimeInstallInput {
116 readonly baseDir: string;
117 readonly version: string;
118 readonly fs: FileSystem.FileSystem;
119 readonly path: Path.Path;
120 readonly runner: ProcessRunner.ProcessRunner["Service"];
121 readonly validate: (
122 paths: PinnedRuntimePaths,
123 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
124 readonly platform: NodeJS.Platform;
125 readonly arch: string;
126 readonly httpClient: HttpClient.HttpClient;
127 readonly releaseBaseUrl?: string | undefined;
128 readonly onProgress?: (progress: PinnedRuntimeProgress) => void;
129}
130
131const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (
132 httpClient: HttpClient.HttpClient,
133 url: string,
134 step: string,
135 onProgress?: (progress: PinnedRuntimeProgress) => void,
136) {
137 // The install lock is held for the whole transaction, so a stalled download
138 // must fail rather than block every other caller.
139 return yield* httpClient.execute(HttpClientRequest.get(url)).pipe(
140 Effect.flatMap(HttpClientResponse.filterStatusOk),
141 Effect.flatMap(
142 Effect.fn(function* (response) {
143 if (onProgress === undefined) return new Uint8Array(yield* response.arrayBuffer);
144 const length = Number(response.headers["content-length"]);
145 const total = Number.isFinite(length) && length > 0 ? length : undefined;
146 let received = 0;
147 onProgress({ stage: "download", received, total });
148 const chunks = yield* response.stream.pipe(
149 Stream.tap((chunk) =>
150 Effect.sync(() => {
151 received += chunk.byteLength;
152 onProgress({ stage: "download", received, total });
153 }),
154 ),
155 Stream.runCollect,
156 );
157 // A completed chunked response finally gives us its total size.
158 if (total === undefined && received > 0) {
159 onProgress({ stage: "download", received, total: received });
160 }
161 const bytes = new Uint8Array(received);
162 let offset = 0;
163 for (const chunk of chunks) {
164 bytes.set(chunk, offset);
165 offset += chunk.byteLength;
166 }
167 return bytes;
168 }),
169 ),
170 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })),
171 Effect.timeoutOrElse({
172 duration: PINNED_RUNTIME_INSTALL_TIMEOUT,
173 orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })),
174 }),
175 );
176});
177
178/**
179 * Downloads the release archive for this platform, verifies it against the
180 * release's checksum file, and unpacks it so the executable sits directly in
181 * the staging directory. Only `tar` is required on the host; every supported
182 * OS ships one that reads gzip and zip.
183 */
184const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* (
185 input: PinnedRuntimeInstallInput,
186 stagingDir: string,
187) {
188 const { fs, path } = input;
189 const platformKey = cliArchivePlatformKey(input.platform, input.arch);
190 if (platformKey === undefined) {
191 return yield* new PinnedRuntimeInstallError({
192 step: `selecting a t3 release archive for ${input.platform}-${input.arch}`,
193 });
194 }
195 const httpClient = input.httpClient;
196 const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl);
197 const fileName = cliArchiveFileName(input.version, platformKey);
198
199 input.onProgress?.({ stage: "download", received: 0, total: undefined });
200 const checksums = parseChecksums(
201 new TextDecoder().decode(
202 yield* fetchReleaseAsset(
203 httpClient,
204 `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`,
205 "downloading the t3 release checksums",
206 ),
207 ),
208 );
209 const expected = checksums.get(fileName);
210 if (expected === undefined) {
211 return yield* new PinnedRuntimeInstallError({
212 step: `finding ${fileName} in the t3 release checksums`,
213 });
214 }
215 const archive = yield* fetchReleaseAsset(
216 httpClient,
217 `${baseUrl}/${fileName}`,
218 "downloading the t3 release archive",
219 input.onProgress,
220 );
221 input.onProgress?.({ stage: "verify" });
222 const digest = yield* Effect.tryPromise({
223 try: () => crypto.subtle.digest("SHA-256", archive),
224 catch: (cause) =>
225 new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }),
226 });
227 if (Hex.encode(new Uint8Array(digest)) !== expected) {
228 return yield* new PinnedRuntimeInstallError({
229 step: "verifying the t3 release archive checksum",
230 });
231 }
232
233 const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE);
234 yield* fs
235 .writeFile(archivePath, archive)
236 .pipe(
237 Effect.mapError(
238 (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }),
239 ),
240 );
241 input.onProgress?.({ stage: "extract" });
242 const extractStep = "extracting the t3 release archive";
243 // The archive wraps everything in one directory named after its stem;
244 // strip it so the executable lands at <versionDir>/t3.
245 yield* input.runner
246 .run({
247 command: cliArchiveTarCommand(input.platform, process.env),
248 args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"],
249 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
250 })
251 .pipe(
252 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })),
253 Effect.filterOrFail(
254 (result) => result.code === 0,
255 (result) =>
256 new PinnedRuntimeInstallError({
257 step: extractStep,
258 exitCode: Number(result.code),
259 stdoutLength: result.stdout.length,
260 stderrLength: result.stderr.length,
261 }),
262 ),
263 );
264 yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore);
265});
266
267const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* (
268 input: PinnedRuntimeInstallInput,
269) {
270 const { fs } = input;
271 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform);
272 const [versionDirExists, entryExists, sentinel] = yield* Effect.all([
273 fs.exists(paths.versionDir),
274 fs.exists(paths.entryPath),
275 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
276 ]).pipe(
277 Effect.mapError(
278 (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }),
279 ),
280 );
281 const alreadyPinned =
282 entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version;
283 if (alreadyPinned) {
284 input.onProgress?.({ stage: "cached" });
285 yield* input.validate(paths);
286 return paths;
287 }
288 if (versionDirExists) {
289 yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(
290 Effect.mapError(
291 (cause) =>
292 new PinnedRuntimeInstallError({
293 step: "removing an incomplete pinned runtime",
294 cause,
295 }),
296 ),
297 );
298 }
299
300 const versionsDir = input.path.dirname(paths.versionDir);
301 yield* fs.makeDirectory(versionsDir, { recursive: true }).pipe(
302 Effect.mapError(
303 (cause) =>
304 new PinnedRuntimeInstallError({
305 step: "preparing the pinned runtime directory",
306 cause,
307 }),
308 ),
309 );
310 const stagingDir = yield* fs
311 .makeTempDirectory({
312 directory: versionsDir,
313 prefix: ".staging-",
314 })
315 .pipe(
316 Effect.mapError(
317 (cause) =>
318 new PinnedRuntimeInstallError({
319 step: "preparing the pinned runtime directory",
320 cause,
321 }),
322 ),
323 );
324 const stagingPaths: PinnedRuntimePaths = {
325 versionDir: stagingDir,
326 entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)),
327 sentinelPath: input.path.join(stagingDir, ".install-complete"),
328 };
329
330 return yield* Effect.gen(function* () {
331 yield* installFromArchive(input, stagingDir);
332
333 input.onProgress?.({ stage: "validate" });
334 yield* input.validate(stagingPaths);
335 yield* fs
336 .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`)
337 .pipe(
338 Effect.mapError(
339 (cause) =>
340 new PinnedRuntimeInstallError({ step: "recording the completed install", cause }),
341 ),
342 );
343 const published = yield* fs.rename(stagingDir, paths.versionDir).pipe(
344 Effect.as(true),
345 Effect.catch((cause) =>
346 Effect.all([
347 fs.exists(paths.entryPath),
348 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
349 ]).pipe(
350 Effect.mapError(
351 (checkCause) =>
352 new PinnedRuntimeInstallError({
353 step: "checking a concurrently published pinned runtime",
354 cause: checkCause,
355 }),
356 ),
357 Effect.flatMap(([publishedEntryExists, publishedSentinel]) =>
358 publishedEntryExists &&
359 Option.isSome(publishedSentinel) &&
360 publishedSentinel.value.trim() === input.version
361 ? Effect.succeed(false)
362 : Effect.fail(
363 new PinnedRuntimeInstallError({
364 step: "publishing the pinned runtime",
365 cause,
366 }),
367 ),
368 ),
369 ),
370 ),
371 );
372 if (!published) yield* input.validate(paths);
373 return paths;
374 }).pipe(
375 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
376 );
377});
378
379export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) =>
380 pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input));