| 1 | // @effect-diagnostics nodeBuiltinImport:off - Release validation reads local archives at the CLI boundary. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeOS from "node:os"; |
| 6 | import * as NodePath from "node:path"; |
| 7 | import * as NodeURL from "node:url"; |
| 8 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | import * as Clock from "effect/Clock"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Data from "effect/Data"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as Stream from "effect/Stream"; |
| 16 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; |
| 17 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 18 | import type { HttpMethod } from "effect/http/HttpMethod"; |
| 19 | import { extractAll } from "@electron/asar"; |
| 20 | |
| 21 | import { |
| 22 | ForkReleaseManifest, |
| 23 | ForkReleaseListing, |
| 24 | ForkRuntimeManifest, |
| 25 | latestForkRelease, |
| 26 | } from "@t3tools/shared/forkUpdateDirectory"; |
| 27 | |
| 28 | import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts"; |
| 29 | import { fingerprintForkServerRuntime } from "./lib/fork-runtime.ts"; |
| 30 | import { loadRepoEnv } from "./lib/public-config.ts"; |
| 31 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 32 | |
| 33 | const ReleaseConfig = Schema.Struct({ |
| 34 | feedUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)), |
| 35 | uploadUrl: Schema.String.check(Schema.isPattern(/^https:\/\//)), |
| 36 | signingIdentity: Schema.String.check(Schema.isPattern(/^[A-Fa-f0-9]{40}$/)), |
| 37 | signingKeychain: Schema.NonEmptyString, |
| 38 | notarizationKeychainProfile: Schema.optionalKey(Schema.NonEmptyString), |
| 39 | uploadApiKey: Schema.optionalKey(Schema.NonEmptyString), |
| 40 | }); |
| 41 | type ReleaseConfig = typeof ReleaseConfig.Type; |
| 42 | |
| 43 | class ForkReleaseError extends Data.TaggedError("ForkReleaseError")<{ |
| 44 | readonly message: string; |
| 45 | }> {} |
| 46 | |
| 47 | class ForkReleaseCollision extends Data.TaggedError("ForkReleaseCollision")<{ |
| 48 | readonly message: string; |
| 49 | }> {} |
| 50 | |
| 51 | const repoRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); |
| 52 | |
| 53 | const run = Effect.fn("fork.release.run")(function* ( |
| 54 | command: string, |
| 55 | args: string[], |
| 56 | options: { env?: NodeJS.ProcessEnv; capture?: boolean } = {}, |
| 57 | ) { |
| 58 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 59 | const child = yield* spawner.spawn( |
| 60 | ChildProcess.make(command, args, { |
| 61 | cwd: repoRoot, |
| 62 | env: options.env, |
| 63 | stdout: options.capture ? "pipe" : "inherit", |
| 64 | stderr: "inherit", |
| 65 | stdin: "ignore", |
| 66 | }), |
| 67 | ); |
| 68 | const [chunks, code] = yield* Effect.all([Stream.runCollect(child.stdout), child.exitCode], { |
| 69 | concurrency: "unbounded", |
| 70 | }); |
| 71 | if (Number(code) !== 0) |
| 72 | return yield* new ForkReleaseError({ message: `${command} exited with code ${code}.` }); |
| 73 | const decoder = new TextDecoder(); |
| 74 | return chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + decoder.decode(); |
| 75 | }); |
| 76 | |
| 77 | const request = Effect.fn("fork.release.request")(function* ( |
| 78 | input: HttpClientRequest.HttpClientRequest, |
| 79 | ) { |
| 80 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); |
| 81 | const response = yield* http.execute(input).pipe( |
| 82 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), |
| 83 | Effect.mapError(() => new ForkReleaseError({ message: "Could not reach the release share." })), |
| 84 | ); |
| 85 | if ( |
| 86 | (response.status < 200 || response.status >= 300) && |
| 87 | !(input.method === "DELETE" && response.status === 404) |
| 88 | ) { |
| 89 | return yield* new ForkReleaseError({ |
| 90 | message: `Release share returned HTTP ${response.status}.`, |
| 91 | }); |
| 92 | } |
| 93 | return response; |
| 94 | }); |
| 95 | |
| 96 | const desktopTargets = { |
| 97 | darwin: { manifest: "latest-mac.yml", channel: "-mac", label: "macOS", extension: ".zip" }, |
| 98 | linux: { |
| 99 | manifest: "latest-linux.yml", |
| 100 | channel: "-linux", |
| 101 | label: "Linux", |
| 102 | extension: ".AppImage", |
| 103 | }, |
| 104 | win32: { manifest: "latest.yml", channel: "", label: "Windows", extension: ".exe" }, |
| 105 | }; |
| 106 | |
| 107 | const installerSuffix = (platform: keyof typeof desktopTargets, arch: string) => |
| 108 | `-${platform === "linux" && arch === "x64" ? "x86_64" : arch}${desktopTargets[platform].extension}`; |
| 109 | |
| 110 | const verifyDesktopRelease = ( |
| 111 | directory: string, |
| 112 | platform: keyof typeof desktopTargets = "darwin", |
| 113 | ) => |
| 114 | Effect.tryPromise({ |
| 115 | try: async () => { |
| 116 | const target = desktopTargets[platform]; |
| 117 | const raw = await NodeFSP.readFile(NodePath.join(directory, target.manifest), "utf8"); |
| 118 | const manifest = parseUpdateManifest(raw, target.manifest, target.label); |
| 119 | if (!/^0\.0\.\d+$/.test(manifest.version)) |
| 120 | throw new ForkReleaseError({ message: "Not a private fork release version." }); |
| 121 | if (!manifest.files.some((file) => file.url.endsWith(target.extension))) { |
| 122 | throw new ForkReleaseError({ message: `The release has no ${target.label} installer.` }); |
| 123 | } |
| 124 | const files: string[] = []; |
| 125 | for (const file of manifest.files) { |
| 126 | if ( |
| 127 | NodePath.basename(file.url) !== file.url || |
| 128 | (!file.url.startsWith(`The-Knife-${manifest.version}-`) && |
| 129 | !["arm64", "x64"].some( |
| 130 | (arch) => file.url === `The-Knife-${platform}-${arch}${target.extension}`, |
| 131 | )) |
| 132 | ) { |
| 133 | throw new ForkReleaseError({ |
| 134 | message: "The release manifest contains an unexpected artifact path.", |
| 135 | }); |
| 136 | } |
| 137 | const artifact = NodePath.join(directory, file.url); |
| 138 | const stat = await NodeFSP.stat(artifact); |
| 139 | const hash = NodeCrypto.createHash("sha512"); |
| 140 | for await (const chunk of NodeFS.createReadStream(artifact)) hash.update(chunk); |
| 141 | if (stat.size !== file.size || hash.digest("base64") !== file.sha512) { |
| 142 | throw new ForkReleaseError({ message: `Artifact verification failed: ${file.url}.` }); |
| 143 | } |
| 144 | files.push(file.url); |
| 145 | if ( |
| 146 | await NodeFSP.stat(`${artifact}.blockmap`).then( |
| 147 | () => true, |
| 148 | () => false, |
| 149 | ) |
| 150 | ) |
| 151 | files.push(`${file.url}.blockmap`); |
| 152 | } |
| 153 | return { manifest, raw, files }; |
| 154 | }, |
| 155 | catch: (cause) => |
| 156 | cause instanceof ForkReleaseError |
| 157 | ? cause |
| 158 | : new ForkReleaseError({ message: "Could not verify the release artifacts." }), |
| 159 | }); |
| 160 | |
| 161 | const decodeRelease = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkReleaseManifest)); |
| 162 | const encodeRelease = Schema.encodeEffect(Schema.fromJsonString(ForkReleaseManifest)); |
| 163 | const PublicationIntentJson = Schema.fromJsonString( |
| 164 | Schema.Struct({ |
| 165 | release: ForkReleaseManifest, |
| 166 | retiredFiles: Schema.Array(Schema.String), |
| 167 | }), |
| 168 | ); |
| 169 | const decodePublicationIntent = Schema.decodeUnknownEffect(PublicationIntentJson); |
| 170 | const encodePublicationIntent = Schema.encodeEffect(PublicationIntentJson); |
| 171 | const decodeListing = Schema.decodeUnknownEffect(ForkReleaseListing); |
| 172 | |
| 173 | const completedVersions = (listing: typeof ForkReleaseListing.Type) => |
| 174 | listing.files |
| 175 | .flatMap((file) => { |
| 176 | const version = latestForkRelease({ files: [file] }); |
| 177 | return version ? [version] : []; |
| 178 | }) |
| 179 | .sort((a, b) => (BigInt(a.slice(4)) > BigInt(b.slice(4)) ? -1 : 1)); |
| 180 | |
| 181 | const readReleaseListing = (feed: URL) => |
| 182 | Effect.gen(function* () { |
| 183 | const response = yield* request(HttpClientRequest.get(new URL("?ls", feed).href)); |
| 184 | return yield* decodeListing(yield* response.json); |
| 185 | }); |
| 186 | |
| 187 | const readPublishedRelease = (feed: URL, version: string) => |
| 188 | Effect.gen(function* () { |
| 189 | const manifest = yield* request(HttpClientRequest.get(new URL(`${version}.json`, feed).href)); |
| 190 | const release = yield* decodeRelease(yield* manifest.text); |
| 191 | if (release.version !== version) |
| 192 | return yield* new ForkReleaseError({ |
| 193 | message: "The published manifest has the wrong version.", |
| 194 | }); |
| 195 | return release; |
| 196 | }); |
| 197 | |
| 198 | const previousRelease = (feed: URL) => |
| 199 | Effect.scoped( |
| 200 | Effect.gen(function* () { |
| 201 | const version = latestForkRelease(yield* readReleaseListing(feed)); |
| 202 | return version ? yield* readPublishedRelease(feed, version) : undefined; |
| 203 | }), |
| 204 | ); |
| 205 | |
| 206 | const artifact = (directory: string, file: string) => |
| 207 | Effect.tryPromise(async () => { |
| 208 | const source = NodePath.join(directory, file); |
| 209 | const hash = NodeCrypto.createHash("sha256"); |
| 210 | for await (const chunk of NodeFS.createReadStream(source)) hash.update(chunk); |
| 211 | return { file, sha256: hash.digest("hex"), size: (await NodeFSP.stat(source)).size }; |
| 212 | }); |
| 213 | |
| 214 | const publicReadme = (release: typeof ForkReleaseManifest.Type, serverAvailable: boolean) => { |
| 215 | const instructions = new Set<string>(); |
| 216 | const downloads = Object.keys(release.desktop).flatMap((key) => { |
| 217 | const [platform, arch] = key.split("-"); |
| 218 | if (platform !== "darwin" && platform !== "linux" && platform !== "win32") return []; |
| 219 | const architecture = |
| 220 | arch === "arm64" |
| 221 | ? platform === "darwin" |
| 222 | ? "Apple silicon" |
| 223 | : "ARM64" |
| 224 | : platform === "darwin" |
| 225 | ? "Intel" |
| 226 | : "x64"; |
| 227 | const install = |
| 228 | platform === "darwin" |
| 229 | ? "Unzip, then move The Knife to Applications. If macOS blocks the first launch, see [Apple’s opening instructions](https://support.apple.com/102445)." |
| 230 | : platform === "linux" |
| 231 | ? "Make the AppImage executable, then open it." |
| 232 | : "Run the installer."; |
| 233 | instructions.add(`**${desktopTargets[platform].label}:** ${install}`); |
| 234 | return [ |
| 235 | `<a href="The-Knife-${key}${desktopTargets[platform].extension}">${desktopTargets[platform].label} ${architecture}</a>`, |
| 236 | ]; |
| 237 | }); |
| 238 | return [ |
| 239 | "## The Knife", |
| 240 | "", |
| 241 | "A focused workspace for coding agents, based on [T3 Code](https://t3.codes). Bring your own agent subscriptions and keep your work on your machines.", |
| 242 | "", |
| 243 | ...(downloads.length > 0 |
| 244 | ? [ |
| 245 | `<p align="center">${downloads.join(" &nbsp; · &nbsp; ")}</p>`, |
| 246 | "", |
| 247 | [...instructions].join(" "), |
| 248 | "", |
| 249 | ] |
| 250 | : []), |
| 251 | "Updates download automatically. The app asks before restarting; UI updates apply when your work can be preserved.", |
| 252 | "", |
| 253 | ...(serverAvailable |
| 254 | ? [ |
| 255 | "**Server:** [Docker bundle](knife-server-linux-amd64.tar.gz) · [SHA-256](knife-server-linux-amd64.tar.gz.sha256). Includes the image and configuration for a Linux x64 host.", |
| 256 | "", |
| 257 | ] |
| 258 | : []), |
| 259 | ].join("\n"); |
| 260 | }; |
| 261 | |
| 262 | const releaseArtifacts = (release: typeof ForkReleaseManifest.Type) => [ |
| 263 | release.ui, |
| 264 | release.assets, |
| 265 | release.server, |
| 266 | ...Object.values(release.desktop).flatMap((desktop) => [ |
| 267 | desktop.ui, |
| 268 | desktop.assets, |
| 269 | desktop.code, |
| 270 | desktop.app, |
| 271 | ]), |
| 272 | ]; |
| 273 | |
| 274 | const releaseFiles = (release: typeof ForkReleaseManifest.Type) => { |
| 275 | const files = [ |
| 276 | ...releaseArtifacts(release).map((artifact) => artifact.file), |
| 277 | `${release.version}.json`, |
| 278 | ]; |
| 279 | for (const [key, desktop] of Object.entries(release.desktop)) { |
| 280 | files.push(`${desktop.app.file}.blockmap`); |
| 281 | const platform = key.slice(0, key.lastIndexOf("-")); |
| 282 | if (platform === "darwin" || platform === "linux" || platform === "win32") |
| 283 | files.push(`${desktop.app.version}${desktopTargets[platform].channel}.yml`); |
| 284 | } |
| 285 | return files; |
| 286 | }; |
| 287 | |
| 288 | const maintainPublicFolder = Effect.fn("fork.release.maintainPublicFolder")(function* ( |
| 289 | release: typeof ForkReleaseManifest.Type, |
| 290 | feedBase: URL, |
| 291 | uploadBase: URL, |
| 292 | apiKey: string | undefined, |
| 293 | completed: ReadonlyArray<string>, |
| 294 | retiredFiles: ReadonlySet<string>, |
| 295 | ) { |
| 296 | const authenticated = (input: HttpClientRequest.HttpClientRequest) => |
| 297 | input.pipe(HttpClientRequest.setHeaders(apiKey ? { PW: apiKey } : {})); |
| 298 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); |
| 299 | const initialVersions = completedVersions(yield* readReleaseListing(feedBase)); |
| 300 | if (initialVersions.join("\n") !== completed.join("\n")) return false; |
| 301 | for (const key of Object.keys(release.desktop)) { |
| 302 | const oldPage = `The-Knife-${key}.html`; |
| 303 | const page = yield* http.head(new URL(oldPage, feedBase).href); |
| 304 | if (page.status !== 200 && page.status !== 404) |
| 305 | return yield* new ForkReleaseError({ |
| 306 | message: `Release share returned HTTP ${page.status}.`, |
| 307 | }); |
| 308 | if (page.status === 200) { |
| 309 | const removed = yield* request( |
| 310 | authenticated(HttpClientRequest.make("DELETE")(new URL(oldPage, uploadBase).href)), |
| 311 | ); |
| 312 | yield* removed.arrayBuffer; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | const server = yield* http.head(new URL("knife-server-linux-amd64.tar.gz", feedBase).href); |
| 317 | if (server.status !== 200 && server.status !== 404) |
| 318 | return yield* new ForkReleaseError({ |
| 319 | message: `Release share returned HTTP ${server.status}.`, |
| 320 | }); |
| 321 | const readme = publicReadme(release, server.status === 200); |
| 322 | const existing = yield* http.get(new URL("preadme.md", feedBase).href); |
| 323 | if (existing.status !== 200 || (yield* existing.text) !== readme) { |
| 324 | if (existing.status !== 200 && existing.status !== 404) |
| 325 | return yield* new ForkReleaseError({ |
| 326 | message: `Release share returned HTTP ${existing.status}.`, |
| 327 | }); |
| 328 | const uploaded = yield* request( |
| 329 | authenticated( |
| 330 | HttpClientRequest.put(new URL("preadme.md", uploadBase).href).pipe( |
| 331 | HttpClientRequest.setHeader("Replace", "1"), |
| 332 | HttpClientRequest.bodyText(readme, "text/markdown; charset=utf-8"), |
| 333 | ), |
| 334 | ), |
| 335 | ); |
| 336 | yield* uploaded.arrayBuffer; |
| 337 | const readable = yield* request(HttpClientRequest.get(new URL("preadme.md", feedBase).href)); |
| 338 | if ((yield* readable.text) !== readme) |
| 339 | return yield* new ForkReleaseError({ |
| 340 | message: "The download page could not be updated. Retry publication.", |
| 341 | }); |
| 342 | } |
| 343 | const listing = yield* readReleaseListing(feedBase); |
| 344 | let versions = completedVersions(listing); |
| 345 | if (versions.join("\n") !== completed.join("\n")) return false; |
| 346 | |
| 347 | const retained = new Set(releaseFiles(release)); |
| 348 | for (const version of versions.slice(0, 2)) { |
| 349 | for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) |
| 350 | retained.add(file); |
| 351 | } |
| 352 | const obsolete = new Set([...retiredFiles].filter((file) => !retained.has(file))); |
| 353 | for (const version of versions.slice(2)) { |
| 354 | for (const file of releaseFiles(yield* readPublishedRelease(feedBase, version))) { |
| 355 | if ( |
| 356 | !retained.has(file) && |
| 357 | (/^knife-(?:ui|assets|server|code)-[a-f0-9]{64}\.tar\.gz$/.test(file) || |
| 358 | /^The-Knife-0\.0\.\d+-[\w.-]+\.(?:zip|exe|AppImage)(?:\.blockmap)?$/.test(file) || |
| 359 | /^0\.0\.\d+(?:-mac|-linux)?\.(?:json|yml)$/.test(file)) |
| 360 | ) |
| 361 | obsolete.add(file); |
| 362 | } |
| 363 | } |
| 364 | const visible = new Set(listing.files.map((file) => file.href)); |
| 365 | const deletions = [...obsolete].sort( |
| 366 | (a, b) => Number(a.endsWith(".json")) - Number(b.endsWith(".json")), |
| 367 | ); |
| 368 | for (const file of deletions) { |
| 369 | if (!visible.has(file)) continue; |
| 370 | const currentVersions = completedVersions(yield* readReleaseListing(feedBase)); |
| 371 | if (currentVersions.join("\n") !== versions.join("\n")) return false; |
| 372 | const deleted = yield* request( |
| 373 | authenticated( |
| 374 | HttpClientRequest.make("DELETE")(new URL(encodeURIComponent(file), uploadBase).href), |
| 375 | ), |
| 376 | ); |
| 377 | yield* deleted.arrayBuffer; |
| 378 | if (file.endsWith(".json")) versions = versions.filter((version) => `${version}.json` !== file); |
| 379 | } |
| 380 | return true; |
| 381 | }); |
| 382 | |
| 383 | export const publishForkRelease = Effect.fn("fork.release.publish")(function* ( |
| 384 | directory: string, |
| 385 | config: Pick<ReleaseConfig, "feedUrl" | "uploadUrl" | "uploadApiKey">, |
| 386 | ) { |
| 387 | const raw = yield* Effect.tryPromise(() => |
| 388 | NodeFSP.readFile(NodePath.join(directory, "release.json"), "utf8"), |
| 389 | ); |
| 390 | const built = yield* decodeRelease(raw); |
| 391 | for (const expected of releaseArtifacts(built)) { |
| 392 | const actual = yield* artifact(directory, expected.file).pipe( |
| 393 | Effect.mapError( |
| 394 | () => |
| 395 | new ForkReleaseError({ |
| 396 | message: `Could not verify the local artifact: ${expected.file}.`, |
| 397 | }), |
| 398 | ), |
| 399 | ); |
| 400 | if (actual.sha256 !== expected.sha256 || actual.size !== expected.size) |
| 401 | return yield* new ForkReleaseError({ |
| 402 | message: `Artifact verification failed: ${expected.file}.`, |
| 403 | }); |
| 404 | } |
| 405 | const desktopReleases = new Map< |
| 406 | keyof typeof desktopTargets, |
| 407 | { |
| 408 | manifest: ReturnType<typeof parseUpdateManifest>; |
| 409 | raw: string; |
| 410 | files: string[]; |
| 411 | } |
| 412 | >(); |
| 413 | for (const [key, desktop] of Object.entries(built.desktop)) { |
| 414 | const platform = key.slice(0, key.lastIndexOf("-")); |
| 415 | if (platform !== "darwin" && platform !== "linux" && platform !== "win32") |
| 416 | return yield* new ForkReleaseError({ message: `Unsupported desktop platform: ${key}.` }); |
| 417 | let desktopRelease = desktopReleases.get(platform); |
| 418 | if (!desktopRelease) { |
| 419 | desktopRelease = yield* verifyDesktopRelease(directory, platform); |
| 420 | desktopReleases.set(platform, desktopRelease); |
| 421 | } |
| 422 | if ( |
| 423 | desktopRelease.manifest.version !== desktop.version || |
| 424 | desktop.app.version !== desktop.version || |
| 425 | !desktopRelease.files.includes(desktop.app.file) |
| 426 | ) |
| 427 | return yield* new ForkReleaseError({ |
| 428 | message: "The local app does not match its updater manifest.", |
| 429 | }); |
| 430 | } |
| 431 | |
| 432 | const intentPath = NodePath.join(directory, "publication.json"); |
| 433 | const intent = NodeFS.existsSync(intentPath) |
| 434 | ? yield* Effect.tryPromise(() => NodeFSP.readFile(intentPath, "utf8")).pipe( |
| 435 | Effect.flatMap(decodePublicationIntent), |
| 436 | ) |
| 437 | : undefined; |
| 438 | if (intent !== undefined && intent.release.version !== built.version) |
| 439 | return yield* new ForkReleaseError({ |
| 440 | message: "The publication intent belongs to a different release.", |
| 441 | }); |
| 442 | const uploadBase = new URL( |
| 443 | config.uploadUrl.endsWith("/") ? config.uploadUrl : `${config.uploadUrl}/`, |
| 444 | ); |
| 445 | const feedBase = new URL(config.feedUrl.endsWith("/") ? config.feedUrl : `${config.feedUrl}/`); |
| 446 | const previous = yield* previousRelease(feedBase); |
| 447 | if (previous && BigInt(previous.version.slice(4)) > BigInt(built.version.slice(4))) |
| 448 | return yield* new ForkReleaseError({ |
| 449 | message: "This release or a newer version is already published. Build a new version.", |
| 450 | }); |
| 451 | for (const [platform, desktop] of Object.entries(built.desktop)) { |
| 452 | const prior = previous?.desktop[platform]; |
| 453 | if (prior && BigInt(prior.version.slice(4)) > BigInt(desktop.version.slice(4))) |
| 454 | return yield* new ForkReleaseError({ |
| 455 | message: `A newer native build is already published for ${platform}.`, |
| 456 | }); |
| 457 | } |
| 458 | |
| 459 | const serverRelease = |
| 460 | previous && BigInt(previous.server.version.slice(4)) > BigInt(built.server.version.slice(4)) |
| 461 | ? previous |
| 462 | : built; |
| 463 | const release = intent?.release ?? { |
| 464 | ...built, |
| 465 | ui: serverRelease.ui, |
| 466 | assets: serverRelease.assets, |
| 467 | server: serverRelease.server, |
| 468 | desktop: { |
| 469 | ...previous?.desktop, |
| 470 | ...Object.fromEntries( |
| 471 | Object.entries(built.desktop).map(([platform, desktop]) => { |
| 472 | const prior = previous?.desktop[platform]; |
| 473 | const nativePlatform = platform.slice(0, platform.lastIndexOf("-")); |
| 474 | if ( |
| 475 | nativePlatform !== "darwin" && |
| 476 | nativePlatform !== "linux" && |
| 477 | nativePlatform !== "win32" |
| 478 | ) |
| 479 | throw new ForkReleaseError({ message: `Unsupported desktop platform: ${platform}.` }); |
| 480 | const file = `The-Knife-${platform}${desktopTargets[nativePlatform].extension}`; |
| 481 | return [ |
| 482 | platform, |
| 483 | { |
| 484 | ...desktop, |
| 485 | code: prior?.runtime === desktop.runtime ? prior.code : desktop.code, |
| 486 | app: { |
| 487 | ...(prior?.shell === desktop.shell && prior.app.file === file |
| 488 | ? prior.app |
| 489 | : desktop.app), |
| 490 | file, |
| 491 | }, |
| 492 | }, |
| 493 | ]; |
| 494 | }), |
| 495 | ), |
| 496 | }, |
| 497 | }; |
| 498 | const encoded = yield* encodeRelease(release); |
| 499 | if (previous?.version === release.version) { |
| 500 | if ((yield* encodeRelease(previous)) !== encoded) |
| 501 | return yield* new ForkReleaseError({ |
| 502 | message: `Published manifest has different contents: ${release.version}.json.`, |
| 503 | }); |
| 504 | } |
| 505 | const sidecars = []; |
| 506 | const sources = new Map<string, string>(); |
| 507 | for (const [key, desktop] of Object.entries(built.desktop)) { |
| 508 | const target = release.desktop[key]!; |
| 509 | if (target.app.sha256 !== desktop.app.sha256) continue; |
| 510 | sources.set(target.app.file, desktop.app.file); |
| 511 | const file = `${desktop.app.file}.blockmap`; |
| 512 | if ( |
| 513 | yield* Effect.tryPromise(() => NodeFSP.stat(NodePath.join(directory, file))).pipe( |
| 514 | Effect.map((stat) => stat.isFile()), |
| 515 | Effect.orElseSucceed(() => false), |
| 516 | ) |
| 517 | ) { |
| 518 | const sidecar = yield* artifact(directory, file); |
| 519 | const name = `${target.app.file}.blockmap`; |
| 520 | sidecars.push({ ...sidecar, file: name }); |
| 521 | sources.set(name, file); |
| 522 | } |
| 523 | } |
| 524 | const files = new Map( |
| 525 | [...releaseArtifacts(release), ...sidecars].map((entry) => [entry.file, entry]), |
| 526 | ); |
| 527 | const mutableFiles = new Set([ |
| 528 | ...Object.values(release.desktop).map((desktop) => desktop.app.file), |
| 529 | ...sidecars.map((sidecar) => sidecar.file), |
| 530 | ]); |
| 531 | const retiredFiles = new Set(intent?.retiredFiles); |
| 532 | |
| 533 | const upload = (name: string, body: HttpClientRequest.HttpClientRequest) => |
| 534 | request( |
| 535 | body.pipe( |
| 536 | HttpClientRequest.setUrl(new URL(encodeURIComponent(name), uploadBase).href), |
| 537 | HttpClientRequest.setHeaders(config.uploadApiKey ? { PW: config.uploadApiKey } : {}), |
| 538 | ), |
| 539 | ).pipe( |
| 540 | Effect.flatMap((response) => response.arrayBuffer), |
| 541 | Effect.asVoid, |
| 542 | ); |
| 543 | |
| 544 | const http = HttpClient.withScope(yield* HttpClient.HttpClient); |
| 545 | const publishManifest = (name: string, contents: string, contentType: string) => |
| 546 | Effect.gen(function* () { |
| 547 | const existing = yield* http.get(new URL(name, feedBase).href); |
| 548 | if (existing.status === 200) { |
| 549 | if ((yield* existing.text) === contents) return; |
| 550 | return yield* new ForkReleaseCollision({ |
| 551 | message: `Published manifest has different contents: ${name}.`, |
| 552 | }); |
| 553 | } |
| 554 | if (existing.status !== 404) |
| 555 | return yield* new ForkReleaseError({ |
| 556 | message: `Release share returned HTTP ${existing.status}.`, |
| 557 | }); |
| 558 | yield* upload( |
| 559 | name, |
| 560 | HttpClientRequest.put("").pipe(HttpClientRequest.bodyText(contents, contentType)), |
| 561 | ); |
| 562 | const readable = yield* request(HttpClientRequest.get(new URL(name, feedBase).href)); |
| 563 | if ((yield* readable.text) !== contents) |
| 564 | return yield* new ForkReleaseCollision({ |
| 565 | message: `Published manifest has different contents: ${name}.`, |
| 566 | }); |
| 567 | }); |
| 568 | const matchesDownload = Effect.fn("fork.release.matchesDownload")(function* ( |
| 569 | name: string, |
| 570 | expected: { readonly size: number; readonly sha256: string }, |
| 571 | ) { |
| 572 | const response = yield* http.get(new URL(name, feedBase).href); |
| 573 | if (response.status === 404) return false; |
| 574 | if (response.status !== 200) |
| 575 | return yield* new ForkReleaseError({ |
| 576 | message: `Release share returned HTTP ${response.status}.`, |
| 577 | }); |
| 578 | const hash = NodeCrypto.createHash("sha256"); |
| 579 | let size = 0; |
| 580 | yield* Stream.runForEach(response.stream, (bytes) => |
| 581 | Effect.sync(() => { |
| 582 | hash.update(bytes); |
| 583 | size += bytes.length; |
| 584 | }), |
| 585 | ); |
| 586 | return size === expected.size && hash.digest("hex") === expected.sha256; |
| 587 | }); |
| 588 | const remove = (name: string) => upload(name, HttpClientRequest.make("DELETE")("")); |
| 589 | const retireConflictingReleases = Effect.fn("fork.release.retireConflictingReleases")( |
| 590 | function* () { |
| 591 | const listing = yield* readReleaseListing(feedBase); |
| 592 | const visible = new Set(listing.files.map((file) => file.href)); |
| 593 | const completed = []; |
| 594 | for (const version of completedVersions(listing)) |
| 595 | completed.push(yield* readPublishedRelease(feedBase, version)); |
| 596 | if ( |
| 597 | completed.some( |
| 598 | (completed) => BigInt(completed.version.slice(4)) > BigInt(release.version.slice(4)), |
| 599 | ) |
| 600 | ) |
| 601 | return yield* new ForkReleaseError({ |
| 602 | message: |
| 603 | "A newer release completed during installer publication. Retry with a new version.", |
| 604 | }); |
| 605 | const retained = completed.filter((completed) => |
| 606 | Object.entries(completed.desktop).every(([key, target]) => { |
| 607 | const current = release.desktop[key]; |
| 608 | return ( |
| 609 | current === undefined || |
| 610 | (current.app.file === target.app.file && |
| 611 | current.app.sha256 === target.app.sha256 && |
| 612 | current.app.size === target.app.size) |
| 613 | ); |
| 614 | }), |
| 615 | ); |
| 616 | const protectedFiles = new Set([...releaseFiles(release), ...retained.flatMap(releaseFiles)]); |
| 617 | const conflicting = completed.filter((completed) => !retained.includes(completed)); |
| 618 | if (conflicting.length > 0) { |
| 619 | const retired = new Set([...retiredFiles, ...conflicting.flatMap(releaseFiles)]); |
| 620 | const encodedIntent = yield* encodePublicationIntent({ |
| 621 | release, |
| 622 | retiredFiles: [...retired], |
| 623 | }); |
| 624 | yield* Effect.tryPromise(async () => { |
| 625 | await NodeFSP.writeFile(`${intentPath}.tmp`, encodedIntent + "\n"); |
| 626 | await NodeFSP.rename(`${intentPath}.tmp`, intentPath); |
| 627 | }); |
| 628 | for (const file of retired) retiredFiles.add(file); |
| 629 | } |
| 630 | for (const completedRelease of conflicting) yield* remove(`${completedRelease.version}.json`); |
| 631 | for (const file of retiredFiles) { |
| 632 | if (file.endsWith(".yml") && !protectedFiles.has(file) && visible.has(file)) |
| 633 | yield* remove(file); |
| 634 | } |
| 635 | }, |
| 636 | ); |
| 637 | const publicationOrder = [...files.values()].toSorted( |
| 638 | (left, right) => Number(mutableFiles.has(left.file)) - Number(mutableFiles.has(right.file)), |
| 639 | ); |
| 640 | for (const expected of publicationOrder) { |
| 641 | const mutable = mutableFiles.has(expected.file); |
| 642 | if (mutable) { |
| 643 | if (yield* matchesDownload(expected.file, expected)) continue; |
| 644 | const source = sources.get(expected.file); |
| 645 | if (source === undefined) |
| 646 | return yield* new ForkReleaseError({ |
| 647 | message: `The installer is unavailable: ${expected.file}.`, |
| 648 | }); |
| 649 | const actual = yield* artifact(directory, source); |
| 650 | if (actual.sha256 !== expected.sha256 || actual.size !== expected.size) |
| 651 | return yield* new ForkReleaseError({ message: `Artifact verification failed: ${source}.` }); |
| 652 | const temporary = `${expected.file}.${NodeCrypto.randomUUID()}.PARTIAL`; |
| 653 | yield* Effect.gen(function* () { |
| 654 | const put = yield* HttpClientRequest.put("").pipe( |
| 655 | HttpClientRequest.bodyFile(NodePath.join(directory, source)), |
| 656 | ); |
| 657 | yield* upload(temporary, put); |
| 658 | if (!(yield* matchesDownload(temporary, expected))) |
| 659 | return yield* new ForkReleaseError({ |
| 660 | message: `The uploaded installer did not match its checksum: ${expected.file}.`, |
| 661 | }); |
| 662 | yield* retireConflictingReleases(); |
| 663 | // Effect's method union omits the WebDAV MOVE supported by the share. |
| 664 | yield* upload( |
| 665 | temporary, |
| 666 | HttpClientRequest.make("MOVE" as HttpMethod)("").pipe( |
| 667 | HttpClientRequest.setHeaders({ |
| 668 | Destination: new URL(expected.file, uploadBase).href, |
| 669 | Overwrite: "T", |
| 670 | }), |
| 671 | ), |
| 672 | ); |
| 673 | if (!(yield* matchesDownload(expected.file, expected))) |
| 674 | return yield* new ForkReleaseError({ |
| 675 | message: `The promoted installer did not match its checksum: ${expected.file}.`, |
| 676 | }); |
| 677 | }).pipe(Effect.ensuring(remove(temporary).pipe(Effect.ignore))); |
| 678 | continue; |
| 679 | } |
| 680 | const visible = yield* http.head(new URL(expected.file, feedBase).href); |
| 681 | if (visible.status === 200 && Number(visible.headers["content-length"]) === expected.size) |
| 682 | continue; |
| 683 | if (visible.status !== 404) |
| 684 | return yield* new ForkReleaseError({ |
| 685 | message: `Published artifact does not match: ${expected.file}.`, |
| 686 | }); |
| 687 | const put = yield* HttpClientRequest.put("").pipe( |
| 688 | HttpClientRequest.bodyFile(NodePath.join(directory, expected.file)), |
| 689 | ); |
| 690 | yield* upload(expected.file, put); |
| 691 | const readable = yield* request(HttpClientRequest.head(new URL(expected.file, feedBase).href)); |
| 692 | if (Number(readable.headers["content-length"]) !== expected.size) |
| 693 | return yield* new ForkReleaseError({ |
| 694 | message: `Published artifact is not readable: ${expected.file}.`, |
| 695 | }); |
| 696 | yield* Effect.log(`Published ${expected.file} (${expected.size} bytes)`); |
| 697 | } |
| 698 | if (previous?.version !== release.version) { |
| 699 | yield* retireConflictingReleases(); |
| 700 | for (const [platform, desktopRelease] of desktopReleases) { |
| 701 | const [key, desktop] = Object.entries(built.desktop).find(([key]) => |
| 702 | key.startsWith(`${platform}-`), |
| 703 | )!; |
| 704 | const target = release.desktop[key]!; |
| 705 | if (target.app.version !== desktopRelease.manifest.version) continue; |
| 706 | const name = `${desktopRelease.manifest.version}${desktopTargets[platform].channel}.yml`; |
| 707 | const manifest = { |
| 708 | ...desktopRelease.manifest, |
| 709 | files: desktopRelease.manifest.files |
| 710 | .filter((file) => file.url === desktop.app.file) |
| 711 | .map((file) => ({ ...file, url: target.app.file })), |
| 712 | }; |
| 713 | yield* publishManifest( |
| 714 | name, |
| 715 | serializeUpdateManifest(manifest, { |
| 716 | platformLabel: desktopTargets[platform].label, |
| 717 | }), |
| 718 | "text/yaml", |
| 719 | ); |
| 720 | } |
| 721 | yield* publishManifest(`${release.version}.json`, encoded + "\n", "application/json"); |
| 722 | } |
| 723 | |
| 724 | for (let attempt = 0; attempt < 8; attempt++) { |
| 725 | const versions = completedVersions(yield* readReleaseListing(feedBase)); |
| 726 | const newest = versions[0]; |
| 727 | if (!newest || BigInt(newest.slice(4)) < BigInt(release.version.slice(4))) |
| 728 | return yield* new ForkReleaseError({ |
| 729 | message: "The release is missing from the feed. Retry publication.", |
| 730 | }); |
| 731 | const latest = yield* readPublishedRelease(feedBase, newest); |
| 732 | let serverRelease = latest; |
| 733 | const desktop = { ...latest.desktop }; |
| 734 | for (const version of versions.slice(1)) { |
| 735 | const completed = yield* readPublishedRelease(feedBase, version); |
| 736 | if (BigInt(completed.server.version.slice(4)) > BigInt(serverRelease.server.version.slice(4))) |
| 737 | serverRelease = completed; |
| 738 | for (const [platform, target] of Object.entries(completed.desktop)) { |
| 739 | const current = desktop[platform]; |
| 740 | if (!current || BigInt(target.version.slice(4)) > BigInt(current.version.slice(4))) |
| 741 | desktop[platform] = target; |
| 742 | } |
| 743 | } |
| 744 | const merged = { |
| 745 | ...latest, |
| 746 | ui: serverRelease.ui, |
| 747 | assets: serverRelease.assets, |
| 748 | server: serverRelease.server, |
| 749 | desktop, |
| 750 | }; |
| 751 | if ((yield* encodeRelease(merged)) === (yield* encodeRelease(latest))) { |
| 752 | const currentVersions = completedVersions(yield* readReleaseListing(feedBase)); |
| 753 | if (currentVersions.join("\n") !== versions.join("\n")) continue; |
| 754 | const maintained = yield* maintainPublicFolder( |
| 755 | latest, |
| 756 | feedBase, |
| 757 | uploadBase, |
| 758 | config.uploadApiKey, |
| 759 | versions, |
| 760 | retiredFiles, |
| 761 | ); |
| 762 | if (!maintained) continue; |
| 763 | if (NodeFS.existsSync(intentPath)) { |
| 764 | const completed = yield* readPublishedRelease(feedBase, release.version); |
| 765 | if ((yield* encodeRelease(completed)) !== encoded) |
| 766 | return yield* new ForkReleaseError({ |
| 767 | message: "The completed release differs from the publication intent.", |
| 768 | }); |
| 769 | yield* Effect.tryPromise(() => NodeFSP.rm(intentPath)); |
| 770 | } |
| 771 | yield* Effect.log(`Fork release ${latest.version} is available to all machines.`); |
| 772 | return; |
| 773 | } |
| 774 | const timestamp = BigInt(Math.floor((yield* Clock.currentTimeMillis) / 1000)); |
| 775 | const next = BigInt(newest.slice(4)) + 1n; |
| 776 | merged.version = `0.0.${timestamp > next ? timestamp : next}`; |
| 777 | yield* publishManifest( |
| 778 | `${merged.version}.json`, |
| 779 | (yield* encodeRelease(merged)) + "\n", |
| 780 | "application/json", |
| 781 | ).pipe(Effect.catchTags({ ForkReleaseCollision: () => Effect.void })); |
| 782 | } |
| 783 | return yield* new ForkReleaseError({ |
| 784 | message: "Concurrent releases are still completing. Retry publication.", |
| 785 | }); |
| 786 | }); |
| 787 | |
| 788 | const archiveComponent = Effect.fn("fork.release.archive")(function* ( |
| 789 | directory: string, |
| 790 | source: string, |
| 791 | name: string, |
| 792 | ) { |
| 793 | const entries = yield* Effect.tryPromise(async () => { |
| 794 | const files: string[] = []; |
| 795 | const visit = async (relative: string): Promise<void> => { |
| 796 | const file = NodePath.join(source, relative); |
| 797 | const stat = await NodeFSP.lstat(file); |
| 798 | if (stat.isDirectory()) { |
| 799 | for (const child of (await NodeFSP.readdir(file)).sort()) |
| 800 | await visit(NodePath.join(relative, child)); |
| 801 | } |
| 802 | if (!stat.isSymbolicLink()) await NodeFSP.utimes(file, 0, 0); |
| 803 | files.push(relative); |
| 804 | }; |
| 805 | await visit("."); |
| 806 | return files; |
| 807 | }); |
| 808 | const list = NodePath.join(directory, `${name}.files`); |
| 809 | yield* Effect.tryPromise(() => NodeFSP.writeFile(list, entries.join("\n") + "\n")); |
| 810 | const temporary = `${name}.tar.gz`; |
| 811 | const platform = yield* HostProcess.Platform; |
| 812 | const owner = |
| 813 | platform === "darwin" |
| 814 | ? [ |
| 815 | "--uid", |
| 816 | "0", |
| 817 | "--gid", |
| 818 | "0", |
| 819 | "--uname", |
| 820 | "root", |
| 821 | "--gname", |
| 822 | "root", |
| 823 | "--options", |
| 824 | "gzip:!timestamp", |
| 825 | ] |
| 826 | : ["--owner=0", "--group=0", "--numeric-owner"]; |
| 827 | yield* run("tar", [ |
| 828 | "--format=gnutar", |
| 829 | ...owner, |
| 830 | "--no-recursion", |
| 831 | "-czf", |
| 832 | NodePath.join(directory, temporary), |
| 833 | "-C", |
| 834 | source, |
| 835 | "-T", |
| 836 | list, |
| 837 | ]); |
| 838 | yield* Effect.tryPromise(() => NodeFSP.rm(list)); |
| 839 | const result = yield* artifact(directory, temporary); |
| 840 | const file = `knife-${name}-${result.sha256}.tar.gz`; |
| 841 | yield* Effect.tryPromise(() => |
| 842 | NodeFSP.rename(NodePath.join(directory, temporary), NodePath.join(directory, file)), |
| 843 | ); |
| 844 | return { ...result, file }; |
| 845 | }); |
| 846 | |
| 847 | const prepareForkComponents = Effect.fn("fork.release.prepareComponents")(function* ( |
| 848 | directory: string, |
| 849 | resources: string, |
| 850 | platform: keyof typeof desktopTargets, |
| 851 | arch: string, |
| 852 | version: string, |
| 853 | serverRuntime: string, |
| 854 | ) { |
| 855 | const desktopRelease = yield* verifyDesktopRelease(directory, platform); |
| 856 | const components = NodePath.join(directory, "components"); |
| 857 | const codeRoot = NodePath.join(components, "code"); |
| 858 | yield* Effect.tryPromise(async () => { |
| 859 | await NodeFSP.mkdir(components); |
| 860 | extractAll(NodePath.join(resources, "app.asar"), codeRoot); |
| 861 | if (platform === "win32") |
| 862 | extractAll(NodePath.join(resources, "server.asar"), NodePath.join(codeRoot, "server")); |
| 863 | }); |
| 864 | const runtime = yield* Effect.tryPromise(() => |
| 865 | NodeFSP.readFile(NodePath.join(codeRoot, "knife-runtime.json"), "utf8"), |
| 866 | ).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest)))); |
| 867 | if (runtime.version !== desktopRelease.manifest.version) |
| 868 | return yield* new ForkReleaseError({ |
| 869 | message: "The packaged app version does not match its installer.", |
| 870 | }); |
| 871 | const serverCodeRoot = platform === "win32" ? NodePath.join(codeRoot, "server") : codeRoot; |
| 872 | const archive = desktopRelease.files.find((name) => |
| 873 | name.endsWith(installerSuffix(platform, arch)), |
| 874 | ); |
| 875 | if (!archive) |
| 876 | return yield* new ForkReleaseError({ |
| 877 | message: "The release has no installer for this architecture.", |
| 878 | }); |
| 879 | const uiRoot = NodePath.join(components, "ui"); |
| 880 | const assetsRoot = NodePath.join(components, "assets"); |
| 881 | const serverRoot = NodePath.join(components, "server"); |
| 882 | yield* Effect.tryPromise(async () => { |
| 883 | await NodeFSP.rename(NodePath.join(serverCodeRoot, "apps/server/dist/client"), uiRoot); |
| 884 | const splitAssets = async (relative: string): Promise<void> => { |
| 885 | const file = NodePath.join(uiRoot, relative); |
| 886 | if ((await NodeFSP.stat(file)).isDirectory()) { |
| 887 | for (const child of await NodeFSP.readdir(file)) |
| 888 | await splitAssets(NodePath.join(relative, child)); |
| 889 | } else if (relative !== ".vite/manifest.json" && !/\.(?:html|m?js|css)$/.test(relative)) { |
| 890 | const target = NodePath.join(assetsRoot, relative); |
| 891 | await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); |
| 892 | await NodeFSP.rename(file, target); |
| 893 | } |
| 894 | }; |
| 895 | await splitAssets(""); |
| 896 | await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server"), { recursive: true }); |
| 897 | await NodeFSP.cp( |
| 898 | NodePath.join(serverCodeRoot, "apps/server/dist"), |
| 899 | NodePath.join(serverRoot, "apps/server/dist"), |
| 900 | { recursive: true }, |
| 901 | ); |
| 902 | for (const dist of [ |
| 903 | NodePath.join(serverCodeRoot, "apps/server/dist"), |
| 904 | NodePath.join(codeRoot, "apps/desktop/dist-electron"), |
| 905 | NodePath.join(serverRoot, "apps/server/dist"), |
| 906 | ]) { |
| 907 | for (const name of await NodeFSP.readdir(dist)) { |
| 908 | if (name.endsWith(".map")) await NodeFSP.rm(NodePath.join(dist, name)); |
| 909 | } |
| 910 | } |
| 911 | }); |
| 912 | const code = yield* archiveComponent(directory, codeRoot, "code"); |
| 913 | const app = { ...(yield* artifact(directory, archive)), version: runtime.version }; |
| 914 | const ui = yield* archiveComponent(directory, uiRoot, "ui"); |
| 915 | const assets = yield* archiveComponent(directory, assetsRoot, "assets"); |
| 916 | const manifest = { |
| 917 | version, |
| 918 | ui, |
| 919 | assets, |
| 920 | server: { |
| 921 | ...(yield* archiveComponent(directory, serverRoot, "server")), |
| 922 | version: runtime.version, |
| 923 | runtime: serverRuntime, |
| 924 | }, |
| 925 | desktop: { |
| 926 | [`${platform}-${arch}`]: { |
| 927 | version: runtime.version, |
| 928 | ui, |
| 929 | assets, |
| 930 | shell: runtime.shell, |
| 931 | runtime: runtime.sha256, |
| 932 | code, |
| 933 | app, |
| 934 | }, |
| 935 | }, |
| 936 | }; |
| 937 | const encoded = yield* encodeRelease(manifest); |
| 938 | yield* Effect.tryPromise(() => |
| 939 | NodeFSP.writeFile(NodePath.join(directory, "release.json"), encoded + "\n"), |
| 940 | ); |
| 941 | yield* Effect.tryPromise(() => NodeFSP.rm(components, { recursive: true })); |
| 942 | yield* Effect.log( |
| 943 | `Built ${version}: UI ${manifest.ui.size} bytes, assets ${manifest.assets.size} bytes, server ${manifest.server.size} bytes.`, |
| 944 | ); |
| 945 | return directory; |
| 946 | }); |
| 947 | |
| 948 | export function resolveForkMacSigningType( |
| 949 | identity: string, |
| 950 | identities: string, |
| 951 | notarizationKeychainProfile: string | undefined, |
| 952 | ): "development" | "distribution" { |
| 953 | const certificate = identities |
| 954 | .split("\n") |
| 955 | .map((line) => /^\s*\d+\) ([A-Fa-f0-9]{40}) "([^"]+)"/.exec(line)) |
| 956 | .find((match) => match?.[1]?.toUpperCase() === identity.toUpperCase())?.[2]; |
| 957 | if (!certificate) |
| 958 | throw new ForkReleaseError({ |
| 959 | message: |
| 960 | "The release signing identity is unavailable. Import its certificate and private key into the signing keychain.", |
| 961 | }); |
| 962 | if (certificate.startsWith("Developer ID Application:")) { |
| 963 | if (!notarizationKeychainProfile) |
| 964 | throw new ForkReleaseError({ |
| 965 | message: |
| 966 | "Developer ID releases need notarization. Set notarizationKeychainProfile in the private release config.", |
| 967 | }); |
| 968 | return "distribution"; |
| 969 | } |
| 970 | if (certificate.startsWith("Apple Development:") && !notarizationKeychainProfile) |
| 971 | return "development"; |
| 972 | throw new ForkReleaseError({ |
| 973 | message: |
| 974 | "Notarization requires a Developer ID Application certificate. Update signingIdentity in the private release config.", |
| 975 | }); |
| 976 | } |
| 977 | |
| 978 | const buildForkRelease = Effect.fn("fork.release.build")(function* (config: ReleaseConfig) { |
| 979 | const keychainPassword = yield* Effect.tryPromise(() => |
| 980 | NodeFSP.readFile(`${config.signingKeychain}.password`, "utf8"), |
| 981 | ); |
| 982 | yield* run("security", [ |
| 983 | "unlock-keychain", |
| 984 | "-p", |
| 985 | keychainPassword.replace(/[\r\n]+$/, ""), |
| 986 | config.signingKeychain, |
| 987 | ]); |
| 988 | const identities = yield* run( |
| 989 | "security", |
| 990 | ["find-identity", "-v", "-p", "codesigning", config.signingKeychain], |
| 991 | { capture: true }, |
| 992 | ); |
| 993 | const signingType = yield* Effect.try({ |
| 994 | try: () => |
| 995 | resolveForkMacSigningType( |
| 996 | config.signingIdentity, |
| 997 | identities, |
| 998 | config.notarizationKeychainProfile, |
| 999 | ), |
| 1000 | catch: (cause) => |
| 1001 | cause instanceof ForkReleaseError |
| 1002 | ? cause |
| 1003 | : new ForkReleaseError({ message: "Could not verify the release signing identity." }), |
| 1004 | }); |
| 1005 | if (config.notarizationKeychainProfile) { |
| 1006 | yield* run( |
| 1007 | "xcrun", |
| 1008 | [ |
| 1009 | "notarytool", |
| 1010 | "history", |
| 1011 | "--keychain-profile", |
| 1012 | config.notarizationKeychainProfile, |
| 1013 | "--keychain", |
| 1014 | config.signingKeychain, |
| 1015 | ], |
| 1016 | { capture: true }, |
| 1017 | ); |
| 1018 | } else { |
| 1019 | yield* Effect.logWarning( |
| 1020 | "Apple Development signing does not clear Gatekeeper. Configure Developer ID Application signing and notarization for direct downloads.", |
| 1021 | ); |
| 1022 | } |
| 1023 | const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`; |
| 1024 | const directory = NodePath.join(repoRoot, "release", "fork", version); |
| 1025 | yield* Effect.tryPromise(() => NodeFSP.mkdir(NodePath.dirname(directory), { recursive: true })); |
| 1026 | yield* Effect.tryPromise(() => NodeFSP.mkdir(directory)); |
| 1027 | const commit = (yield* run("jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id.short(12)"], { |
| 1028 | capture: true, |
| 1029 | })).trim(); |
| 1030 | if (!/^[a-f0-9]{12}$/.test(commit)) |
| 1031 | return yield* new ForkReleaseError({ message: "Could not read the current Jujutsu revision." }); |
| 1032 | const env = { ...loadRepoEnv(), ...process.env }; |
| 1033 | env.T3CODE_COMMIT_HASH = commit; |
| 1034 | env.APP_VERSION = version; |
| 1035 | env.T3CODE_DESKTOP_UPDATE_URL = config.feedUrl; |
| 1036 | env.T3CODE_DESKTOP_MAC_SIGN_IDENTITY = config.signingIdentity; |
| 1037 | env.T3CODE_DESKTOP_MAC_SIGN_TYPE = signingType; |
| 1038 | env.CSC_KEYCHAIN = config.signingKeychain; |
| 1039 | if (config.notarizationKeychainProfile) { |
| 1040 | env.APPLE_KEYCHAIN_PROFILE = config.notarizationKeychainProfile; |
| 1041 | env.APPLE_KEYCHAIN = config.signingKeychain; |
| 1042 | delete env.APPLE_ID; |
| 1043 | delete env.APPLE_APP_SPECIFIC_PASSWORD; |
| 1044 | delete env.APPLE_TEAM_ID; |
| 1045 | } |
| 1046 | env.T3CODE_DESKTOP_SIGNED = "false"; |
| 1047 | env.T3CODE_DESKTOP_SKIP_BUILD = "false"; |
| 1048 | delete env.T3CODE_DISABLE_AUTO_UPDATE; |
| 1049 | delete env.T3CODE_DESKTOP_MOCK_UPDATES; |
| 1050 | yield* run( |
| 1051 | process.execPath, |
| 1052 | [ |
| 1053 | "scripts/build-desktop-artifact.ts", |
| 1054 | "--platform", |
| 1055 | "mac", |
| 1056 | "--target", |
| 1057 | "zip", |
| 1058 | "--arch", |
| 1059 | "arm64", |
| 1060 | "--build-version", |
| 1061 | version, |
| 1062 | "--output-dir", |
| 1063 | directory, |
| 1064 | "--verbose", |
| 1065 | ], |
| 1066 | { env }, |
| 1067 | ); |
| 1068 | const release = yield* verifyDesktopRelease(directory); |
| 1069 | const archive = release.files.find((name) => name.endsWith(".zip"))!; |
| 1070 | yield* run("ditto", ["-x", "-k", NodePath.join(directory, archive), directory]); |
| 1071 | const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(directory)); |
| 1072 | const appName = entries.find((name) => name.endsWith(".app")); |
| 1073 | if (!appName) |
| 1074 | return yield* new ForkReleaseError({ message: "The update ZIP did not contain an app." }); |
| 1075 | yield* run("codesign", ["--verify", "--deep", "--strict", NodePath.join(directory, appName)]); |
| 1076 | if (signingType === "distribution") { |
| 1077 | yield* run("xcrun", ["stapler", "validate", NodePath.join(directory, appName)]); |
| 1078 | yield* run("spctl", ["--assess", "--type", "execute", NodePath.join(directory, appName)]); |
| 1079 | } |
| 1080 | yield* run( |
| 1081 | NodePath.join(directory, appName, "Contents", "MacOS", appName.slice(0, -4)), |
| 1082 | ["-p", "process.versions.node"], |
| 1083 | { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } }, |
| 1084 | ); |
| 1085 | return yield* prepareForkComponents( |
| 1086 | directory, |
| 1087 | NodePath.join(directory, appName, "Contents/Resources"), |
| 1088 | "darwin", |
| 1089 | "arm64", |
| 1090 | version, |
| 1091 | yield* Effect.tryPromise(() => fingerprintForkServerRuntime(repoRoot)), |
| 1092 | ); |
| 1093 | }); |
| 1094 | |
| 1095 | export const ingestForkRelease = Effect.fn("fork.release.ingest")(function* ( |
| 1096 | platformKey: string, |
| 1097 | source: string, |
| 1098 | directory: string, |
| 1099 | version: string, |
| 1100 | ) { |
| 1101 | const match = /^(linux|win32)-(x64|arm64)$/.exec(platformKey); |
| 1102 | if (!match) |
| 1103 | return yield* new ForkReleaseError({ |
| 1104 | message: "Choose linux-x64, linux-arm64, win32-x64, or win32-arm64.", |
| 1105 | }); |
| 1106 | const platform = match[1] === "linux" ? "linux" : "win32"; |
| 1107 | const arch = match[2]!; |
| 1108 | const verified = yield* verifyDesktopRelease(source, platform); |
| 1109 | const installer = verified.manifest.files.find((file) => |
| 1110 | file.url.endsWith(installerSuffix(platform, arch)), |
| 1111 | ); |
| 1112 | if (!installer) |
| 1113 | return yield* new ForkReleaseError({ |
| 1114 | message: "The release has no installer for this architecture.", |
| 1115 | }); |
| 1116 | const archive = NodePath.join(source, `knife-resources-${platformKey}.tar.gz`); |
| 1117 | const entries = (yield* run("tar", ["-tzf", archive], { capture: true })).trim().split(/\r?\n/); |
| 1118 | if ( |
| 1119 | entries.some((entry) => { |
| 1120 | const path = entry.replace(/^\.\//, ""); |
| 1121 | return ( |
| 1122 | path.startsWith("/") || |
| 1123 | path.includes("\\") || |
| 1124 | path.split("/").includes("..") || |
| 1125 | !/^(?:app\.asar(?:\.unpacked(?:\/.*)?)?|server\.asar(?:\.unpacked(?:\/.*)?)?|knife-server-runtime)\/?$/.test( |
| 1126 | path, |
| 1127 | ) |
| 1128 | ); |
| 1129 | }) |
| 1130 | ) |
| 1131 | return yield* new ForkReleaseError({ |
| 1132 | message: "The native resource archive contains an unexpected path.", |
| 1133 | }); |
| 1134 | yield* Effect.tryPromise(() => NodeFSP.mkdir(directory, { recursive: true })); |
| 1135 | const resources = yield* Effect.tryPromise(() => |
| 1136 | NodeFSP.mkdtemp(NodePath.join(directory, "native-resources-")), |
| 1137 | ); |
| 1138 | yield* run("tar", ["-xzf", archive, "-C", resources]); |
| 1139 | const serverRuntime = (yield* Effect.tryPromise(() => |
| 1140 | NodeFSP.readFile(NodePath.join(resources, "knife-server-runtime"), "utf8"), |
| 1141 | )).trim(); |
| 1142 | yield* Effect.tryPromise(async () => { |
| 1143 | for (const name of verified.files.filter( |
| 1144 | (name) => name === installer.url || name === `${installer.url}.blockmap`, |
| 1145 | )) |
| 1146 | await NodeFSP.copyFile(NodePath.join(source, name), NodePath.join(directory, name)); |
| 1147 | await NodeFSP.writeFile( |
| 1148 | NodePath.join(directory, desktopTargets[platform].manifest), |
| 1149 | serializeUpdateManifest( |
| 1150 | { ...verified.manifest, files: [installer] }, |
| 1151 | { platformLabel: desktopTargets[platform].label }, |
| 1152 | ), |
| 1153 | ); |
| 1154 | }); |
| 1155 | yield* prepareForkComponents(directory, resources, platform, arch, version, serverRuntime); |
| 1156 | yield* Effect.tryPromise(() => NodeFSP.rm(resources, { recursive: true })); |
| 1157 | return directory; |
| 1158 | }); |
| 1159 | |
| 1160 | if (import.meta.main) { |
| 1161 | Effect.gen(function* () { |
| 1162 | const mode = process.argv[2] ?? "release"; |
| 1163 | if (!["build", "publish", "release", "ingest"].includes(mode)) |
| 1164 | return yield* new ForkReleaseError({ |
| 1165 | message: |
| 1166 | "Use build, publish <release-directory>, release, or ingest <platform-arch> <native-build-directory>.", |
| 1167 | }); |
| 1168 | const config = yield* Effect.tryPromise(() => |
| 1169 | NodeFSP.readFile(NodePath.join(NodeOS.homedir(), ".config/t3-fork-release.json"), "utf8"), |
| 1170 | ).pipe( |
| 1171 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseConfig))), |
| 1172 | Effect.mapError( |
| 1173 | () => |
| 1174 | new ForkReleaseError({ |
| 1175 | message: |
| 1176 | "Configure ~/.config/t3-fork-release.json with feedUrl, uploadUrl, signingIdentity, and signingKeychain.", |
| 1177 | }), |
| 1178 | ), |
| 1179 | ); |
| 1180 | let directory: string | undefined; |
| 1181 | if (mode === "ingest") { |
| 1182 | const platform = process.argv[3]; |
| 1183 | const source = process.argv[4]; |
| 1184 | if (!platform || !source) |
| 1185 | return yield* new ForkReleaseError({ |
| 1186 | message: "Pass the platform and native build directory to ingest.", |
| 1187 | }); |
| 1188 | const version = `0.0.${Math.floor((yield* Clock.currentTimeMillis) / 1000)}`; |
| 1189 | directory = yield* ingestForkRelease( |
| 1190 | platform, |
| 1191 | NodePath.resolve(source), |
| 1192 | NodePath.join(repoRoot, "release/fork", version), |
| 1193 | version, |
| 1194 | ); |
| 1195 | } else { |
| 1196 | directory = mode === "publish" ? process.argv[3] : yield* buildForkRelease(config); |
| 1197 | } |
| 1198 | if (!directory) |
| 1199 | return yield* new ForkReleaseError({ message: "Pass the release directory to publish." }); |
| 1200 | if (mode !== "build") yield* publishForkRelease(NodePath.resolve(directory), config); |
| 1201 | }).pipe( |
| 1202 | Effect.scoped, |
| 1203 | Effect.catch((error) => |
| 1204 | Effect.logError(error instanceof Error ? error.message : "Fork release failed.").pipe( |
| 1205 | Effect.andThen( |
| 1206 | Effect.sync(() => { |
| 1207 | process.exitCode = 1; |
| 1208 | }), |
| 1209 | ), |
| 1210 | ), |
| 1211 | ), |
| 1212 | Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), |
| 1213 | NodeRuntime.runMain, |
| 1214 | ); |
| 1215 | } |