t3-code-android-nightly/scripts/build-cli-archive.ts
Julius Marminge 194c73f3f9
chore(deps): upgrade Effect to stable 4.0.1 (#16138)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:15:14 -07:00

591 lines
24 KiB
JavaScript

#!/usr/bin/env node
/**
* Packages the server single-executable into a self-contained per-platform
* archive: the `t3` binary, the web client, the resource monitor, and a
* production install of the native packages the bundle keeps external. The
* archive is the unit every runtime installer downloads, so nothing in it may
* require Node, npm, or a compiler on the machine that unpacks it.
*
* Layout inside the archive (a single top-level directory named after the
* archive stem):
*
* t3 | t3.exe the single-executable
* client/ web app served by the server
* resource-monitor/ per-platform Rust helper, same paths as the npm package
* node_modules/ runtime externals (node-pty, msgpackr-extract, fff)
*/
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import { Command, Flag } from "effect/cli";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { fromYaml } from "@t3tools/shared/schemaYaml";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import rootPackageJson from "../package.json" with { type: "json" };
import serverPackageJson from "../apps/server/package.json" with { type: "json" };
import {
createStagePatchedDependencies,
createStageWorkspaceConfig,
resolveFffNativeDependencies,
STAGE_INSTALL_ARGS,
} from "./build-desktop-artifact.ts";
import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts";
import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
const BuildArch = Schema.Literals(["arm64", "x64"]);
type BuildPlatform = typeof BuildPlatform.Type;
type BuildArch = typeof BuildArch.Type;
const WorkspaceConfig = Schema.Struct({
catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)),
overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
});
const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
const StageWorkspaceConfig = Schema.Struct({
supportedArchitectures: Schema.Struct({
os: Schema.Array(Schema.String),
cpu: Schema.Array(Schema.String),
libc: Schema.optional(Schema.Array(Schema.String)),
}),
allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
});
const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
const RepoRoot = Effect.service(Path.Path).pipe(
Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
);
export class CliArchiveCommandFailedError extends Schema.TaggedError<CliArchiveCommandFailedError>()(
"CliArchiveCommandFailedError",
{ command: Schema.String, exitCode: Schema.Int },
) {
override get message(): string {
return `${this.command} exited with code ${this.exitCode}.`;
}
}
export class CliArchiveInputMissingError extends Schema.TaggedError<CliArchiveInputMissingError>()(
"CliArchiveInputMissingError",
{ inputPath: Schema.String, hint: Schema.String },
) {
override get message(): string {
return `Missing ${this.inputPath}. ${this.hint}`;
}
}
/** Platform/arch pair as it appears in archive names and `process.platform`/`process.arch`. */
export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string {
const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
return `${nodePlatform}-${arch}`;
}
export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string {
return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`;
}
export function cliArchiveFileName(version: string, platform: BuildPlatform, arch: BuildArch) {
// gzip rather than xz: GNU tar needs an external xz binary for -J, which
// minimal hosts lack, while every tar (and Node's zlib) handles gzip alone.
return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`;
}
/** The bsdtar Windows ships in System32; resolves regardless of which tar is first on PATH. */
export function windowsSystemTar(): string {
const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
return `${systemRoot}\\System32\\tar.exe`;
}
const runCommand = Effect.fn("runCommand")(function* (
command: ChildProcess.Command,
label: string,
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
// Output is inherited so the build log shows what each tool did; a failing
// signing or packaging step is otherwise a bare exit code.
const child = yield* spawner.spawn(
ChildProcess.isStandardCommand(command)
? ChildProcess.make(command.command, command.args, {
...command.options,
stdout: "inherit",
stderr: "inherit",
})
: command,
);
const exitCode = Number(yield* child.exitCode);
if (exitCode !== 0) {
return yield* new CliArchiveCommandFailedError({ command: label, exitCode });
}
});
const requireInput = Effect.fn("requireInput")(function* (inputPath: string, hint: string) {
const fs = yield* FileSystem.FileSystem;
if (!(yield* fs.exists(inputPath))) {
return yield* new CliArchiveInputMissingError({ inputPath, hint });
}
});
/**
* Installs the runtime-external packages into `stageDir/node_modules` with a
* hoisted, symlink-free layout. The tree is archived and unpacked on machines
* without pnpm, so the store layout cannot be relied on to survive the trip.
*/
const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (input: {
readonly repoRoot: string;
readonly stageDir: string;
readonly platform: BuildPlatform;
readonly arch: BuildArch;
readonly version: string;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspace = yield* decodeWorkspaceConfig(
yield* fs.readFileString(path.join(input.repoRoot, "pnpm-workspace.yaml")),
);
const catalog = workspace.catalog ?? {};
const serverDependencies = resolveCatalogDependencies(
serverPackageJson.dependencies,
catalog,
"apps/server",
);
const fffNodeVersion = serverDependencies["@ff-labs/fff-node"];
if (fffNodeVersion === undefined) {
return yield* new CliArchiveInputMissingError({
inputPath: "apps/server/package.json#dependencies['@ff-labs/fff-node']",
hint: "The archive stages fff's platform binary from this version.",
});
}
const dependencies = {
...selectCliRuntimeExternalDependencies(serverDependencies),
...resolveFffNativeDependencies(input.platform, input.arch, fffNodeVersion),
};
const patchedDependencies = createStagePatchedDependencies(
workspace.patchedDependencies ?? {},
dependencies,
);
yield* fs.writeFileString(
path.join(input.stageDir, "package.json"),
`${yield* encodeJsonString({
name: "t3-runtime",
version: input.version,
private: true,
packageManager: rootPackageJson.packageManager,
dependencies,
})}\n`,
);
yield* fs.writeFileString(
path.join(input.stageDir, "pnpm-workspace.yaml"),
yield* encodeStageWorkspaceConfig({
...createStageWorkspaceConfig({
platform: input.platform,
arch: input.arch,
...(workspace.allowBuilds ? { allowBuilds: workspace.allowBuilds } : {}),
patchedDependencies,
overrides: resolveCatalogDependencies(workspace.overrides ?? {}, catalog, "apps/server"),
}),
nodeLinker: "hoisted",
}),
);
if (Object.keys(patchedDependencies).length > 0) {
yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(input.stageDir, "patches"));
}
const install = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
yield* runCommand(
ChildProcess.make(install.command, install.args, {
cwd: input.stageDir,
shell: install.shell,
stdout: "inherit",
stderr: "inherit",
}),
"vp install --prod (cli archive runtime externals)",
);
// pnpm's bookkeeping and the manifest only matter to pnpm; the runtime
// resolves packages by directory. node-pty ships every platform's prebuilds
// in one package (58 MB); only the archive's own platform loads.
const platformKey = cliArchivePlatformKey(input.platform, input.arch);
const prebuildsDir = path.join(input.stageDir, "node_modules/node-pty/prebuilds");
const foreignPrebuilds = (yield* fs
.readDirectory(prebuildsDir)
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter(
(entry) => entry !== platformKey,
);
for (const entry of [
"package.json",
"pnpm-workspace.yaml",
"pnpm-lock.yaml",
"patches",
"node_modules/.pnpm",
"node_modules/.modules.yaml",
"node_modules/.pnpm-workspace-state-v1.json",
"node_modules/.bin",
...foreignPrebuilds.map((entry) => `node_modules/node-pty/prebuilds/${entry}`),
]) {
yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true });
}
// A hoisted install still leaves nested `node_modules/.bin` shim directories
// inside packages that declare bins (msgpackr-extract's). They are symlinks
// nothing runs, and the npm registry refuses a tarball that contains any
// symlink, so strip every `.bin` directory below node_modules.
yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules"));
});
const removeNestedBinDirectories = (
fs: FileSystem.FileSystem,
path: Path.Path,
root: string,
): Effect.Effect<void, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []));
for (const entry of entries) {
const child = path.join(root, entry);
if (entry === ".bin") {
yield* fs.remove(child, { recursive: true, force: true });
continue;
}
const info = yield* fs.stat(child).pipe(Effect.option);
if (Option.isSome(info) && info.value.type === "Directory") {
yield* removeNestedBinDirectories(fs, path, child);
}
}
});
/** Copies the web client without its sourcemaps, which nothing serves. */
const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.copy(source, target);
const maps = (yield* fs.readDirectory(target, { recursive: true })).filter((entry) =>
entry.endsWith(".map"),
);
for (const entry of maps) {
yield* fs.remove(path.join(target, entry), { force: true });
}
});
const MacSigningConfig = Config.all({
identity: Config.String("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option),
appleApiKey: Config.String("APPLE_API_KEY").pipe(Config.option),
appleApiKeyId: Config.String("APPLE_API_KEY_ID").pipe(Config.option),
appleApiIssuer: Config.String("APPLE_API_ISSUER").pipe(Config.option),
});
/**
* Apple Silicon refuses to run unsigned Mach-O binaries at all, so the
* executable is always signed: ad hoc when no identity is configured, or with
* the Developer ID plus notarization when it is. The hardened runtime that
* notarization requires only loads signed libraries, so every native addon in
* the archive is signed with the same identity.
*/
const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: {
readonly repoRoot: string;
readonly contentDir: string;
readonly executablePath: string;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const signing = yield* MacSigningConfig;
const identity = Option.getOrUndefined(signing.identity)?.trim() || "-";
const entitlements = path.join(input.repoRoot, "apps/server/resources/cli-entitlements.plist");
const libraries = (yield* fs.readDirectory(input.contentDir, { recursive: true }))
.filter(
(entry) =>
entry.endsWith(".node") ||
entry.endsWith(".dylib") ||
entry.endsWith("spawn-helper") ||
entry.endsWith("t3-resource-monitor"),
)
.map((entry) => path.join(input.contentDir, entry));
for (const target of [...libraries, input.executablePath]) {
yield* runCommand(
ChildProcess.make("codesign", [
"--force",
"--sign",
identity,
...(identity === "-" ? [] : ["--options", "runtime", "--timestamp"]),
...(target === input.executablePath ? ["--entitlements", entitlements] : []),
target,
]),
`codesign ${path.relative(input.contentDir, target)}`,
);
}
if (identity === "-") {
yield* Effect.log("[cli-archive] Signed ad hoc (no T3CODE_CLI_MAC_SIGN_IDENTITY).");
return;
}
const apiKey = Option.getOrUndefined(signing.appleApiKey);
const apiKeyId = Option.getOrUndefined(signing.appleApiKeyId);
const apiIssuer = Option.getOrUndefined(signing.appleApiIssuer);
if (!apiKey || !apiKeyId || !apiIssuer) {
yield* Effect.logWarning(
"[cli-archive] Developer ID signed but not notarized (missing APPLE_API_KEY*).",
);
return;
}
// notarytool only accepts archives, and a bare executable cannot be stapled,
// so notarize a zip of the binary and rely on the online ticket lookup.
const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip");
yield* runCommand(
ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]),
"ditto (notarization zip)",
);
yield* runCommand(
ChildProcess.make("xcrun", [
"notarytool",
"submit",
notarizeZip,
"--key",
apiKey,
"--key-id",
apiKeyId,
"--issuer",
apiIssuer,
"--wait",
]),
"notarytool submit",
).pipe(Effect.ensuring(fs.remove(notarizeZip, { force: true }).pipe(Effect.ignore)));
yield* Effect.log("[cli-archive] Notarized t3.");
});
const WindowsSigningConfig = Config.all({
endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT").pipe(Config.option),
accountName: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME").pipe(Config.option),
certificateProfileName: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME").pipe(
Config.option,
),
});
/**
* Node's --build-sea injects the blob into a copy of node.exe by rebuilding
* its resource section but, unlike its Mach-O path, leaves node's original
* Authenticode data-directory entry in the PE header. The file grows, so the
* entry now points into the middle of the new section at bytes that are not a
* certificate table. signtool refuses to sign such an image (0x800700C1, "not
* a valid Win32 application") and cannot `remove /s` it either, since the SIP
* fails to parse the garbage. Clearing the entry is exactly what a signature
* strip does, without needing a parser that trusts the broken table.
*/
const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* (
executablePath: string,
) {
const fs = yield* FileSystem.FileSystem;
const bytes = yield* fs.readFile(executablePath);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const peOffset = view.getUint32(0x3c, true);
if (view.getUint32(peOffset, true) !== 0x00004550) {
return yield* new CliArchiveInputMissingError({
inputPath: executablePath,
hint: "Expected a PE executable to strip the stale signature entry from.",
});
}
const optionalHeader = peOffset + 24;
const magic = view.getUint16(optionalHeader, true);
// Data directories start at +112 (PE32+) or +96 (PE32); the certificate
// table is directory index 4, eight bytes (file offset, size).
const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8;
const offset = view.getUint32(securityEntry, true);
const size = view.getUint32(securityEntry + 4, true);
if (offset === 0 && size === 0) return;
if (offset + size === bytes.byteLength) {
// A certificate table that still ends at EOF is intact; leave it for
// signtool to replace rather than second-guessing it here.
return;
}
view.setUint32(securityEntry, 0, true);
view.setUint32(securityEntry + 4, 0, true);
yield* fs.writeFile(executablePath, bytes);
yield* Effect.log(
`[cli-archive] Cleared the stale Authenticode entry (offset ${String(offset)}, size ${String(size)}) left by --build-sea.`,
);
});
/** Signs t3.exe through the same Azure Trusted Signing setup the installer uses. */
const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* (
executablePath: string,
) {
const signing = yield* WindowsSigningConfig;
const endpoint = Option.getOrUndefined(signing.endpoint);
const accountName = Option.getOrUndefined(signing.accountName);
const profile = Option.getOrUndefined(signing.certificateProfileName);
if (!endpoint || !accountName || !profile) {
yield* Effect.log("[cli-archive] Windows signing disabled (missing Azure Trusted Signing).");
return;
}
yield* stripStaleAuthenticodeEntry(executablePath);
// Mirrors electron-builder's invocation for the installer: every value
// single-quoted, the file path in Windows form. `$ErrorActionPreference`
// makes a signing failure inside the cmdlet surface as a non-zero exit.
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
const script = [
"$ErrorActionPreference = 'Stop';",
"Invoke-TrustedSigning",
`-Endpoint ${quote(endpoint)}`,
`-CodeSigningAccountName ${quote(accountName)}`,
`-CertificateProfileName ${quote(profile)}`,
"-FileDigest 'SHA256'",
"-TimestampRfc3161 'http://timestamp.acs.microsoft.com'",
"-TimestampDigest 'SHA256'",
`-Files ${quote(executablePath)}`,
].join(" ");
yield* runCommand(
ChildProcess.make("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script]),
"Invoke-TrustedSigning t3.exe",
);
yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing).");
});
const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {
readonly platform: BuildPlatform;
readonly arch: BuildArch;
readonly version: string;
readonly outputDir: string;
readonly resourceMonitorDir: Option.Option<string>;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const repoRoot = yield* RepoRoot;
const serverDir = path.join(repoRoot, "apps/server");
const executableName = input.platform === "win" ? "t3.exe" : "t3";
// tsdown suffixes cross-built executables with their target (t3-darwin-x64);
// a host build is plain t3. Prefer the exact target when both exist.
const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`;
const targetExecutable = path.join(
serverDir,
"dist-exe",
`t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`,
);
// The unsuffixed host build is only a valid stand-in when it was built for
// this platform and architecture; otherwise a missing target must fail.
const hostPlatform = yield* HostProcessPlatform;
const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcessArchitecture}`;
const builtExecutable = (yield* fs.exists(targetExecutable))
? targetExecutable
: targetKey === hostKey
? path.join(serverDir, "dist-exe", executableName)
: targetExecutable;
const webClient = path.join(serverDir, "dist/client");
const resourceMonitorDir = Option.getOrElse(input.resourceMonitorDir, () =>
path.join(serverDir, "dist/resource-monitor"),
);
yield* requireInput(
builtExecutable,
`Run \`node apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`,
);
yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first.");
yield* requireInput(
resourceMonitorDir,
"Build the resource monitor or pass --resource-monitor-dir.",
);
const stem = cliArchiveStem(input.version, input.platform, input.arch);
const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-archive-" });
const contentDir = path.join(stageRoot, stem);
yield* fs.makeDirectory(contentDir, { recursive: true });
yield* Effect.log(`[cli-archive] Staging ${stem}...`);
yield* fs.copyFile(builtExecutable, path.join(contentDir, executableName));
yield* stageWebClient(webClient, path.join(contentDir, "client"));
yield* fs.copy(resourceMonitorDir, path.join(contentDir, "resource-monitor"));
yield* stageRuntimeExternals({
repoRoot,
stageDir: contentDir,
platform: input.platform,
arch: input.arch,
version: input.version,
});
const executablePath = path.join(contentDir, executableName);
if (input.platform === "mac") {
yield* signMacArchiveContents({ repoRoot, contentDir, executablePath });
} else if (input.platform === "win") {
yield* signWindowsExecutable(executablePath);
}
if (input.platform !== "win") {
yield* fs.chmod(executablePath, 0o755);
}
yield* fs.makeDirectory(input.outputDir, { recursive: true });
const archivePath = path.join(
input.outputDir,
cliArchiveFileName(input.version, input.platform, input.arch),
);
yield* fs.remove(archivePath, { force: true });
if (input.platform === "win") {
// Windows ships bsdtar, which writes zip natively. Name it by path: under
// the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither
// writes zip nor accepts a drive-letter path.
yield* runCommand(
ChildProcess.make(windowsSystemTar(), ["-a", "-c", "-f", archivePath, "-C", stageRoot, stem]),
"tar (zip)",
);
} else {
// On Linux, pnpm hard-links identical files out of its store and node-gyp
// hard-links build outputs, and GNU tar records those as link entries.
// The npm registry rejects a tarball containing any, and the npm platform
// packages are re-packed from this archive's contents, so store every
// file as a file. macOS's bsdtar has no such flag; pnpm clones there.
yield* runCommand(
ChildProcess.make("tar", [
...(input.platform === "linux" ? ["--hard-dereference"] : []),
"-czf",
archivePath,
"-C",
stageRoot,
stem,
]),
"tar (gzip)",
);
}
const stat = yield* fs.stat(archivePath);
yield* Effect.log(`[cli-archive] Wrote ${archivePath} (${String(stat.size)} bytes).`);
return archivePath;
});
const command = Command.make(
"build-cli-archive",
{
platform: Flag.Literals("platform", BuildPlatform.literals),
arch: Flag.Literals("arch", BuildArch.literals),
version: Flag.String("version").pipe(
Flag.withDescription("Release version for the archive name."),
),
outputDir: Flag.String("output-dir").pipe(Flag.withDefault("release-cli")),
resourceMonitorDir: Flag.String("resource-monitor-dir").pipe(
Flag.withDescription(
"Directory laid out like dist/resource-monitor (defaults to apps/server/dist/resource-monitor).",
),
Flag.optional,
),
},
(input) => buildCliArchive(input).pipe(Effect.scoped),
).pipe(Command.withDescription("Package the t3 single-executable into a per-platform archive."));
if (import.meta.main) {
Command.run(command, { version: "0.0.0" }).pipe(
Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),
NodeRuntime.runMain,
);
}