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 { resolveSpawnCommand } from "@t3tools/shared/shell";
15import {
16 ServerCliBuildAssetMissingError,
17 ServerCliCommandExitError,
18 ServerCliDevelopmentIconSourceMissingError,
19 ServerCliDevelopmentIconTargetMissingError,
20 ServerCliExecutableImportError,
21} from "./cliErrors.ts";
22import { publishPlatformsThenLauncher } from "./publishOrder.ts";
23
24const RepoRoot = Effect.service(Path.Path).pipe(
25 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
26);
27
28const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {
29 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
30 const child = yield* spawner.spawn(command);
31 const exitCode = yield* child.exitCode;
32
33 if (exitCode !== 0) {
34 return yield* new ServerCliCommandExitError({
35 command: command.command,
36 args: command.args,
37 cwd: command.options.cwd,
38 exitCode,
39 });
40 }
41});
42
43const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* (
44 repoRoot: string,
45 serverDir: string,
46) {
47 const path = yield* Path.Path;
48 const fs = yield* FileSystem.FileSystem;
49
50 for (const override of DEVELOPMENT_ICON_OVERRIDES) {
51 const sourcePath = path.join(repoRoot, override.sourceRelativePath);
52 const targetPath = path.join(serverDir, override.targetRelativePath);
53
54 if (!(yield* fs.exists(sourcePath))) {
55 return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath });
56 }
57 if (!(yield* fs.exists(targetPath))) {
58 return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath });
59 }
60
61 yield* fs.copyFile(sourcePath, targetPath);
62 }
63
64 yield* Effect.log("[cli] Applied development icon overrides to dist/client");
65});
66
67// ---------------------------------------------------------------------------
68// build subcommand
69// ---------------------------------------------------------------------------
70
71const buildCmd = Command.make(
72 "build",
73 {
74 verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
75 },
76 (config) =>
77 Effect.gen(function* () {
78 const path = yield* Path.Path;
79 const fs = yield* FileSystem.FileSystem;
80 const repoRoot = yield* RepoRoot;
81 const serverDir = path.join(repoRoot, "apps/server");
82
83 yield* Effect.log("[cli] Running tsdown...");
84 yield* runCommand(
85 ChildProcess.make(process.execPath, ["--run", "build:bundle"], {
86 cwd: serverDir,
87 stdout: config.verbose ? "inherit" : "ignore",
88 stderr: "inherit",
89 shell: false,
90 }),
91 );
92
93 const webDist = path.join(repoRoot, "apps/web/dist");
94 const clientTarget = path.join(serverDir, "dist/client");
95
96 if (yield* fs.exists(webDist)) {
97 yield* fs.copy(webDist, clientTarget);
98 yield* applyDevelopmentIconOverrides(repoRoot, serverDir);
99 yield* Effect.log("[cli] Bundled web app into dist/client");
100 } else {
101 yield* Effect.logWarning("[cli] Web dist not found — skipping client bundle.");
102 }
103 }),
104).pipe(Command.withDescription("Build the server package (tsdown + bundle web client)."));
105
106// ---------------------------------------------------------------------------
107// build-exe subcommand
108// ---------------------------------------------------------------------------
109
110const buildExeCmd = Command.make(
111 "build-exe",
112 {
113 verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
114 target: Flag.String("target").pipe(
115 Flag.withDescription(
116 "Cross-build for <platform>-<arch> in nodejs.org naming (for example darwin-x64); defaults to the host.",
117 ),
118 Flag.optional,
119 ),
120 },
121 (config) =>
122 Effect.gen(function* () {
123 const path = yield* Path.Path;
124 const fs = yield* FileSystem.FileSystem;
125 const repoRoot = yield* RepoRoot;
126 const serverDir = path.join(repoRoot, "apps/server");
127
128 yield* Effect.log("[cli] Building single-executable...");
129 const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]);
130 yield* runCommand(
131 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
132 cwd: serverDir,
133 env: {
134 ...process.env,
135 T3CODE_PACK_EXE: "1",
136 ...Option.match(config.target, {
137 onNone: () => ({}),
138 onSome: (target) => ({ T3CODE_PACK_EXE_TARGET: target }),
139 }),
140 },
141 stdout: config.verbose ? "inherit" : "ignore",
142 stderr: "inherit",
143 shell: spawnCommand.shell,
144 }),
145 );
146
147 // The executable can only `import` built-ins. A file-backed import
148 // passes the bundler and `node dist/bin.mjs`, then throws inside the
149 // binary, so read the emitted module graph rather than trusting config.
150 const bundlePath = path.join(serverDir, "dist-exe/bin.mjs");
151 const specifiers = findEsmImportsOfExternalPackages(yield* fs.readFileString(bundlePath));
152 if (specifiers.length > 0) {
153 return yield* new ServerCliExecutableImportError({ bundlePath, specifiers });
154 }
155 yield* Effect.log(
156 "[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)",
157 );
158 }),
159).pipe(
160 Command.withDescription(
161 "Build the server as a Node single-executable (needs a Node 25.7+ host for --build-sea). The binary still resolves native packages from a node_modules tree beside it.",
162 ),
163);
164
165// ---------------------------------------------------------------------------
166// publish subcommand
167// ---------------------------------------------------------------------------
168
169/**
170 * Publishes the tarballs scripts/build-npm-platform-packages.ts produced:
171 * every `@t3code/t3-<platform>.tgz` first, `t3.tgz` (the launcher) last, so
172 * the launcher is never installable before the executables it depends on.
173 * Tarballs rather than directories because `npm publish <dir>` strips the
174 * `node_modules/` the executable loads its native addons from.
175 */
176const publishCmd = Command.make(
177 "publish",
178 {
179 packagesDir: Flag.String("packages-dir").pipe(
180 Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."),
181 ),
182 tag: Flag.String("tag").pipe(Flag.withDefault("latest")),
183 access: Flag.String("access").pipe(Flag.withDefault("public")),
184 provenance: Flag.Boolean("provenance").pipe(Flag.withDefault(false)),
185 dryRun: Flag.Boolean("dry-run").pipe(Flag.withDefault(false)),
186 verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)),
187 },
188 (config) =>
189 Effect.gen(function* () {
190 const path = yield* Path.Path;
191 const fs = yield* FileSystem.FileSystem;
192 // npm runs with cwd set to the packages dir below, so tarball paths are
193 // resolved once here rather than joined twice.
194 const packagesDir = path.resolve(config.packagesDir);
195 const scopeDir = path.join(packagesDir, "@t3code");
196 const launcherTarball = path.join(packagesDir, "t3.tgz");
197 const platformTarballs = (yield* fs
198 .readDirectory(scopeDir)
199 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])))
200 .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz"))
201 .sort()
202 .map((entry) => path.join(scopeDir, entry));
203 if (platformTarballs.length === 0) {
204 return yield* new ServerCliBuildAssetMissingError({
205 assetPath: path.join(scopeDir, "t3-<platform>.tgz"),
206 });
207 }
208 if (!(yield* fs.exists(launcherTarball))) {
209 return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball });
210 }
211
212 const args = ["publish", "--access", config.access, "--tag", config.tag];
213 if (config.provenance) args.push("--provenance");
214 if (config.dryRun) args.push("--dry-run");
215
216 const publish = Effect.fn("publish")(function* (tarball: string) {
217 const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]);
218 yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`);
219 yield* runCommand(
220 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
221 cwd: packagesDir,
222 stdout: config.verbose ? "inherit" : "ignore",
223 stderr: "inherit",
224 shell: spawnCommand.shell,
225 }),
226 );
227 });
228
229 // Each publish takes about 17s, so the platform packages go at once.
230 yield* publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish });
231 }),
232).pipe(
233 Command.withDescription(
234 "Publish the @t3code/t3-<platform> tarballs and then the t3 launcher to npm.",
235 ),
236);
237
238// ---------------------------------------------------------------------------
239// root command
240// ---------------------------------------------------------------------------
241
242const cli = Command.make("cli").pipe(
243 Command.withDescription("T3 server build & publish CLI."),
244 Command.withSubcommands([buildCmd, buildExeCmd, publishCmd]),
245);
246
247Command.run(cli, { version: "0.0.0" }).pipe(
248 Effect.scoped,
249 Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]),
250 NodeRuntime.runMain,
251);