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 */
17import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
18import * as NodeServices from "@effect/platform-node/NodeServices";
19import * as Config from "effect/Config";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Logger from "effect/Logger";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as PlatformError from "effect/PlatformError";
27import * as Schema from "effect/Schema";
28import { Command, Flag } from "effect/cli";
29import { ChildProcess, ChildProcessSpawner } from "effect/process";
30
31import * as HostProcess from "@t3tools/shared/HostProcess";
32import { fromYaml } from "@t3tools/shared/schemaYaml";
33import { resolveSpawnCommand } from "@t3tools/shared/shell";
34import rootPackageJson from "../package.json" with { type: "json" };
35import serverPackageJson from "../apps/server/package.json" with { type: "json" };
36
37import {
38 createStagePatchedDependencies,
39 createStageWorkspaceConfig,
40 resolveFffNativeDependencies,
41 STAGE_INSTALL_ARGS,
42} from "./build-desktop-artifact.ts";
43import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts";
44import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
45
46const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
47const BuildArch = Schema.Literals(["arm64", "x64"]);
48type BuildPlatform = typeof BuildPlatform.Type;
49type BuildArch = typeof BuildArch.Type;
50
51const 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});
57const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
58const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
59const 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});
70const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
71
72const RepoRoot = Effect.service(Path.Path).pipe(
73 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
74);
75
76export 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
85export 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`. */
95export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string {
96 const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
97 return `${nodePlatform}-${arch}`;
98}
99
100export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string {
101 return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`;
102}
103
104export 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. */
111export function windowsSystemTar(): string {
112 const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
113 return `${systemRoot}\\System32\\tar.exe`;
114}
115
116const 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
138const 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 */
150const 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
252const 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. */
273const 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
285const 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 */
299const 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
372const 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 */
390const 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. */
425const 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
459const 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
567const 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
586if (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}