Open History to see the last commit
1#!/usr/bin/env node
2import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Logger from "effect/Logger";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import { Command, Flag } from "effect/cli";
10import { ChildProcess, ChildProcessSpawner } from "effect/process";
11
12import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts";
13import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts";
14import { injectLinuxArm64Sea } from "../../../scripts/lib/linux-arm64-sea.ts";
15import rootPackageJson from "../../../package.json" with { type: "json" };
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import { resolveSpawnCommand } from "@t3tools/shared/shell";
18import {
19 ServerCliBootstrapTooLargeError,
20 ServerCliBuildAssetMissingError,
21 ServerCliCommandExitError,
22 ServerCliDevelopmentIconSourceMissingError,
23 ServerCliDevelopmentIconTargetMissingError,
24 ServerCliExecutableImportError,
25} from "./cliErrors.ts";
26
27const RepoRoot = Effect.service(Path.Path).pipe(
28 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
29);
30
31const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {
32 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
33 const child = yield* spawner.spawn(command);
34 const exitCode = yield* child.exitCode;
35
36 if (exitCode !== 0) {
37 return yield* new ServerCliCommandExitError({
38 command: command.command,
39 args: command.args.map(String),
40 cwd: command.options.cwd,
41 exitCode,
42 });
43 }
44});
45
46const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* (
47 repoRoot: string,
48 serverDir: string,
49) {
50 const path = yield* Path.Path;
51 const fs = yield* FileSystem.FileSystem;
52
53 for (const override of DEVELOPMENT_ICON_OVERRIDES) {
54 const sourcePath = path.join(repoRoot, override.sourceRelativePath);
55 const targetPath = path.join(serverDir, override.targetRelativePath);
56
57 if (!(yield* fs.exists(sourcePath))) {
58 return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath });
59 }
60 if (!(yield* fs.exists(targetPath))) {
61 return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath });
62 }
63
64 yield* fs.copyFile(sourcePath, targetPath);
65 }
66
67 yield* Effect.log("[cli] Applied development icon overrides to dist/client");
68});
69
70const buildCmd = Command.make(
71 "build",
72 {
73 verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
74 },
75 (config) =>
76 Effect.gen(function* () {
77 const path = yield* Path.Path;
78 const fs = yield* FileSystem.FileSystem;
79 const repoRoot = yield* RepoRoot;
80 const serverDir = path.join(repoRoot, "apps/server");
81
82 yield* Effect.log("[cli] Running tsdown...");
83 yield* runCommand(
84 ChildProcess.make(process.execPath, ["--run", "build:bundle"], {
85 cwd: serverDir,
86 stdout: config.verbose ? "inherit" : "ignore",
87 stderr: "inherit",
88 shell: false,
89 }),
90 );
91
92 const webDist = path.join(repoRoot, "apps/web/dist");
93 const clientTarget = path.join(serverDir, "dist/client");
94
95 if (yield* fs.exists(webDist)) {
96 yield* fs.copy(webDist, clientTarget);
97 yield* applyDevelopmentIconOverrides(repoRoot, serverDir);
98 yield* Effect.log("[cli] Bundled web app into dist/client");
99 } else {
100 yield* Effect.logWarning("[cli] Web dist not found. Skipping client bundle.");
101 }
102 }),
103).pipe(Command.withDescription("Build the server package (tsdown + bundle web client)."));
104
105const buildExeCmd = Command.make(
106 "build-exe",
107 {
108 verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
109 target: Flag.String("target").pipe(
110 Flag.withDescription(
111 "Cross-build for <platform>-<arch> in nodejs.org naming (for example darwin-x64); defaults to the host.",
112 ),
113 Flag.optional,
114 ),
115 },
116 (config) =>
117 Effect.gen(function* () {
118 const path = yield* Path.Path;
119 const fs = yield* FileSystem.FileSystem;
120 const repoRoot = yield* RepoRoot;
121 const serverDir = path.join(repoRoot, "apps/server");
122
123 yield* Effect.log("[cli] Building single-executable...");
124 const target = Option.getOrUndefined(config.target);
125 // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
126 const linuxArm64 =
127 !target &&
128 (yield* HostProcess.Platform) === "linux" &&
129 (yield* HostProcess.Architecture) === "arm64";
130 const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]);
131 const cliAssetDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-asset-" });
132 const pack = (packMode: "bundle" | "bootstrap" | "1") =>
133 runCommand(
134 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
135 cwd: serverDir,
136 env: {
137 ...process.env,
138 T3CODE_PACK_EXE: packMode,
139 T3CODE_CLI_ASSET_DIR: cliAssetDir,
140 T3CODE_PACK_EXE_TARGET: target ?? "",
141 },
142 stdout: config.verbose ? "inherit" : "ignore",
143 stderr: "inherit",
144 shell: spawnCommand.shell,
145 }),
146 );
147
148 yield* pack("bundle");
149 const cliMainPath = path.join(cliAssetDir, "binMain.mjs");
150 yield* pack(linuxArm64 ? "bootstrap" : "1");
151
152 // Executables reject non-builtin imports even when bundling succeeds; inspect the emitted graph.
153 const bootstrapPath = path.join(serverDir, "dist-exe/bin.mjs");
154 for (const bundlePath of [bootstrapPath, cliMainPath]) {
155 const specifiers = findEsmImportsOfExternalPackages(yield* fs.readFileString(bundlePath));
156 if (specifiers.length > 0) {
157 return yield* new ServerCliExecutableImportError({ bundlePath, specifiers });
158 }
159 }
160 // The service launcher's memory win depends on the CLI staying out of the bootstrap.
161 const bootstrapBytes = (yield* fs.stat(bootstrapPath)).size;
162 if (bootstrapBytes > 1024 * 1024) {
163 return yield* new ServerCliBootstrapTooLargeError({
164 bootstrapPath,
165 bytes: Number(bootstrapBytes),
166 });
167 }
168 // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
169 if (linuxArm64) {
170 yield* injectLinuxArm64Sea({
171 bundlePath: bootstrapPath,
172 cliAssetPath: cliMainPath,
173 outputPath: path.join(serverDir, "dist-exe/t3"),
174 nodeVersion: rootPackageJson.engines.node,
175 verbose: config.verbose,
176 });
177 }
178 yield* Effect.log(
179 "[cli] Built dist-exe/t3 (expects client/, resource-monitor/, and the runtime-external node_modules beside it; scripts/build-cli-archive.ts assembles that tree)",
180 );
181 }),
182).pipe(
183 Command.withDescription(
184 `Build the server as a Node single-executable. Native builds require Node ${rootPackageJson.engines.node}; explicit cross-target builds need a Node 25.7+ host for --build-sea. Linux ARM64 builds only on a Linux ARM64 host. The binary still resolves native packages from a node_modules tree beside it.`,
185 ),
186);
187
188/** Publish after the GitHub release exists; the launcher downloads from it. */
189const publishCmd = Command.make(
190 "publish",
191 {
192 packagesDir: Flag.String("packages-dir").pipe(
193 Flag.withDescription("Output dir of scripts/build-npm-launcher.ts."),
194 ),
195 tag: Flag.String("tag").pipe(Flag.withDefault("latest")),
196 access: Flag.String("access").pipe(Flag.withDefault("public")),
197 provenance: Flag.Boolean("provenance").pipe(Flag.withDefault(false)),
198 dryRun: Flag.Boolean("dry-run").pipe(Flag.withDefault(false)),
199 verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
200 },
201 (config) =>
202 Effect.gen(function* () {
203 const path = yield* Path.Path;
204 const fs = yield* FileSystem.FileSystem;
205 const packagesDir = path.resolve(config.packagesDir);
206 const launcherTarball = path.join(packagesDir, "t3.tgz");
207 if (!(yield* fs.exists(launcherTarball))) {
208 return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball });
209 }
210
211 const args = ["publish", "--access", config.access, "--tag", config.tag];
212 if (config.provenance) args.push("--provenance");
213 if (config.dryRun) args.push("--dry-run");
214 const spawnCommand = yield* resolveSpawnCommand("npm", [...args, launcherTarball]);
215 yield* Effect.log(`[cli] npm ${args.join(" ")} t3.tgz`);
216 yield* runCommand(
217 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
218 cwd: packagesDir,
219 stdout: config.verbose ? "inherit" : "ignore",
220 stderr: "inherit",
221 shell: spawnCommand.shell,
222 }),
223 );
224 }),
225).pipe(Command.withDescription("Publish the t3 launcher to npm."));
226
227const cli = Command.make("cli").pipe(
228 Command.withDescription("T3 server build & publish CLI."),
229 Command.withSubcommands([buildCmd, buildExeCmd, publishCmd]),
230);
231
232Command.run(cli, { version: "0.0.0" }).pipe(
233 Effect.scoped,
234 Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]),
235 NodeRuntime.runMain,
236);