Skip to content
Source
Sign inSign up

build-cli-archive.ts

756 lines29.7 KB

1#!/usr/bin/env node
2/** Runtime archives must run without host Node, npm, or compilers. */
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Config from "effect/Config";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as Schema from "effect/Schema";
14import * as Stream from "effect/Stream";
15import { Command, Flag } from "effect/cli";
16import { ChildProcess, ChildProcessSpawner } from "effect/process";
17​
18import * as HostProcess from "@t3tools/shared/HostProcess";
19import { fromYaml } from "@t3tools/shared/schemaYaml";
20import { resolveSpawnCommand } from "@t3tools/shared/shell";
21import rootPackageJson from "../package.json" with { type: "json" };
22import serverPackageJson from "../apps/server/package.json" with { type: "json" };
23​
24import {
25 createStagePatchedDependencies,
26 createStageWorkspaceConfig,
27 resolveFffNativeDependencies,
28 STAGE_INSTALL_ARGS,
29} from "./build-desktop-artifact.ts";
30import { assertLinuxArm64PageCompatibility } from "./lib/linux-arm64-sea.ts";
31import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts";
32import { signWithJsign } from "./lib/windows-jsign.ts";
33import { windowsSystemTar } from "./lib/windows-tar.ts";
34import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
35​
36const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
37const BuildArch = Schema.Literals(["arm64", "x64"]);
38type BuildPlatform = typeof BuildPlatform.Type;
39type BuildArch = typeof BuildArch.Type;
40​
41const WorkspaceConfig = Schema.Struct({
42 catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)),
43 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
44 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
45 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
46});
47const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
48const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
49const StageWorkspaceConfig = Schema.Struct({
50 supportedArchitectures: Schema.Struct({
51 os: Schema.Array(Schema.String),
52 cpu: Schema.Array(Schema.String),
53 libc: Schema.optional(Schema.Array(Schema.String)),
54 }),
55 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
56 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
57 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
58 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
59});
60const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
61​
62const RepoRoot = Effect.service(Path.Path).pipe(
63 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
64);
65​
66export class CliArchiveLinuxArm64PageError extends Schema.TaggedError<CliArchiveLinuxArm64PageError>()(
67 "CliArchiveLinuxArm64PageError",
68 { executablePath: Schema.String, cause: Schema.Defect() },
69) {
70 override get message(): string {
71 return `${this.executablePath} is not page-compatible with Linux arm64.`;
72 }
74​
75export class CliArchiveCommandFailedError extends Schema.TaggedError<CliArchiveCommandFailedError>()(
76 "CliArchiveCommandFailedError",
77 { command: Schema.String, exitCode: Schema.Int },
78) {
79 override get message(): string {
80 return `${this.command} exited with code ${this.exitCode}.`;
81 }
83​
84export class CliArchiveInputMissingError extends Schema.TaggedError<CliArchiveInputMissingError>()(
85 "CliArchiveInputMissingError",
86 { inputPath: Schema.String, hint: Schema.String },
87) {
88 override get message(): string {
89 return `Missing ${this.inputPath}. ${this.hint}`;
90 }
92​
93export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string {
94 const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
95 return `${nodePlatform}-${arch}`;
97​
98export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string {
99 return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`;
101​
102export function cliArchiveFileName(version: string, platform: BuildPlatform, arch: BuildArch) {
103 // Minimal hosts may lack xz, but tar and Node zlib handle gzip without it.
104 return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`;
106​
107const runCommand = Effect.fn("runCommand")(function* (
108 command: ChildProcess.Command,
109 label: string,
110) {
111 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
112 const child = yield* spawner.spawn(
113 ChildProcess.isStandardCommand(command)
114 ? ChildProcess.make(command.command, command.args, {
115 ...command.options,
116 stdout: "inherit",
117 stderr: "inherit",
118 })
119 : command,
120 );
121 const exitCode = Number(yield* child.exitCode);
122 if (exitCode !== 0) {
123 return yield* new CliArchiveCommandFailedError({ command: label, exitCode });
124 }
125});
126​
127const requireInput = Effect.fn("requireInput")(function* (inputPath: string, hint: string) {
128 const fs = yield* FileSystem.FileSystem;
129 if (!(yield* fs.exists(inputPath))) {
130 return yield* new CliArchiveInputMissingError({ inputPath, hint });
131 }
132});
133​
134/** Installed runtimes cannot rely on pnpm's store layout surviving archival. */
135const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (input: {
136 readonly repoRoot: string;
137 readonly stageDir: string;
138 readonly platform: BuildPlatform;
139 readonly arch: BuildArch;
140 readonly version: string;
141}) {
142 const fs = yield* FileSystem.FileSystem;
143 const path = yield* Path.Path;
144 const workspace = yield* decodeWorkspaceConfig(
145 yield* fs.readFileString(path.join(input.repoRoot, "pnpm-workspace.yaml")),
146 );
147 const catalog = workspace.catalog ?? {};
148 const serverDependencies = resolveCatalogDependencies(
149 serverPackageJson.dependencies,
150 catalog,
151 "apps/server",
152 );
153 const fffNodeVersion = serverDependencies["@ff-labs/fff-node"];
154 if (fffNodeVersion === undefined) {
155 return yield* new CliArchiveInputMissingError({
156 inputPath: "apps/server/package.json#dependencies['@ff-labs/fff-node']",
157 hint: "The archive stages fff's platform binary from this version.",
158 });
159 }
160 const dependencies = {
161 ...selectCliRuntimeExternalDependencies(serverDependencies),
162 ...resolveFffNativeDependencies(input.platform, input.arch, fffNodeVersion),
163 };
164 const patchedDependencies = createStagePatchedDependencies(
165 workspace.patchedDependencies ?? {},
166 dependencies,
167 );
168​
169 yield* fs.writeFileString(
170 path.join(input.stageDir, "package.json"),
171 `${yield* encodeJsonString({
172 name: "t3-runtime",
173 version: input.version,
174 private: true,
175 packageManager: rootPackageJson.packageManager,
176 dependencies,
177 })}\n`,
178 );
179 yield* fs.writeFileString(
180 path.join(input.stageDir, "pnpm-workspace.yaml"),
181 yield* encodeStageWorkspaceConfig({
182 ...createStageWorkspaceConfig({
183 platform: input.platform,
184 arch: input.arch,
185 ...(workspace.allowBuilds ? { allowBuilds: workspace.allowBuilds } : {}),
186 patchedDependencies,
187 overrides: resolveCatalogDependencies(workspace.overrides ?? {}, catalog, "apps/server"),
188 }),
189 nodeLinker: "hoisted",
190 }),
191 );
192 if (Object.keys(patchedDependencies).length > 0) {
193 yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(input.stageDir, "patches"));
194 }
195​
196 const install = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
197 yield* runCommand(
198 ChildProcess.make(install.command, install.args, {
199 cwd: input.stageDir,
200 shell: install.shell,
201 stdout: "inherit",
202 stderr: "inherit",
203 }),
204 "vp install --prod (cli archive runtime externals)",
205 );
206​
207 const platformKey = cliArchivePlatformKey(input.platform, input.arch);
208 const prebuildsDir = path.join(input.stageDir, "node_modules/node-pty/prebuilds");
209 const foreignPrebuilds = (yield* fs
210 .readDirectory(prebuildsDir)
211 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter(
212 (entry) => entry !== platformKey,
213 );
214 for (const entry of [
215 "package.json",
216 "pnpm-workspace.yaml",
217 "pnpm-lock.yaml",
218 "patches",
219 "node_modules/.pnpm",
220 "node_modules/.modules.yaml",
221 "node_modules/.pnpm-workspace-state-v1.json",
222 "node_modules/.bin",
223 ...foreignPrebuilds.map((entry) => `node_modules/node-pty/prebuilds/${entry}`),
224 ]) {
225 yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true });
226 }
227 // Nested .bin shims are unused symlinks, which the npm registry rejects in tarballs.
228 yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules"));
229});
230​
231const removeNestedBinDirectories = Effect.fn("build-cli-archive.removeNestedBinDirectories")(
232 function* (
233 fs: FileSystem.FileSystem,
234 path: Path.Path,
235 root: string,
236 ): Effect.fn.Return<void, PlatformError.PlatformError> {
237 const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []));
238 for (const entry of entries) {
239 const child = path.join(root, entry);
240 if (entry === ".bin") {
241 yield* fs.remove(child, { recursive: true, force: true });
242 continue;
243 }
244 const info = yield* fs.stat(child).pipe(Effect.option);
245 if (Option.isSome(info) && info.value.type === "Directory") {
246 yield* removeNestedBinDirectories(fs, path, child);
247 }
248 }
249 },
250);
251​
252const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) {
253 const fs = yield* FileSystem.FileSystem;
254 const path = yield* Path.Path;
255 yield* fs.copy(source, target);
256 const maps = (yield* fs.readDirectory(target, { recursive: true })).filter((entry) =>
257 entry.endsWith(".map"),
258 );
259 for (const entry of maps) {
260 yield* fs.remove(path.join(target, entry), { force: true });
261 }
262});
263​
264/** Node links libatomic.so.1, which Ubuntu server and container images lack, so t3 carries its own. */
265const bundleLinuxLibatomic = Effect.fn("bundleLinuxLibatomic")(function* (input: {
266 readonly repoRoot: string;
267 readonly contentDir: string;
268 readonly executablePath: string;
269}) {
270 const fs = yield* FileSystem.FileSystem;
271 const path = yield* Path.Path;
272 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
273 const ldd = yield* spawner.string(ChildProcess.make("ldd", [input.executablePath]));
274 const source = /libatomic\.so\.1 => (\/\S+)/.exec(ldd)?.[1];
275 if (source === undefined) {
276 return yield* new CliArchiveInputMissingError({
277 inputPath: "libatomic.so.1",
278 hint: `ldd did not resolve it for ${input.executablePath}. Install libatomic1 on the build host.`,
279 });
280 }
281 yield* fs.makeDirectory(path.join(input.contentDir, "lib"));
282 yield* fs.copyFile(source, path.join(input.contentDir, "lib/libatomic.so.1"));
283​
284 // libatomic is GPL-3.0-or-later WITH GCC-exception-3.1, so its terms and source travel with it.
285 const owner = yield* spawner.string(
286 ChildProcess.make("dpkg-query", ["-S", yield* fs.realPath(source)]),
287 );
288 // Keep the arch qualifier so a second installed arch cannot add its own line.
289 const [, qualifiedPkg, pkg] = /^(([^:\s]+)(?::[^:\s]+)?): /.exec(owner) ?? [];
290 const versions = qualifiedPkg
291 ? yield* spawner.string(
292 ChildProcess.make("dpkg-query", [
293 "-W",
294 "-f=${Version} ${source:Package} ${source:Version}",
295 qualifiedPkg,
296 ]),
297 )
298 : "";
299 const [version, sourcePackage, sourceVersion] = versions.trim().split(" ");
300 if (!pkg || !version || !sourcePackage || !sourceVersion) {
301 return yield* new CliArchiveInputMissingError({
302 inputPath: `dpkg package for ${source}`,
303 hint: "The archive copies libatomic's license and source reference from its Ubuntu package.",
304 });
305 }
306 const header = [
307 `libatomic.so.1 is copied from the Ubuntu package ${pkg} ${version}.`,
308 `Source: https://launchpad.net/ubuntu/+source/${sourcePackage}/${sourceVersion}`,
309 ].join("\n");
310 // Vendored because CI runner images strip /usr/share/doc.
311 const license = yield* fs.readFileString(
312 path.join(input.repoRoot, "scripts/licenses/libatomic.LICENSE"),
313 );
314 yield* fs.writeFileString(
315 path.join(input.contentDir, "lib/libatomic.so.1.LICENSE"),
316 `${header}\n\n${license}`,
317 );
318​
319 // RUNPATH resolves through symlinks and is not inherited by child processes, unlike LD_LIBRARY_PATH.
320 yield* runCommand(
321 ChildProcess.make("patchelf", ["--set-rpath", "$ORIGIN/lib", input.executablePath]),
322 "patchelf --set-rpath",
323 );
324 yield* Effect.log(`[cli-archive] Bundled ${source} as lib/libatomic.so.1.`);
325});
326​
327const MacSigningConfig = Config.all({
328 identity: Config.String("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option),
329 appleApiKey: Config.String("APPLE_API_KEY").pipe(Config.option),
330 appleApiKeyId: Config.String("APPLE_API_KEY_ID").pipe(Config.option),
331 appleApiIssuer: Config.String("APPLE_API_ISSUER").pipe(Config.option),
332});
333​
334export class CliArchiveNotarizationRejectedError extends Schema.TaggedError<CliArchiveNotarizationRejectedError>()(
335 "CliArchiveNotarizationRejectedError",
336 { submissionId: Schema.String, status: Schema.String },
337) {
338 override get message(): string {
339 return `Apple notarization ${this.submissionId} is ${this.status}, not Accepted.`;
340 }
342​
343const NotarySubmission = Schema.Struct({
344 id: Schema.NonEmptyString,
345 status: Schema.optional(Schema.String),
346});
347// Final failures from Apple's Notary API. "In Progress" is pending and has no log yet.
348const rejectedNotaryStatuses: ReadonlySet<string> = new Set(["Invalid", "Rejected"]);
349const decodeNotarySubmission = Schema.decodeEffect(Schema.fromJsonString(NotarySubmission));
350​
351/** Notarization runs only for a Developer ID identity with all three App Store Connect key values. */
352const resolveNotaryCredentials = Effect.fn("resolveNotaryCredentials")(function* () {
353 const signing = yield* MacSigningConfig;
354 const identity = Option.getOrUndefined(signing.identity)?.trim() || "-";
355 const key = Option.getOrUndefined(signing.appleApiKey);
356 const keyId = Option.getOrUndefined(signing.appleApiKeyId);
357 const issuer = Option.getOrUndefined(signing.appleApiIssuer);
358 const credentials =
359 identity !== "-" && key && keyId && issuer
360 ? Option.some(["--key", key, "--key-id", keyId, "--issuer", issuer])
361 : Option.none<ReadonlyArray<string>>();
362 return { identity, credentials };
363});
364​
365const notarytool = Effect.fn("notarytool")(function* (
366 args: ReadonlyArray<string>,
367 credentials: ReadonlyArray<string>,
368) {
369 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
370 const child = yield* spawner.spawn(
371 ChildProcess.make("xcrun", ["notarytool", ...args, ...credentials, "--output-format", "json"], {
372 stdout: "pipe",
373 stderr: "inherit",
374 }),
375 );
376 const [stdout, exitCode] = yield* Effect.all(
377 [Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode],
378 { concurrency: "unbounded" },
379 );
380 const submission = yield* decodeNotarySubmission(stdout).pipe(Effect.option);
381 const status = Option.getOrUndefined(submission)?.status;
382 // A finished submission reports its status even when notarytool exits non-zero for it.
383 if (Option.isSome(submission) && status !== undefined && rejectedNotaryStatuses.has(status)) {
384 yield* runCommand(
385 ChildProcess.make("xcrun", ["notarytool", "log", submission.value.id, ...credentials]),
386 "notarytool log",
387 ).pipe(Effect.ignore);
388 return yield* new CliArchiveNotarizationRejectedError({
389 submissionId: submission.value.id,
390 status,
391 });
392 }
393 if (Number(exitCode) !== 0 || Option.isNone(submission)) {
394 return yield* new CliArchiveCommandFailedError({
395 command: `notarytool ${args[0]}`,
396 exitCode: Number(exitCode),
397 });
398 }
399 return submission.value;
400}, Effect.scoped);
401​
402const requireAccepted = Effect.fn("requireAccepted")(function* (
403 submission: typeof NotarySubmission.Type,
404) {
405 if (submission.status !== "Accepted") {
406 return yield* new CliArchiveNotarizationRejectedError({
407 submissionId: submission.id,
408 status: submission.status ?? "without a status",
409 });
410 }
411 yield* Effect.log(`[cli-archive] Notarized t3 (${submission.id}).`);
412});
413​
414/** Apple Silicon requires signed binaries; notarization's hardened runtime also requires every native addon signed. */
415export const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: {
416 readonly repoRoot: string;
417 readonly contentDir: string;
418 readonly executablePath: string;
419 readonly notarySubmissionFile: Option.Option<string>;
420}) {
421 const fs = yield* FileSystem.FileSystem;
422 const path = yield* Path.Path;
423 const { identity, credentials } = yield* resolveNotaryCredentials();
424 const deferred = Option.getOrUndefined(input.notarySubmissionFile);
425 // A stale id would let await-notarization approve bytes from an earlier build.
426 if (deferred !== undefined) yield* fs.remove(deferred, { force: true });
427​
428 const entitlements = path.join(input.repoRoot, "apps/server/resources/cli-entitlements.plist");
429 const libraries = (yield* fs.readDirectory(input.contentDir, { recursive: true }))
430 .filter(
431 (entry) =>
432 entry.endsWith(".node") ||
433 entry.endsWith(".dylib") ||
434 entry.endsWith("spawn-helper") ||
435 entry.endsWith("t3-resource-monitor"),
436 )
437 .map((entry) => path.join(input.contentDir, entry));
438​
439 for (const target of [...libraries, input.executablePath]) {
440 yield* runCommand(
441 ChildProcess.make("codesign", [
442 "--force",
443 "--sign",
444 identity,
445 ...(identity === "-" ? [] : ["--options", "runtime", "--timestamp"]),
446 ...(target === input.executablePath ? ["--entitlements", entitlements] : []),
447 target,
448 ]),
449 `codesign ${path.relative(input.contentDir, target)}`,
450 );
451 }
452 if (identity === "-") {
453 yield* Effect.log("[cli-archive] Signed ad hoc (no T3CODE_CLI_MAC_SIGN_IDENTITY).");
454 return;
455 }
456 if (Option.isNone(credentials)) {
457 yield* Effect.logWarning(
458 "[cli-archive] Developer ID signed but not notarized (missing APPLE_API_KEY*).",
459 );
460 return;
461 }
462 // notarytool requires an archive; bare executables rely on online tickets because they cannot be stapled.
463 const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip");
464 yield* runCommand(
465 ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]),
466 "ditto (notarization zip)",
467 );
468 const submission = yield* notarytool(
469 ["submit", notarizeZip, ...(deferred === undefined ? ["--wait"] : [])],
470 credentials.value,
471 ).pipe(Effect.ensuring(fs.remove(notarizeZip, { force: true }).pipe(Effect.ignore)));
472 if (deferred === undefined) {
473 yield* requireAccepted(submission);
474 return;
475 }
476 // The ticket lives with Apple, not in the archive, so the archive bytes are final before approval.
477 yield* fs.writeFileString(deferred, submission.id);
478 yield* Effect.log(
479 `[cli-archive] Submitted t3 for notarization (${submission.id}). Run await-notarization before publishing.`,
480 );
481});
482​
483/** Publication gate for a deferred submission: fails unless Apple accepted it. */
484export const awaitCliNotarization = Effect.fn("awaitCliNotarization")(function* (
485 submissionFile: string,
486) {
487 const fs = yield* FileSystem.FileSystem;
488 const { credentials } = yield* resolveNotaryCredentials();
489 const submitted = yield* fs.exists(submissionFile);
490 if (Option.isNone(credentials)) {
491 if (submitted) {
492 return yield* new CliArchiveInputMissingError({
493 inputPath: "T3CODE_CLI_MAC_SIGN_IDENTITY and APPLE_API_KEY*",
494 hint: `${submissionFile} names a submission, but its credentials are not set.`,
495 });
496 }
497 yield* Effect.log("[cli-archive] No notarization to await (signing or APPLE_API_KEY* unset).");
498 return;
499 }
500 if (!submitted) {
501 return yield* new CliArchiveInputMissingError({
502 inputPath: submissionFile,
503 hint: "Notarization is configured, but build-cli-archive recorded no submission.",
504 });
505 }
506 const id = (yield* fs.readFileString(submissionFile)).trim();
507 yield* requireAccepted(yield* notarytool(["wait", id], credentials.value));
508});
509​
510const WindowsSigningConfig = Config.all({
511 endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT").pipe(Config.option),
512 accountName: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME").pipe(Config.option),
513 certificateProfileName: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME").pipe(
514 Config.option,
515 ),
516});
517​
518/** Node's Windows --build-sea leaves a stale Authenticode offset pointing into rebuilt resources instead of certificates. */
519const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* (
520 executablePath: string,
521) {
522 const fs = yield* FileSystem.FileSystem;
523 const bytes = yield* fs.readFile(executablePath);
524 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
525 const peOffset = view.getUint32(0x3c, true);
526 if (view.getUint32(peOffset, true) !== 0x00004550) {
527 return yield* new CliArchiveInputMissingError({
528 inputPath: executablePath,
529 hint: "Expected a PE executable to strip the stale signature entry from.",
530 });
531 }
532 const optionalHeader = peOffset + 24;
533 const magic = view.getUint16(optionalHeader, true);
534 // PE data directories start at +112 (PE32+) or +96 (PE32); index 4 holds the certificate offset and size.
535 const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8;
536 const offset = view.getUint32(securityEntry, true);
537 const size = view.getUint32(securityEntry + 4, true);
538 if (offset === 0 && size === 0) return;
539 if (offset + size === bytes.byteLength) {
540 // Let signtool replace intact certificate tables ending at EOF.
541 return;
542 }
543 view.setUint32(securityEntry, 0, true);
544 view.setUint32(securityEntry + 4, 0, true);
545 yield* fs.writeFile(executablePath, bytes);
546 yield* Effect.log(
547 `[cli-archive] Cleared the stale Authenticode entry (offset ${String(offset)}, size ${String(size)}) left by --build-sea.`,
548 );
549});
550​
551const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* (
552 executablePath: string,
553) {
554 const signing = yield* WindowsSigningConfig;
555 const endpoint = Option.getOrUndefined(signing.endpoint);
556 const accountName = Option.getOrUndefined(signing.accountName);
557 const profile = Option.getOrUndefined(signing.certificateProfileName);
558 if (!endpoint || !accountName || !profile) {
559 yield* Effect.log("[cli-archive] Windows signing disabled (missing Azure Trusted Signing).");
560 return;
561 }
562 yield* stripStaleAuthenticodeEntry(executablePath);
563 if ((yield* HostProcess.Platform) !== "win32") {
564 yield* signWithJsign(executablePath);
565 yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing via jsign).");
566 return;
567 }
568 const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
569 const script = [
570 "$ErrorActionPreference = 'Stop';",
571 "Invoke-TrustedSigning",
572 `-Endpoint ${quote(endpoint)}`,
573 `-CodeSigningAccountName ${quote(accountName)}`,
574 `-CertificateProfileName ${quote(profile)}`,
575 "-FileDigest 'SHA256'",
576 "-TimestampRfc3161 'http://timestamp.acs.microsoft.com'",
577 "-TimestampDigest 'SHA256'",
578 `-Files ${quote(executablePath)}`,
579 ].join(" ");
580 yield* runCommand(
581 ChildProcess.make("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script]),
582 "Invoke-TrustedSigning t3.exe",
583 );
584 yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing).");
585});
586​
587const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {
588 readonly platform: BuildPlatform;
589 readonly arch: BuildArch;
590 readonly version: string;
591 readonly outputDir: string;
592 readonly resourceMonitorDir: Option.Option<string>;
593 readonly notarySubmissionFile: Option.Option<string>;
594}) {
595 const fs = yield* FileSystem.FileSystem;
596 const path = yield* Path.Path;
597 const repoRoot = yield* RepoRoot;
598 const serverDir = path.join(repoRoot, "apps/server");
599 const executableName = input.platform === "win" ? "t3.exe" : "t3";
600 // tsdown suffixes cross-built executables; prefer the exact target over the plain host build.
601 const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`;
602 const targetExecutable = path.join(
603 serverDir,
604 "dist-exe",
605 `t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`,
606 );
607 // The unsuffixed executable is valid only for the host platform and architecture.
608 const hostPlatform = yield* HostProcess.Platform;
609 const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcess.Architecture}`;
610 const builtExecutable = (yield* fs.exists(targetExecutable))
611 ? targetExecutable
612 : targetKey === hostKey
613 ? path.join(serverDir, "dist-exe", executableName)
614 : targetExecutable;
615 const webClient = path.join(serverDir, "dist/client");
616 const resourceMonitorDir = Option.getOrElse(input.resourceMonitorDir, () =>
617 path.join(serverDir, "dist/resource-monitor"),
618 );
619​
620 yield* requireInput(
621 builtExecutable,
622 `Run \`node apps/server/scripts/cli.ts build-exe${targetKey === hostKey ? "" : ` --target ${targetKey}`}\` first.`,
623 );
624 yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first.");
625 yield* requireInput(
626 resourceMonitorDir,
627 "Build the resource monitor or pass --resource-monitor-dir.",
628 );
629​
630 const stem = cliArchiveStem(input.version, input.platform, input.arch);
631 const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-archive-" });
632 const contentDir = path.join(stageRoot, stem);
633 yield* fs.makeDirectory(contentDir, { recursive: true });
634​
635 yield* Effect.log(`[cli-archive] Staging ${stem}...`);
636 yield* fs.copyFile(builtExecutable, path.join(contentDir, executableName));
637 yield* stageWebClient(webClient, path.join(contentDir, "client"));
638 yield* fs.copy(resourceMonitorDir, path.join(contentDir, "resource-monitor"));
639 yield* stageRuntimeExternals({
640 repoRoot,
641 stageDir: contentDir,
642 platform: input.platform,
643 arch: input.arch,
644 version: input.version,
645 });
646​
647 const executablePath = path.join(contentDir, executableName);
648 if (input.platform === "linux") {
649 yield* bundleLinuxLibatomic({ repoRoot, contentDir, executablePath });
650 }
651 // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
652 if (input.platform === "linux" && input.arch === "arm64") {
653 const bytes = yield* fs.readFile(executablePath);
654 yield* Effect.try({
655 try: () => assertLinuxArm64PageCompatibility(bytes),
656 catch: (cause) => new CliArchiveLinuxArm64PageError({ executablePath, cause }),
657 });
658 }
659 if (input.platform === "mac") {
660 yield* signMacArchiveContents({
661 repoRoot,
662 contentDir,
663 executablePath,
664 notarySubmissionFile: input.notarySubmissionFile,
665 });
666 } else if (input.platform === "win") {
667 yield* signWindowsExecutable(executablePath);
668 }
669 if (input.platform !== "win") {
670 yield* fs.chmod(executablePath, 0o755);
671 }
672​
673 yield* fs.makeDirectory(input.outputDir, { recursive: true });
674 const archivePath = path.join(
675 input.outputDir,
676 cliArchiveFileName(input.version, input.platform, input.arch),
677 );
678 yield* fs.remove(archivePath, { force: true });
679 if (input.platform === "win" && hostPlatform !== "win32") {
680 yield* runCommand(
681 ChildProcess.make("zip", ["-q", "-r", "-X", path.resolve(archivePath), stem], {
682 cwd: stageRoot,
683 }),
684 "zip",
685 );
686 } else if (input.platform === "win") {
687 // Windows ships bsdtar, which writes zip natively. Name it by path: under
688 // the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither
689 // writes zip nor accepts a drive-letter path.
690 yield* runCommand(
691 ChildProcess.make(windowsSystemTar(), ["-a", "-c", "-f", archivePath, "-C", stageRoot, stem]),
692 "tar (zip)",
693 );
694 } else {
695 // npm rejects Linux hardlink entries; macOS pnpm clones and bsdtar lacks this flag.
696 yield* runCommand(
697 ChildProcess.make("tar", [
698 ...(input.platform === "linux" ? ["--hard-dereference"] : []),
699 "-czf",
700 archivePath,
701 "-C",
702 stageRoot,
703 stem,
704 ]),
705 "tar (gzip)",
706 );
707 }
708 const stat = yield* fs.stat(archivePath);
709 yield* Effect.log(`[cli-archive] Wrote ${archivePath} (${String(stat.size)} bytes).`);
710 return archivePath;
711});
712​
713const command = Command.make(
714 "build-cli-archive",
715 {
716 platform: Flag.Literals("platform", BuildPlatform.literals),
717 arch: Flag.Literals("arch", BuildArch.literals),
718 version: Flag.String("version").pipe(
719 Flag.withDescription("Release version for the archive name."),
720 ),
721 outputDir: Flag.String("output-dir").pipe(Flag.withDefault("release-cli")),
722 resourceMonitorDir: Flag.String("resource-monitor-dir").pipe(
723 Flag.withDescription(
724 "Directory laid out like dist/resource-monitor (defaults to apps/server/dist/resource-monitor).",
725 ),
726 Flag.optional,
727 ),
728 notarySubmissionFile: Flag.String("notary-submission-file").pipe(
729 Flag.withDescription(
730 "macOS: submit for notarization without waiting and write the submission id here. Publish only after await-notarization succeeds.",
731 ),
732 Flag.optional,
733 ),
734 },
735 (input) => buildCliArchive(input).pipe(Effect.scoped),
736).pipe(
737 Command.withDescription("Package the t3 single-executable into a per-platform archive."),
738 Command.withSubcommands([
739 Command.make(
740 "await-notarization",
741 { submissionFile: Flag.String("submission-file") },
742 (input) => awaitCliNotarization(input.submissionFile),
743 ).pipe(
744 Command.withDescription(
745 "Wait for a --notary-submission-file submission; fail unless Accepted.",
746 ),
747 ),
748 ]),
749);
750​
751if (import.meta.main) {
752 Command.run(command, { version: "0.0.0" }).pipe(
753 Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),
754 NodeRuntime.runMain,
755 );