1#!/usr/bin/env node
2/**
3 * Turns the per-platform CLI archives of one release into the npm packages
4 * behind `npx t3` / `npm i -g t3`: one `@t3code/t3-<platformKey>` package per
5 * archive holding the archive's contents verbatim, plus the `t3` launcher
6 * that lists them as optionalDependencies and execs the one npm installed.
7 * The bytes a user gets from npm are therefore the release archive's, and
8 * running them needs neither a Node runtime, npm, nor a native build.
9 *
10 * Output layout under `--output-dir`:
11 *
12 * @t3code/t3-<platformKey>/ archive contents flattened + package.json
13 * @t3code/t3-<platformKey>.tgz the same tree as an npm tarball
14 * t3/ launcher: package.json, bin/t3.js, README.md
15 * t3.tgz the launcher as an npm tarball
16 *
17 * The tarballs are what gets published. `npm publish <dir>` always drops
18 * `node_modules/` (npm-packlist ignores it whatever `files` says, and
19 * bundleDependencies needs an arborist tree these flattened installs are
20 * not), whereas `npm publish <tarball>` uploads the bytes as given.
21 */
22import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher";
23import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
24import * as NodeServices from "@effect/platform-node/NodeServices";
25import * as Effect from "effect/Effect";
26import * as FileSystem from "effect/FileSystem";
27import * as Layer from "effect/Layer";
28import * as Logger from "effect/Logger";
29import * as Path from "effect/Path";
30import * as Schema from "effect/Schema";
31import { Command, Flag } from "effect/cli";
32import { ChildProcess, ChildProcessSpawner } from "effect/process";
33
34import {
35 CLI_ARCHIVE_PLATFORM_KEYS,
36 cliArchiveFileName,
37 type CliArchivePlatformKey,
38} from "@t3tools/shared/cliRelease";
39import * as HostProcess from "@t3tools/shared/HostProcess";
40import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
41import { isCommandAvailable } from "@t3tools/shared/shell";
42import serverPackageJson from "../apps/server/package.json" with { type: "json" };
43
44import { windowsSystemTar } from "./build-cli-archive.ts";
45
46export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code";
47export const NPM_LAUNCHER_PACKAGE_NAME = "t3";
48
49const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown));
50
51export class NpmPackagesCommandFailedError extends Schema.TaggedError<NpmPackagesCommandFailedError>()(
52 "NpmPackagesCommandFailedError",
53 { command: Schema.String, exitCode: Schema.Int },
54) {
55 override get message(): string {
56 return `${this.command} exited with code ${this.exitCode}.`;
57 }
58}
59
60export class NpmPackagesToolMissingError extends Schema.TaggedError<NpmPackagesToolMissingError>()(
61 "NpmPackagesToolMissingError",
62 { tool: Schema.String, purpose: Schema.String },
63) {
64 override get message(): string {
65 return `\`${this.tool}\` is not on PATH; it is needed to ${this.purpose}.`;
66 }
67}
68
69export class NpmPackagesArchivesMissingError extends Schema.TaggedError<NpmPackagesArchivesMissingError>()(
70 "NpmPackagesArchivesMissingError",
71 { archivesDir: Schema.String, missing: Schema.Array(Schema.String) },
72) {
73 override get message(): string {
74 return `${this.archivesDir} lacks archives for ${this.missing.join(", ")}. A launcher published without them would silently skip those platforms; pass --allow-missing for a deliberately partial build.`;
75 }
76}
77
78export class NpmPackagesArchiveLayoutError extends Schema.TaggedError<NpmPackagesArchiveLayoutError>()(
79 "NpmPackagesArchiveLayoutError",
80 { archive: Schema.String, detail: Schema.String },
81) {
82 override get message(): string {
83 return `${this.archive}: ${this.detail}`;
84 }
85}
86
87export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): string {
88 return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`;
89}
90
91/**
92 * package.json for one platform package; `os`/`cpu` let npm skip the other
93 * five. The archive's runtime `node_modules` (native addons and their
94 * loaders) ships inside the tarball, and npm only keeps a nested tree it can
95 * account for: anything not declared is extraneous and pruned on the next
96 * `npm install` in that project, which then breaks the executable. Declaring
97 * every bundled package as a bundled dependency at the exact version on disk
98 * makes npm treat the tree as part of this package and leave it alone.
99 */
100export function npmPlatformPackageManifest(
101 platformKey: CliArchivePlatformKey,
102 version: string,
103 bundled: Readonly<Record<string, string>>,
104) {
105 const [os, cpu] = platformKey.split("-") as [string, string];
106 const bundleDependencies = Object.keys(bundled).sort();
107 return {
108 name: npmPlatformPackageName(platformKey),
109 version,
110 description: `T3 Code CLI executable for ${platformKey}`,
111 license: serverPackageJson.license,
112 repository: serverPackageJson.repository,
113 os: [os],
114 cpu: [cpu],
115 files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"],
116 preferUnplugged: true,
117 dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])),
118 bundleDependencies,
119 };
120}
121
122const PackageVersion = Schema.Struct({ version: Schema.String });
123const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion));
124
125/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */
126const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) {
127 const fs = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const bundled: Record<string, string> = {};
130 const packageDirs: Array<{ readonly name: string; readonly dir: string }> = [];
131 for (const entry of yield* fs.readDirectory(nodeModulesDir)) {
132 if (entry.startsWith(".")) continue;
133 const dir = path.join(nodeModulesDir, entry);
134 if (entry.startsWith("@")) {
135 for (const scoped of yield* fs.readDirectory(dir)) {
136 packageDirs.push({ name: `${entry}/${scoped}`, dir: path.join(dir, scoped) });
137 }
138 } else {
139 packageDirs.push({ name: entry, dir });
140 }
141 }
142 for (const { name, dir } of packageDirs) {
143 const manifest = yield* fs.readFileString(path.join(dir, "package.json"));
144 bundled[name] = (yield* decodePackageVersion(manifest)).version;
145 }
146 return bundled;
147});
148
149/**
150 * README for one platform package. Without one at the package root, npm
151 * shows the first README it finds in the tarball, which is a bundled
152 * dependency's (ffi-rs).
153 */
154export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): string {
155 return [
156 `# ${npmPlatformPackageName(platformKey)}`,
157 "",
158 `The T3 Code CLI executable for ${platformKey}. Do not install this package directly:`,
159 `it is an optional dependency of \`${NPM_LAUNCHER_PACKAGE_NAME}\`, which picks the package for the`,
160 "current platform and runs the executable inside it.",
161 "",
162 "```sh",
163 `npx ${NPM_LAUNCHER_PACKAGE_NAME}@latest`,
164 "```",
165 "",
166 "Source and documentation: https://github.com/pingdotgg/t3code",
167 "",
168 ].join("\n");
169}
170
171/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */
172export function npmLauncherPackageManifest(
173 version: string,
174 platformKeys: ReadonlyArray<CliArchivePlatformKey>,
175) {
176 return {
177 name: NPM_LAUNCHER_PACKAGE_NAME,
178 version,
179 description: "T3 Code CLI. Installs the self-contained executable for this platform.",
180 license: serverPackageJson.license,
181 repository: serverPackageJson.repository,
182 bin: { t3: "./bin/t3.js" },
183 files: ["bin", "dist"],
184 optionalDependencies: Object.fromEntries(
185 platformKeys.map((key) => [npmPlatformPackageName(key), version]),
186 ),
187 };
188}
189
190/**
191 * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it
192 * loads on any Node that npm itself runs on; the real work happens in the
193 * single-executable it execs.
194 */
195export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node
196"use strict";
197const { spawnSync } = require("node:child_process");
198const { constants } = require("node:os");
199const { dirname, join } = require("node:path");
200
201const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}];
202const key = process.platform + "-" + process.arch;
203
204let packageDir;
205try {
206 packageDir = dirname(require.resolve("${NPM_PLATFORM_PACKAGE_SCOPE}/t3-" + key + "/package.json"));
207} catch {
208 process.stderr.write(
209 [
210 "t3: no T3 Code CLI build is available for this platform (" + key + ").",
211 "Supported platforms: " + SUPPORTED.join(", ") + ".",
212 "If yours is listed, reinstall t3 so npm fetches its optional dependency.",
213 "The desktop app and release archives are at https://github.com/pingdotgg/t3code/releases",
214 "",
215 ].join("\\n"),
216 );
217 process.exit(1);
218}
219
220const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3");
221const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" });
222if (result.error) {
223 process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n");
224 process.exit(1);
225}
226// A child killed by a signal has no status; report it the way a shell would.
227process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1));
228`;
229
230const runCommand = Effect.fn("runCommand")(function* (
231 command: ChildProcess.StandardCommand,
232 label: string,
233) {
234 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
235 const child = yield* spawner.spawn(
236 ChildProcess.make(command.command, command.args, {
237 ...command.options,
238 stdout: "inherit",
239 stderr: "inherit",
240 }),
241 );
242 const exitCode = Number(yield* child.exitCode);
243 if (exitCode !== 0) {
244 return yield* new NpmPackagesCommandFailedError({ command: label, exitCode });
245 }
246});
247
248/**
249 * Extracts an archive and returns its single top-level directory. `.tar.gz`
250 * goes through tar everywhere; `.zip` through the bsdtar Windows ships or,
251 * elsewhere, `unzip`, since GNU tar cannot read zip.
252 */
253const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) {
254 const fs = yield* FileSystem.FileSystem;
255 const path = yield* Path.Path;
256 const platform = yield* HostProcess.Platform;
257 if (!archive.endsWith(".zip")) {
258 yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf");
259 } else if (platform === "win32") {
260 yield* runCommand(
261 ChildProcess.make(windowsSystemTar(), ["-xf", archive, "-C", into]),
262 "tar.exe -xf (zip)",
263 );
264 } else {
265 if (!(yield* isCommandAvailable("unzip"))) {
266 return yield* new NpmPackagesToolMissingError({
267 tool: "unzip",
268 purpose: `extract ${path.basename(archive)} (GNU tar cannot read zip)`,
269 });
270 }
271 yield* runCommand(ChildProcess.make("unzip", ["-q", archive, "-d", into]), "unzip");
272 }
273 const entries = yield* fs.readDirectory(into);
274 const [root] = entries;
275 if (root === undefined || entries.length !== 1) {
276 return yield* new NpmPackagesArchiveLayoutError({
277 archive: path.basename(archive),
278 detail: `expected exactly one top-level directory, found ${String(entries.length)} entries`,
279 });
280 }
281 return path.join(into, root);
282});
283
284/** Tar to build npm tarballs with; see build-cli-archive.ts for why Windows names bsdtar by path. */
285const hostTar = Effect.map(HostProcess.Platform, (platform) =>
286 platform === "win32" ? windowsSystemTar() : "tar",
287);
288
289/**
290 * Writes `stageDir/package` as a gzipped npm tarball and then moves the tree
291 * to `packageDir` so the contents stay inspectable beside the tarball.
292 */
293const packAndPlace = Effect.fn("packAndPlace")(function* (input: {
294 readonly stageDir: string;
295 readonly packageDir: string;
296 readonly tarball: string;
297}) {
298 const fs = yield* FileSystem.FileSystem;
299 yield* fs.remove(input.tarball, { force: true });
300 yield* runCommand(
301 ChildProcess.make(yield* hostTar, ["-czf", input.tarball, "-C", input.stageDir, "package"]),
302 `tar (${input.tarball})`,
303 );
304 yield* fs.remove(input.packageDir, { recursive: true, force: true });
305 yield* fs.rename(`${input.stageDir}/package`, input.packageDir);
306});
307
308export interface NpmPackageOutput {
309 readonly name: string;
310 readonly packageDir: string;
311 readonly tarball: string;
312}
313
314/**
315 * Extracts one archive, adds its package.json, and emits the package dir and
316 * tarball. The scratch dir lives inside the output dir so the extracted tree
317 * is renamed into place rather than copied across filesystems.
318 */
319const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: {
320 readonly key: CliArchivePlatformKey;
321 readonly archive: string;
322 readonly outputDir: string;
323 readonly version: string;
324}) {
325 const fs = yield* FileSystem.FileSystem;
326 const path = yield* Path.Path;
327 yield* Effect.log(`[npm-packages] Extracting ${path.basename(input.archive)}...`);
328 const scratch = yield* fs.makeTempDirectoryScoped({
329 directory: input.outputDir,
330 prefix: ".extract-",
331 });
332 const extractDir = path.join(scratch, "extract");
333 yield* fs.makeDirectory(extractDir);
334 const contentDir = yield* extractArchive(input.archive, extractDir);
335 const executableName = input.key.startsWith("win32") ? "t3.exe" : "t3";
336 const executable = path.join(contentDir, executableName);
337 if (!(yield* fs.exists(executable))) {
338 return yield* new NpmPackagesArchiveLayoutError({
339 archive: path.basename(input.archive),
340 detail: `missing ${executableName} at the archive root`,
341 });
342 }
343 // The tarball carries the on-disk mode, so the bit must be set before packing.
344 if (executableName === "t3") {
345 yield* fs.chmod(executable, 0o755);
346 }
347 const bundled = yield* readBundledPackages(path.join(contentDir, "node_modules"));
348 yield* fs.writeFileString(
349 path.join(contentDir, "package.json"),
350 `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version, bundled))}\n`,
351 );
352 yield* fs.writeFileString(
353 path.join(contentDir, "README.md"),
354 npmPlatformPackageReadme(input.key),
355 );
356 // npm tarballs root everything under `package/`.
357 yield* fs.rename(contentDir, path.join(scratch, "package"));
358 const name = npmPlatformPackageName(input.key);
359 const output: NpmPackageOutput = {
360 name,
361 packageDir: path.join(input.outputDir, name),
362 tarball: path.join(input.outputDir, `${name}.tgz`),
363 };
364 yield* packAndPlace({ stageDir: scratch, ...output });
365 return output;
366}, Effect.scoped);
367
368/** Writes the launcher package (package.json, bin/t3.js, README) and its tarball. */
369const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: {
370 readonly outputDir: string;
371 readonly version: string;
372 readonly platformKeys: ReadonlyArray<CliArchivePlatformKey>;
373}) {
374 const fs = yield* FileSystem.FileSystem;
375 const path = yield* Path.Path;
376 const scratch = yield* fs.makeTempDirectoryScoped({
377 directory: input.outputDir,
378 prefix: ".launcher-",
379 });
380 const stageDir = path.join(scratch, "package");
381 yield* fs.makeDirectory(path.join(stageDir, "bin"), { recursive: true });
382 yield* fs.writeFileString(
383 path.join(stageDir, "package.json"),
384 `${yield* encodePackageJson(npmLauncherPackageManifest(input.version, input.platformKeys))}\n`,
385 );
386 const launcherScript = path.join(stageDir, "bin/t3.js");
387 yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT);
388 yield* fs.chmod(launcherScript, 0o755);
389 // Older service updaters and launchers run this exact path with Node.
390 // Keep it in the package so they can preflight and start the new executable.
391 yield* fs.makeDirectory(path.join(stageDir, "dist"));
392 yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript());
393 const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url));
394 if (yield* fs.exists(readme)) {
395 yield* fs.copyFile(readme, path.join(stageDir, "README.md"));
396 }
397 const output: NpmPackageOutput = {
398 name: NPM_LAUNCHER_PACKAGE_NAME,
399 packageDir: path.join(input.outputDir, NPM_LAUNCHER_PACKAGE_NAME),
400 tarball: path.join(input.outputDir, `${NPM_LAUNCHER_PACKAGE_NAME}.tgz`),
401 };
402 yield* packAndPlace({ stageDir: scratch, ...output });
403 return output;
404}, Effect.scoped);
405
406export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(function* (input: {
407 readonly archivesDir: string;
408 readonly version: string;
409 readonly outputDir: string;
410 readonly allowMissing: boolean;
411}) {
412 const fs = yield* FileSystem.FileSystem;
413 const path = yield* Path.Path;
414
415 const present = yield* fs.readDirectory(input.archivesDir);
416 const archives = CLI_ARCHIVE_PLATFORM_KEYS.flatMap((key) => {
417 const fileName = cliArchiveFileName(input.version, key);
418 return present.includes(fileName)
419 ? [{ key, archive: path.join(input.archivesDir, fileName) }]
420 : [];
421 });
422 const missing = CLI_ARCHIVE_PLATFORM_KEYS.filter(
423 (key) => !archives.some((entry) => entry.key === key),
424 );
425 if (missing.length > 0 && (!input.allowMissing || archives.length === 0)) {
426 return yield* new NpmPackagesArchivesMissingError({ archivesDir: input.archivesDir, missing });
427 }
428
429 yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), {
430 recursive: true,
431 });
432 // Each archive stages in its own scratch dir, so all of them unpack and
433 // compress at once. Sequentially this took about 45s for five archives.
434 const platformOutputs = yield* Effect.forEach(
435 archives,
436 ({ key, archive }) =>
437 stagePlatformPackage({ key, archive, outputDir: input.outputDir, version: input.version }),
438 { concurrency: "unbounded" },
439 );
440 const outputs = [
441 ...platformOutputs,
442 yield* stageLauncherPackage({
443 outputDir: input.outputDir,
444 version: input.version,
445 platformKeys: archives.map((entry) => entry.key),
446 }),
447 ];
448
449 for (const output of outputs) {
450 yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`);
451 }
452 if (missing.length > 0) {
453 yield* Effect.logWarning(
454 `[npm-packages] Launcher omits ${missing.join(", ")} (--allow-missing).`,
455 );
456 }
457 return outputs;
458});
459
460const command = Command.make(
461 "build-npm-platform-packages",
462 {
463 archivesDir: Flag.String("archives-dir").pipe(
464 Flag.withDescription("Directory holding the release's t3-<version>-<platform> archives."),
465 ),
466 version: Flag.String("version").pipe(
467 Flag.withDescription(
468 "Exact release version; selects the archives and versions the packages.",
469 ),
470 ),
471 outputDir: Flag.String("output-dir").pipe(Flag.withDefault("npm-packages")),
472 allowMissing: Flag.Boolean("allow-missing").pipe(
473 Flag.withDefault(false),
474 Flag.withDescription("Build a launcher that lists only the platforms present."),
475 ),
476 },
477 buildNpmPlatformPackages,
478).pipe(
479 Command.withDescription(
480 "Build the t3 launcher and @t3code/t3-<platform> npm packages from CLI release archives.",
481 ),
482);
483
484if (import.meta.main) {
485 Command.run(command, { version: "0.0.0" }).pipe(
486 Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),
487 NodeRuntime.runMain,
488 );
489}