1import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";
3
4import { compareSemverVersions } from "./semver.ts";
5
6const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code";
7export const CLI_RELEASES_PAGE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases`;
8export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS";
9export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL";
10
11// Keep archive keys aligned with the Linux matrix, Windows ARM64 build, and cli_archive rows in release.yml,
12// and with the SSH runner's host check in packages/ssh/src/tunnel.ts.
13// Node single-executables do not support darwin-x64 and segfault at startup.
14const CLI_ARCHIVE_PLATFORM_KEYS = [
15 "darwin-arm64",
16 "linux-arm64",
17 "linux-x64",
18 "win32-arm64",
19 "win32-x64",
20] as const;
21export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number];
22
23export function cliArchivePlatformKey(
24 platform: NodeJS.Platform,
25 arch: string,
26): CliArchivePlatformKey | undefined {
27 const key = `${platform}-${arch}`;
28 return CLI_ARCHIVE_PLATFORM_KEYS.find((candidate) => candidate === key);
29}
30
31/** Use System32 bsdtar on Windows; Git-for-Windows GNU tar cannot open zip archives. */
32export function cliArchiveTarCommand(
33 platform: NodeJS.Platform,
34 env: Readonly<Record<string, string | undefined>>,
35): string {
36 if (platform !== "win32") return "tar";
37 const systemRoot = env["SystemRoot"] ?? env["windir"] ?? "C:\\Windows";
38 return `${systemRoot}\\System32\\tar.exe`;
39}
40
41export function cliArchiveFileName(version: string, platformKey: CliArchivePlatformKey): string {
42 return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`;
43}
44
45const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`;
46
47export function cliReleaseDownloadBaseUrl(
48 version: string,
49 baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL,
50): string {
51 return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`;
52}
53
54/** `segments` added to `baseUrl`'s path, keeping its query, such as a signed mirror URL's token. */
55export function releaseAssetUrl(baseUrl: string, ...segments: ReadonlyArray<string>): string {
56 const url = new URL(baseUrl);
57 url.pathname = [url.pathname.replace(/\/+$/, ""), ...segments.map(encodeURIComponent)].join("/");
58 return url.toString();
59}
60
61/** A file of release `version`, from `baseUrl` laid out as GitHub's release downloads. */
62export function cliReleaseAssetUrl(
63 version: string,
64 fileName: string,
65 baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL,
66): string {
67 return releaseAssetUrl(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL, `v${version}`, fileName);
68}
69
70export function parseChecksums(text: string): ReadonlyMap<string, string> {
71 const checksums = new Map<string, string>();
72 for (const line of text.split(/\r?\n/)) {
73 const match = /^([0-9a-fA-F]{64})\s+\*?(\S.*)$/.exec(line.trim());
74 if (match?.[1] !== undefined && match[2] !== undefined) {
75 checksums.set(match[2], match[1].toLowerCase());
76 }
77 }
78 return checksums;
79}
80
81export type CliReleaseChannel = "stable" | "nightly" | "preview";
82export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [
83 "stable",
84 "nightly",
85 "preview",
86];
87
88export function cliReleaseChannelOf(version: string): CliReleaseChannel {
89 const channel = /^[^-+]+-(nightly|preview)\.\d{8}\.\d+$/.exec(version)?.[1];
90 return channel === "nightly" || channel === "preview" ? channel : "stable";
91}
92
93/** Whether updating between these versions crosses Stable and Nightly. Preview builds cross nothing.
94 * Hosts refuse a crossing outside a track switch, which checks the database first. */
95export function crossesReleaseTrack(fromVersion: string, toVersion: string): boolean {
96 const from = cliReleaseChannelOf(fromVersion);
97 const to = cliReleaseChannelOf(toVersion);
98 return from !== to && from !== "preview" && to !== "preview";
99}
100
101/** The desktop update track a build belongs to. Preview builds have no feed and read as Stable. */
102export function desktopUpdateChannelOf(version: string): "latest" | "nightly" {
103 return cliReleaseChannelOf(version) === "nightly" ? "nightly" : "latest";
104}
105
106/**
107 * The update feed file a desktop app on this platform reads, which an update mirror publishes at
108 * its root beside the release's CLI archives.
109 */
110export function updateFeedFileName(
111 channel: "latest" | "nightly",
112 platform: NodeJS.Platform,
113 arch: string,
114): string {
115 if (platform === "darwin") return `${channel}-mac.yml`;
116 if (platform === "linux")
117 return arch === "arm64" ? `${channel}-linux-arm64.yml` : `${channel}-linux.yml`;
118 return `${channel}.yml`;
119}
120
121/** Whether a track switch may install `found` after its database check cleared `checked`: any build
122 * on the switch's track at least as new, since later releases only add migrations. */
123export function acceptsTrackSwitchRelease(
124 found: string,
125 channel: "latest" | "nightly",
126 checked: string,
127): boolean {
128 return (
129 cliReleaseChannelOf(found) === (channel === "nightly" ? "nightly" : "stable") &&
130 compareSemverVersions(found, checked) >= 0
131 );
132}
133
134/** Page until the channel matches; nightlies can push preview/stable releases past the first page. */
135export function cliReleaseIndexPageUrl(page: number): string {
136 return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`;
137}
138
139/** Draft assets are not downloadable. */
140export function newestCliReleaseVersion(
141 releases: ReadonlyArray<{
142 readonly tag_name: string;
143 readonly draft?: boolean | undefined;
144 }>,
145 channel: CliReleaseChannel,
146): string | undefined {
147 for (const release of releases) {
148 if (release.draft) continue;
149 const version = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(release.tag_name)?.[1];
150 if (version === undefined) continue;
151 if (cliReleaseChannelOf(version) === channel) return version;
152 }
153 return undefined;
154}
155
156const NpmReleaseMigrationManifest = Schema.Struct({
157 t3code: Schema.Struct({ databaseMigration: Schema.Int }),
158});
159const decodeNpmReleaseMigrationManifest = Schema.decodeUnknownOption(NpmReleaseMigrationManifest);
160
161/** Highest database migration a release's server knows, from its npm `t3` manifest. */
162export function npmReleaseDatabaseMigration(manifest: unknown): number | undefined {
163 return Option.getOrUndefined(decodeNpmReleaseMigrationManifest(manifest))?.t3code
164 .databaseMigration;
165}