| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Packages the server single-executable into a self-contained per-platform |
| 4 | * archive: the `t3` binary, the web client, the resource monitor, and a |
| 5 | * production install of the native packages the bundle keeps external. The |
| 6 | * archive is the unit every runtime installer downloads, so nothing in it may |
| 7 | * require Node, npm, or a compiler on the machine that unpacks it. |
| 8 | * |
| 9 | * Layout inside the archive (a single top-level directory named after the |
| 10 | * archive stem): |
| 11 | * |
| 12 | * t3 | t3.exe the single-executable |
| 13 | * client/ web app served by the server |
| 14 | * resource-monitor/ per-platform Rust helper, same paths as the npm package |
| 15 | * node_modules/ runtime externals (node-pty, msgpackr-extract, fff) |
| 16 | */ |
| 17 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 19 | import * as Config from "effect/Config"; |
| 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as FileSystem from "effect/FileSystem"; |
| 22 | import * as Layer from "effect/Layer"; |
| 23 | import * as Logger from "effect/Logger"; |
| 24 | import * as Option from "effect/Option"; |
| 25 | import * as Path from "effect/Path"; |
| 26 | import * as PlatformError from "effect/PlatformError"; |
| 27 | import * as Schema from "effect/Schema"; |
| 28 | import { Command, Flag } from "effect/cli"; |
| 29 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 30 | |
| 31 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 32 | import { fromYaml } from "@t3tools/shared/schemaYaml"; |
| 33 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 34 | import rootPackageJson from "../package.json" with { type: "json" }; |
| 35 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; |
| 36 | |
| 37 | import { |
| 38 | createStagePatchedDependencies, |
| 39 | createStageWorkspaceConfig, |
| 40 | resolveFffNativeDependencies, |
| 41 | STAGE_INSTALL_ARGS, |
| 42 | } from "./build-desktop-artifact.ts"; |
| 43 | import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts"; |
| 44 | import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; |
| 45 | |
| 46 | const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); |
| 47 | const BuildArch = Schema.Literals(["arm64", "x64"]); |
| 48 | type BuildPlatform = typeof BuildPlatform.Type; |
| 49 | type BuildArch = typeof BuildArch.Type; |
| 50 | |
| 51 | const WorkspaceConfig = Schema.Struct({ |
| 52 | catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 53 | overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 54 | patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 55 | allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), |
| 56 | }); |
| 57 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); |
| 58 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 59 | const StageWorkspaceConfig = Schema.Struct({ |
| 60 | supportedArchitectures: Schema.Struct({ |
| 61 | os: Schema.Array(Schema.String), |
| 62 | cpu: Schema.Array(Schema.String), |
| 63 | libc: Schema.optional(Schema.Array(Schema.String)), |
| 64 | }), |
| 65 | allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), |
| 66 | patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 67 | overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 68 | nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), |
| 69 | }); |
| 70 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); |
| 71 | |
| 72 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 73 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 74 | ); |
| 75 | |
| 76 | export class CliArchiveCommandFailedError extends Schema.TaggedError<CliArchiveCommandFailedError>()( |
| 77 | "CliArchiveCommandFailedError", |
| 78 | { command: Schema.String, exitCode: Schema.Int }, |
| 79 | ) { |
| 80 | override get message(): string { |
| 81 | return `${this.command} exited with code ${this.exitCode}.`; |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | export class CliArchiveInputMissingError extends Schema.TaggedError<CliArchiveInputMissingError>()( |
| 86 | "CliArchiveInputMissingError", |
| 87 | { inputPath: Schema.String, hint: Schema.String }, |
| 88 | ) { |
| 89 | override get message(): string { |
| 90 | return `Missing ${this.inputPath}. ${this.hint}`; |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** Platform/arch pair as it appears in archive names and `process.platform`/`process.arch`. */ |
| 95 | export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string { |
| 96 | const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; |
| 97 | return `${nodePlatform}-${arch}`; |
| 98 | } |
| 99 | |
| 100 | export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string { |
| 101 | return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`; |
| 102 | } |
| 103 | |
| 104 | export function cliArchiveFileName(version: string, platform: BuildPlatform, arch: BuildArch) { |
| 105 | // gzip rather than xz: GNU tar needs an external xz binary for -J, which |
| 106 | // minimal hosts lack, while every tar (and Node's zlib) handles gzip alone. |
| 107 | return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`; |
| 108 | } |
| 109 | |
| 110 | /** The bsdtar Windows ships in System32; resolves regardless of which tar is first on PATH. */ |
| 111 | export function windowsSystemTar(): string { |
| 112 | const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows"; |
| 113 | return `${systemRoot}\\System32\\tar.exe`; |
| 114 | } |
| 115 | |
| 116 | const runCommand = Effect.fn("runCommand")(function* ( |
| 117 | command: ChildProcess.Command, |
| 118 | label: string, |
| 119 | ) { |
| 120 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 121 | // Output is inherited so the build log shows what each tool did; a failing |
| 122 | // signing or packaging step is otherwise a bare exit code. |
| 123 | const child = yield* spawner.spawn( |
| 124 | ChildProcess.isStandardCommand(command) |
| 125 | ? ChildProcess.make(command.command, command.args, { |
| 126 | ...command.options, |
| 127 | stdout: "inherit", |
| 128 | stderr: "inherit", |
| 129 | }) |
| 130 | : command, |
| 131 | ); |
| 132 | const exitCode = Number(yield* child.exitCode); |
| 133 | if (exitCode !== 0) { |
| 134 | return yield* new CliArchiveCommandFailedError({ command: label, exitCode }); |
| 135 | } |
| 136 | }); |
| 137 | |
| 138 | const requireInput = Effect.fn("requireInput")(function* (inputPath: string, hint: string) { |
| 139 | const fs = yield* FileSystem.FileSystem; |
| 140 | if (!(yield* fs.exists(inputPath))) { |
| 141 | return yield* new CliArchiveInputMissingError({ inputPath, hint }); |
| 142 | } |
| 143 | }); |
| 144 | |
| 145 | /** |
| 146 | * Installs the runtime-external packages into `stageDir/node_modules` with a |
| 147 | * hoisted, symlink-free layout. The tree is archived and unpacked on machines |
| 148 | * without pnpm, so the store layout cannot be relied on to survive the trip. |
| 149 | */ |
| 150 | const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (input: { |
| 151 | readonly repoRoot: string; |
| 152 | readonly stageDir: string; |
| 153 | readonly platform: BuildPlatform; |
| 154 | readonly arch: BuildArch; |
| 155 | readonly version: string; |
| 156 | }) { |
| 157 | const fs = yield* FileSystem.FileSystem; |
| 158 | const path = yield* Path.Path; |
| 159 | const workspace = yield* decodeWorkspaceConfig( |
| 160 | yield* fs.readFileString(path.join(input.repoRoot, "pnpm-workspace.yaml")), |
| 161 | ); |
| 162 | const catalog = workspace.catalog ?? {}; |
| 163 | const serverDependencies = resolveCatalogDependencies( |
| 164 | serverPackageJson.dependencies, |
| 165 | catalog, |
| 166 | "apps/server", |
| 167 | ); |
| 168 | const fffNodeVersion = serverDependencies["@ff-labs/fff-node"]; |
| 169 | if (fffNodeVersion === undefined) { |
| 170 | return yield* new CliArchiveInputMissingError({ |
| 171 | inputPath: "apps/server/package.json#dependencies['@ff-labs/fff-node']", |
| 172 | hint: "The archive stages fff's platform binary from this version.", |
| 173 | }); |
| 174 | } |
| 175 | const dependencies = { |
| 176 | ...selectCliRuntimeExternalDependencies(serverDependencies), |
| 177 | ...resolveFffNativeDependencies(input.platform, input.arch, fffNodeVersion), |
| 178 | }; |
| 179 | const patchedDependencies = createStagePatchedDependencies( |
| 180 | workspace.patchedDependencies ?? {}, |
| 181 | dependencies, |
| 182 | ); |
| 183 | |
| 184 | yield* fs.writeFileString( |
| 185 | path.join(input.stageDir, "package.json"), |
| 186 | `${yield* encodeJsonString({ |
| 187 | name: "t3-runtime", |
| 188 | version: input.version, |
| 189 | private: true, |
| 190 | packageManager: rootPackageJson.packageManager, |
| 191 | dependencies, |
| 192 | })}\n`, |
| 193 | ); |
| 194 | yield* fs.writeFileString( |
| 195 | path.join(input.stageDir, "pnpm-workspace.yaml"), |
| 196 | yield* encodeStageWorkspaceConfig({ |
| 197 | ...createStageWorkspaceConfig({ |
| 198 | platform: input.platform, |
| 199 | arch: input.arch, |
| 200 | ...(workspace.allowBuilds ? { allowBuilds: workspace.allowBuilds } : {}), |
| 201 | patchedDependencies, |
| 202 | overrides: resolveCatalogDependencies(workspace.overrides ?? {}, catalog, "apps/server"), |
| 203 | }), |
| 204 | nodeLinker: "hoisted", |
| 205 | }), |
| 206 | ); |
| 207 | if (Object.keys(patchedDependencies).length > 0) { |
| 208 | yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(input.stageDir, "patches")); |
| 209 | } |
| 210 | |
| 211 | const install = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); |
| 212 | yield* runCommand( |
| 213 | ChildProcess.make(install.command, install.args, { |
| 214 | cwd: input.stageDir, |
| 215 | shell: install.shell, |
| 216 | stdout: "inherit", |
| 217 | stderr: "inherit", |
| 218 | }), |
| 219 | "vp install --prod (cli archive runtime externals)", |
| 220 | ); |
| 221 | |
| 222 | // pnpm's bookkeeping and the manifest only matter to pnpm; the runtime |
| 223 | // resolves packages by directory. node-pty ships every platform's prebuilds |
| 224 | // in one package (58 MB); only the archive's own platform loads. |
| 225 | const platformKey = cliArchivePlatformKey(input.platform, input.arch); |
| 226 | const prebuildsDir = path.join(input.stageDir, "node_modules/node-pty/prebuilds"); |
| 227 | const foreignPrebuilds = (yield* fs |
| 228 | .readDirectory(prebuildsDir) |
| 229 | .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter( |
| 230 | (entry) => entry !== platformKey, |
| 231 | ); |
| 232 | for (const entry of [ |
| 233 | "package.json", |
| 234 | "pnpm-workspace.yaml", |
| 235 | "pnpm-lock.yaml", |
| 236 | "patches", |
| 237 | "node_modules/.pnpm", |
| 238 | "node_modules/.modules.yaml", |
| 239 | "node_modules/.pnpm-workspace-state-v1.json", |
| 240 | "node_modules/.bin", |
| 241 | ...foreignPrebuilds.map((entry) => `node_modules/node-pty/prebuilds/${entry}`), |
| 242 | ]) { |
| 243 | yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); |
| 244 | } |
| 245 | // A hoisted install still leaves nested `node_modules/.bin` shim directories |
| 246 | // inside packages that declare bins (msgpackr-extract's). They are symlinks |
| 247 | // nothing runs, and the npm registry refuses a tarball that contains any |
| 248 | // symlink, so strip every `.bin` directory below node_modules. |
| 249 | yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules")); |
| 250 | }); |
| 251 | |
| 252 | const removeNestedBinDirectories = ( |
| 253 | fs: FileSystem.FileSystem, |
| 254 | path: Path.Path, |
| 255 | root: string, |
| 256 | ): Effect.Effect<void, PlatformError.PlatformError> => |
| 257 | Effect.gen(function* () { |
| 258 | const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); |
| 259 | for (const entry of entries) { |
| 260 | const child = path.join(root, entry); |
| 261 | if (entry === ".bin") { |
| 262 | yield* fs.remove(child, { recursive: true, force: true }); |
| 263 | continue; |
| 264 | } |
| 265 | const info = yield* fs.stat(child).pipe(Effect.option); |
| 266 | if (Option.isSome(info) && info.value.type === "Directory") { |
| 267 | yield* removeNestedBinDirectories(fs, path, child); |
| 268 | } |
| 269 | } |
| 270 | }); |
| 271 | |
| 272 | /** Copies the web client without its sourcemaps, which nothing serves. */ |
| 273 | const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { |
| 274 | const fs = yield* FileSystem.FileSystem; |
| 275 | const path = yield* Path.Path; |
| 276 | yield* fs.copy(source, target); |
| 277 | const maps = (yield* fs.readDirectory(target, { recursive: true })).filter((entry) => |
| 278 | entry.endsWith(".map"), |
| 279 | ); |
| 280 | for (const entry of maps) { |
| 281 | yield* fs.remove(path.join(target, entry), { force: true }); |
| 282 | } |
| 283 | }); |
| 284 | |
| 285 | const MacSigningConfig = Config.all({ |
| 286 | identity: Config.String("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option), |
| 287 | appleApiKey: Config.String("APPLE_API_KEY").pipe(Config.option), |
| 288 | appleApiKeyId: Config.String("APPLE_API_KEY_ID").pipe(Config.option), |
| 289 | appleApiIssuer: Config.String("APPLE_API_ISSUER").pipe(Config.option), |
| 290 | }); |
| 291 | |
| 292 | /** |
| 293 | * Apple Silicon refuses to run unsigned Mach-O binaries at all, so the |
| 294 | * executable is always signed: ad hoc when no identity is configured, or with |
| 295 | * the Developer ID plus notarization when it is. The hardened runtime that |
| 296 | * notarization requires only loads signed libraries, so every native addon in |
| 297 | * the archive is signed with the same identity. |
| 298 | */ |
| 299 | const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: { |
| 300 | readonly repoRoot: string; |
| 301 | readonly contentDir: string; |
| 302 | readonly executablePath: string; |
| 303 | }) { |
| 304 | const fs = yield* FileSystem.FileSystem; |
| 305 | const path = yield* Path.Path; |
| 306 | const signing = yield* MacSigningConfig; |
| 307 | const identity = Option.getOrUndefined(signing.identity)?.trim() || "-"; |
| 308 | |
| 309 | const entitlements = path.join(input.repoRoot, "apps/server/resources/cli-entitlements.plist"); |
| 310 | const libraries = (yield* fs.readDirectory(input.contentDir, { recursive: true })) |
| 311 | .filter( |
| 312 | (entry) => |
| 313 | entry.endsWith(".node") || |
| 314 | entry.endsWith(".dylib") || |
| 315 | entry.endsWith("spawn-helper") || |
| 316 | entry.endsWith("t3-resource-monitor"), |
| 317 | ) |
| 318 | .map((entry) => path.join(input.contentDir, entry)); |
| 319 | |
| 320 | for (const target of [...libraries, input.executablePath]) { |
| 321 | yield* runCommand( |
| 322 | ChildProcess.make("codesign", [ |
| 323 | "--force", |
| 324 | "--sign", |
| 325 | identity, |
| 326 | ...(identity === "-" ? [] : ["--options", "runtime", "--timestamp"]), |
| 327 | ...(target === input.executablePath ? ["--entitlements", entitlements] : []), |
| 328 | target, |
| 329 | ]), |
| 330 | `codesign ${path.relative(input.contentDir, target)}`, |
| 331 | ); |
| 332 | } |
| 333 | if (identity === "-") { |
| 334 | yield* Effect.log("[cli-archive] Signed ad hoc (no T3CODE_CLI_MAC_SIGN_IDENTITY)."); |
| 335 | return; |
| 336 | } |
| 337 | |
| 338 | const apiKey = Option.getOrUndefined(signing.appleApiKey); |
| 339 | const apiKeyId = Option.getOrUndefined(signing.appleApiKeyId); |
| 340 | const apiIssuer = Option.getOrUndefined(signing.appleApiIssuer); |
| 341 | if (!apiKey || !apiKeyId || !apiIssuer) { |
| 342 | yield* Effect.logWarning( |
| 343 | "[cli-archive] Developer ID signed but not notarized (missing APPLE_API_KEY*).", |
| 344 | ); |
| 345 | return; |
| 346 | } |
| 347 | // notarytool only accepts archives, and a bare executable cannot be stapled, |
| 348 | // so notarize a zip of the binary and rely on the online ticket lookup. |
| 349 | const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip"); |
| 350 | yield* runCommand( |
| 351 | ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]), |
| 352 | "ditto (notarization zip)", |
| 353 | ); |
| 354 | yield* runCommand( |
| 355 | ChildProcess.make("xcrun", [ |
| 356 | "notarytool", |
| 357 | "submit", |
| 358 | notarizeZip, |
| 359 | "--key", |
| 360 | apiKey, |
| 361 | "--key-id", |
| 362 | apiKeyId, |
| 363 | "--issuer", |
| 364 | apiIssuer, |
| 365 | "--wait", |
| 366 | ]), |
| 367 | "notarytool submit", |
| 368 | ).pipe(Effect.ensuring(fs.remove(notarizeZip, { force: true }).pipe(Effect.ignore))); |
| 369 | yield* Effect.log("[cli-archive] Notarized t3."); |
| 370 | }); |
| 371 | |
| 372 | const WindowsSigningConfig = Config.all({ |
| 373 | endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT").pipe(Config.option), |
| 374 | accountName: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME").pipe(Config.option), |
| 375 | certificateProfileName: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME").pipe( |
| 376 | Config.option, |
| 377 | ), |
| 378 | }); |
| 379 | |
| 380 | /** |
| 381 | * Node's --build-sea injects the blob into a copy of node.exe by rebuilding |
| 382 | * its resource section but, unlike its Mach-O path, leaves node's original |
| 383 | * Authenticode data-directory entry in the PE header. The file grows, so the |
| 384 | * entry now points into the middle of the new section at bytes that are not a |
| 385 | * certificate table. signtool refuses to sign such an image (0x800700C1, "not |
| 386 | * a valid Win32 application") and cannot `remove /s` it either, since the SIP |
| 387 | * fails to parse the garbage. Clearing the entry is exactly what a signature |
| 388 | * strip does, without needing a parser that trusts the broken table. |
| 389 | */ |
| 390 | const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* ( |
| 391 | executablePath: string, |
| 392 | ) { |
| 393 | const fs = yield* FileSystem.FileSystem; |
| 394 | const bytes = yield* fs.readFile(executablePath); |
| 395 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 396 | const peOffset = view.getUint32(0x3c, true); |
| 397 | if (view.getUint32(peOffset, true) !== 0x00004550) { |
| 398 | return yield* new CliArchiveInputMissingError({ |
| 399 | inputPath: executablePath, |
| 400 | hint: "Expected a PE executable to strip the stale signature entry from.", |
| 401 | }); |
| 402 | } |
| 403 | const optionalHeader = peOffset + 24; |
| 404 | const magic = view.getUint16(optionalHeader, true); |
| 405 | // Data directories start at +112 (PE32+) or +96 (PE32); the certificate |
| 406 | // table is directory index 4, eight bytes (file offset, size). |
| 407 | const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8; |
| 408 | const offset = view.getUint32(securityEntry, true); |
| 409 | const size = view.getUint32(securityEntry + 4, true); |
| 410 | if (offset === 0 && size === 0) return; |
| 411 | if (offset + size === bytes.byteLength) { |
| 412 | // A certificate table that still ends at EOF is intact; leave it for |
| 413 | // signtool to replace rather than second-guessing it here. |
| 414 | return; |
| 415 | } |
| 416 | view.setUint32(securityEntry, 0, true); |
| 417 | view.setUint32(securityEntry + 4, 0, true); |
| 418 | yield* fs.writeFile(executablePath, bytes); |
| 419 | yield* Effect.log( |
| 420 | `[cli-archive] Cleared the stale Authenticode entry (offset ${String(offset)}, size ${String(size)}) left by --build-sea.`, |
| 421 | ); |
| 422 | }); |
| 423 | |
| 424 | /** Signs t3.exe through the same Azure Trusted Signing setup the installer uses. */ |
| 425 | const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* ( |
| 426 | executablePath: string, |
| 427 | ) { |
| 428 | const signing = yield* WindowsSigningConfig; |
| 429 | const endpoint = Option.getOrUndefined(signing.endpoint); |
| 430 | const accountName = Option.getOrUndefined(signing.accountName); |
| 431 | const profile = Option.getOrUndefined(signing.certificateProfileName); |
| 432 | if (!endpoint || !accountName || !profile) { |
| 433 | yield* Effect.log("[cli-archive] Windows signing disabled (missing Azure Trusted Signing)."); |
| 434 | return; |
| 435 | } |
| 436 | yield* stripStaleAuthenticodeEntry(executablePath); |
| 437 | // Mirrors electron-builder's invocation for the installer: every value |
| 438 | // single-quoted, the file path in Windows form. `$ErrorActionPreference` |
| 439 | // makes a signing failure inside the cmdlet surface as a non-zero exit. |
| 440 | const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; |
| 441 | const script = [ |
| 442 | "$ErrorActionPreference = 'Stop';", |
| 443 | "Invoke-TrustedSigning", |
| 444 | `-Endpoint ${quote(endpoint)}`, |
| 445 | `-CodeSigningAccountName ${quote(accountName)}`, |
| 446 | `-CertificateProfileName ${quote(profile)}`, |
| 447 | "-FileDigest 'SHA256'", |
| 448 | "-TimestampRfc3161 'http://timestamp.acs.microsoft.com'", |
| 449 | "-TimestampDigest 'SHA256'", |
| 450 | `-Files ${quote(executablePath)}`, |
| 451 | ].join(" "); |
| 452 | yield* runCommand( |
| 453 | ChildProcess.make("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script]), |
| 454 | "Invoke-TrustedSigning t3.exe", |
| 455 | ); |
| 456 | yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing)."); |
| 457 | }); |
| 458 | |
| 459 | const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { |
| 460 | readonly platform: BuildPlatform; |
| 461 | readonly arch: BuildArch; |
| 462 | readonly version: string; |
| 463 | readonly outputDir: string; |
| 464 | readonly resourceMonitorDir: Option.Option<string>; |
| 465 | }) { |
| 466 | const fs = yield* FileSystem.FileSystem; |
| 467 | const path = yield* Path.Path; |
| 468 | const repoRoot = yield* RepoRoot; |
| 469 | const serverDir = path.join(repoRoot, "apps/server"); |
| 470 | const executableName = input.platform === "win" ? "t3.exe" : "t3"; |
| 471 | // tsdown suffixes cross-built executables with their target (t3-darwin-x64); |
| 472 | // a host build is plain t3. Prefer the exact target when both exist. |
| 473 | const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`; |
| 474 | const targetExecutable = path.join( |
| 475 | serverDir, |
| 476 | "dist-exe", |
| 477 | `t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`, |
| 478 | ); |
| 479 | // The unsuffixed host build is only a valid stand-in when it was built for |
| 480 | // this platform and architecture; otherwise a missing target must fail. |
| 481 | const hostPlatform = yield* HostProcess.Platform; |
| 482 | const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcess.Architecture}`; |
| 483 | const builtExecutable = (yield* fs.exists(targetExecutable)) |
| 484 | ? targetExecutable |
| 485 | : targetKey === hostKey |
| 486 | ? path.join(serverDir, "dist-exe", executableName) |
| 487 | : targetExecutable; |
| 488 | const webClient = path.join(serverDir, "dist/client"); |
| 489 | const resourceMonitorDir = Option.getOrElse(input.resourceMonitorDir, () => |
| 490 | path.join(serverDir, "dist/resource-monitor"), |
| 491 | ); |
| 492 | |
| 493 | yield* requireInput( |
| 494 | builtExecutable, |
| 495 | `Run \`node apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`, |
| 496 | ); |
| 497 | yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first."); |
| 498 | yield* requireInput( |
| 499 | resourceMonitorDir, |
| 500 | "Build the resource monitor or pass --resource-monitor-dir.", |
| 501 | ); |
| 502 | |
| 503 | const stem = cliArchiveStem(input.version, input.platform, input.arch); |
| 504 | const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-archive-" }); |
| 505 | const contentDir = path.join(stageRoot, stem); |
| 506 | yield* fs.makeDirectory(contentDir, { recursive: true }); |
| 507 | |
| 508 | yield* Effect.log(`[cli-archive] Staging ${stem}...`); |
| 509 | yield* fs.copyFile(builtExecutable, path.join(contentDir, executableName)); |
| 510 | yield* stageWebClient(webClient, path.join(contentDir, "client")); |
| 511 | yield* fs.copy(resourceMonitorDir, path.join(contentDir, "resource-monitor")); |
| 512 | yield* stageRuntimeExternals({ |
| 513 | repoRoot, |
| 514 | stageDir: contentDir, |
| 515 | platform: input.platform, |
| 516 | arch: input.arch, |
| 517 | version: input.version, |
| 518 | }); |
| 519 | |
| 520 | const executablePath = path.join(contentDir, executableName); |
| 521 | if (input.platform === "mac") { |
| 522 | yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); |
| 523 | } else if (input.platform === "win") { |
| 524 | yield* signWindowsExecutable(executablePath); |
| 525 | } |
| 526 | if (input.platform !== "win") { |
| 527 | yield* fs.chmod(executablePath, 0o755); |
| 528 | } |
| 529 | |
| 530 | yield* fs.makeDirectory(input.outputDir, { recursive: true }); |
| 531 | const archivePath = path.join( |
| 532 | input.outputDir, |
| 533 | cliArchiveFileName(input.version, input.platform, input.arch), |
| 534 | ); |
| 535 | yield* fs.remove(archivePath, { force: true }); |
| 536 | if (input.platform === "win") { |
| 537 | // Windows ships bsdtar, which writes zip natively. Name it by path: under |
| 538 | // the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither |
| 539 | // writes zip nor accepts a drive-letter path. |
| 540 | yield* runCommand( |
| 541 | ChildProcess.make(windowsSystemTar(), ["-a", "-c", "-f", archivePath, "-C", stageRoot, stem]), |
| 542 | "tar (zip)", |
| 543 | ); |
| 544 | } else { |
| 545 | // On Linux, pnpm hard-links identical files out of its store and node-gyp |
| 546 | // hard-links build outputs, and GNU tar records those as link entries. |
| 547 | // The npm registry rejects a tarball containing any, and the npm platform |
| 548 | // packages are re-packed from this archive's contents, so store every |
| 549 | // file as a file. macOS's bsdtar has no such flag; pnpm clones there. |
| 550 | yield* runCommand( |
| 551 | ChildProcess.make("tar", [ |
| 552 | ...(input.platform === "linux" ? ["--hard-dereference"] : []), |
| 553 | "-czf", |
| 554 | archivePath, |
| 555 | "-C", |
| 556 | stageRoot, |
| 557 | stem, |
| 558 | ]), |
| 559 | "tar (gzip)", |
| 560 | ); |
| 561 | } |
| 562 | const stat = yield* fs.stat(archivePath); |
| 563 | yield* Effect.log(`[cli-archive] Wrote ${archivePath} (${String(stat.size)} bytes).`); |
| 564 | return archivePath; |
| 565 | }); |
| 566 | |
| 567 | const command = Command.make( |
| 568 | "build-cli-archive", |
| 569 | { |
| 570 | platform: Flag.Literals("platform", BuildPlatform.literals), |
| 571 | arch: Flag.Literals("arch", BuildArch.literals), |
| 572 | version: Flag.String("version").pipe( |
| 573 | Flag.withDescription("Release version for the archive name."), |
| 574 | ), |
| 575 | outputDir: Flag.String("output-dir").pipe(Flag.withDefault("release-cli")), |
| 576 | resourceMonitorDir: Flag.String("resource-monitor-dir").pipe( |
| 577 | Flag.withDescription( |
| 578 | "Directory laid out like dist/resource-monitor (defaults to apps/server/dist/resource-monitor).", |
| 579 | ), |
| 580 | Flag.optional, |
| 581 | ), |
| 582 | }, |
| 583 | (input) => buildCliArchive(input).pipe(Effect.scoped), |
| 584 | ).pipe(Command.withDescription("Package the t3 single-executable into a per-platform archive.")); |
| 585 | |
| 586 | if (import.meta.main) { |
| 587 | Command.run(command, { version: "0.0.0" }).pipe( |
| 588 | Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), |
| 589 | NodeRuntime.runMain, |
| 590 | ); |
| 591 | } |