1// @effect-diagnostics nodeBuiltinImport:off - Release validation reads local archives at the CLI boundary.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeURL from "node:url";
8import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import * as Clock from "effect/Clock";
11import * as Effect from "effect/Effect";
12import * as Data from "effect/Data";
13import * as Layer from "effect/Layer";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
17import { ChildProcess, ChildProcessSpawner } from "effect/process";
18import type { HttpMethod } from "effect/http/HttpMethod";
19import { extractAll } from "@electron/asar";
20
21import {
22 ForkReleaseManifest,
23 ForkReleaseListing,
24 ForkRuntimeManifest,
25 latestForkRelease,
26} from "@t3tools/shared/forkUpdateDirectory";
27
28import { parseUpdateManifest, serializeUpdateManifest } from "./lib/update-manifest.ts";
29import { fingerprintForkServerRuntime } from "./lib/fork-runtime.ts";
30import { loadRepoEnv } from "./lib/public-config.ts";
31import * as HostProcess from "@t3tools/shared/HostProcess";
32
33const 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});
41type ReleaseConfig = typeof ReleaseConfig.Type;
42
43class ForkReleaseError extends Data.TaggedError("ForkReleaseError")<{
44 readonly message: string;
45}> {}
46
47class ForkReleaseCollision extends Data.TaggedError("ForkReleaseCollision")<{
48 readonly message: string;
49}> {}
50
51const repoRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)));
52
53const 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
77const 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
96const 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
107const installerSuffix = (platform: keyof typeof desktopTargets, arch: string) =>
108 `-${platform === "linux" && arch === "x64" ? "x86_64" : arch}${desktopTargets[platform].extension}`;
109
110const 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
161const decodeRelease = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkReleaseManifest));
162const encodeRelease = Schema.encodeEffect(Schema.fromJsonString(ForkReleaseManifest));
163const PublicationIntentJson = Schema.fromJsonString(
164 Schema.Struct({
165 release: ForkReleaseManifest,
166 retiredFiles: Schema.Array(Schema.String),
167 }),
168);
169const decodePublicationIntent = Schema.decodeUnknownEffect(PublicationIntentJson);
170const encodePublicationIntent = Schema.encodeEffect(PublicationIntentJson);
171const decodeListing = Schema.decodeUnknownEffect(ForkReleaseListing);
172
173const 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
181const 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
187const 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
198const 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
206const 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
214const 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
262const 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
274const 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
288const 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
383export 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
788const 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
847const 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
948export 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
978const 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
1095export 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
1160if (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}