t3-code-android-nightly/scripts/build-npm-platform-packages.ts
Julius Marminge 194c73f3f9
chore(deps): upgrade Effect to stable 4.0.1 (#16138)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:15:14 -07:00

489 lines
19 KiB
JavaScript

#!/usr/bin/env node
/**
* Turns the per-platform CLI archives of one release into the npm packages
* behind `npx t3` / `npm i -g t3`: one `@t3code/t3-<platformKey>` package per
* archive holding the archive's contents verbatim, plus the `t3` launcher
* that lists them as optionalDependencies and execs the one npm installed.
* The bytes a user gets from npm are therefore the release archive's, and
* running them needs neither a Node runtime, npm, nor a native build.
*
* Output layout under `--output-dir`:
*
* @t3code/t3-<platformKey>/ archive contents flattened + package.json
* @t3code/t3-<platformKey>.tgz the same tree as an npm tarball
* t3/ launcher: package.json, bin/t3.js, README.md
* t3.tgz the launcher as an npm tarball
*
* The tarballs are what gets published. `npm publish <dir>` always drops
* `node_modules/` (npm-packlist ignores it whatever `files` says, and
* bundleDependencies needs an arborist tree these flattened installs are
* not), whereas `npm publish <tarball>` uploads the bytes as given.
*/
import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { Command, Flag } from "effect/cli";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import {
CLI_ARCHIVE_PLATFORM_KEYS,
cliArchiveFileName,
type CliArchivePlatformKey,
} from "@t3tools/shared/cliRelease";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
import { isCommandAvailable } from "@t3tools/shared/shell";
import serverPackageJson from "../apps/server/package.json" with { type: "json" };
import { windowsSystemTar } from "./build-cli-archive.ts";
export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code";
export const NPM_LAUNCHER_PACKAGE_NAME = "t3";
const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown));
export class NpmPackagesCommandFailedError extends Schema.TaggedError<NpmPackagesCommandFailedError>()(
"NpmPackagesCommandFailedError",
{ command: Schema.String, exitCode: Schema.Int },
) {
override get message(): string {
return `${this.command} exited with code ${this.exitCode}.`;
}
}
export class NpmPackagesToolMissingError extends Schema.TaggedError<NpmPackagesToolMissingError>()(
"NpmPackagesToolMissingError",
{ tool: Schema.String, purpose: Schema.String },
) {
override get message(): string {
return `\`${this.tool}\` is not on PATH; it is needed to ${this.purpose}.`;
}
}
export class NpmPackagesArchivesMissingError extends Schema.TaggedError<NpmPackagesArchivesMissingError>()(
"NpmPackagesArchivesMissingError",
{ archivesDir: Schema.String, missing: Schema.Array(Schema.String) },
) {
override get message(): string {
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.`;
}
}
export class NpmPackagesArchiveLayoutError extends Schema.TaggedError<NpmPackagesArchiveLayoutError>()(
"NpmPackagesArchiveLayoutError",
{ archive: Schema.String, detail: Schema.String },
) {
override get message(): string {
return `${this.archive}: ${this.detail}`;
}
}
export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): string {
return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`;
}
/**
* package.json for one platform package; `os`/`cpu` let npm skip the other
* five. The archive's runtime `node_modules` (native addons and their
* loaders) ships inside the tarball, and npm only keeps a nested tree it can
* account for: anything not declared is extraneous and pruned on the next
* `npm install` in that project, which then breaks the executable. Declaring
* every bundled package as a bundled dependency at the exact version on disk
* makes npm treat the tree as part of this package and leave it alone.
*/
export function npmPlatformPackageManifest(
platformKey: CliArchivePlatformKey,
version: string,
bundled: Readonly<Record<string, string>>,
) {
const [os, cpu] = platformKey.split("-") as [string, string];
const bundleDependencies = Object.keys(bundled).sort();
return {
name: npmPlatformPackageName(platformKey),
version,
description: `T3 Code CLI executable for ${platformKey}`,
license: serverPackageJson.license,
repository: serverPackageJson.repository,
os: [os],
cpu: [cpu],
files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"],
preferUnplugged: true,
dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])),
bundleDependencies,
};
}
const PackageVersion = Schema.Struct({ version: Schema.String });
const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion));
/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */
const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const bundled: Record<string, string> = {};
const packageDirs: Array<{ readonly name: string; readonly dir: string }> = [];
for (const entry of yield* fs.readDirectory(nodeModulesDir)) {
if (entry.startsWith(".")) continue;
const dir = path.join(nodeModulesDir, entry);
if (entry.startsWith("@")) {
for (const scoped of yield* fs.readDirectory(dir)) {
packageDirs.push({ name: `${entry}/${scoped}`, dir: path.join(dir, scoped) });
}
} else {
packageDirs.push({ name: entry, dir });
}
}
for (const { name, dir } of packageDirs) {
const manifest = yield* fs.readFileString(path.join(dir, "package.json"));
bundled[name] = (yield* decodePackageVersion(manifest)).version;
}
return bundled;
});
/**
* README for one platform package. Without one at the package root, npm
* shows the first README it finds in the tarball, which is a bundled
* dependency's (ffi-rs).
*/
export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): string {
return [
`# ${npmPlatformPackageName(platformKey)}`,
"",
`The T3 Code CLI executable for ${platformKey}. Do not install this package directly:`,
`it is an optional dependency of \`${NPM_LAUNCHER_PACKAGE_NAME}\`, which picks the package for the`,
"current platform and runs the executable inside it.",
"",
"```sh",
`npx ${NPM_LAUNCHER_PACKAGE_NAME}@latest`,
"```",
"",
"Source and documentation: https://github.com/pingdotgg/t3code",
"",
].join("\n");
}
/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */
export function npmLauncherPackageManifest(
version: string,
platformKeys: ReadonlyArray<CliArchivePlatformKey>,
) {
return {
name: NPM_LAUNCHER_PACKAGE_NAME,
version,
description: "T3 Code CLI. Installs the self-contained executable for this platform.",
license: serverPackageJson.license,
repository: serverPackageJson.repository,
bin: { t3: "./bin/t3.js" },
files: ["bin", "dist"],
optionalDependencies: Object.fromEntries(
platformKeys.map((key) => [npmPlatformPackageName(key), version]),
),
};
}
/**
* The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it
* loads on any Node that npm itself runs on; the real work happens in the
* single-executable it execs.
*/
export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node
"use strict";
const { spawnSync } = require("node:child_process");
const { constants } = require("node:os");
const { dirname, join } = require("node:path");
const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}];
const key = process.platform + "-" + process.arch;
let packageDir;
try {
packageDir = dirname(require.resolve("${NPM_PLATFORM_PACKAGE_SCOPE}/t3-" + key + "/package.json"));
} catch {
process.stderr.write(
[
"t3: no T3 Code CLI build is available for this platform (" + key + ").",
"Supported platforms: " + SUPPORTED.join(", ") + ".",
"If yours is listed, reinstall t3 so npm fetches its optional dependency.",
"The desktop app and release archives are at https://github.com/pingdotgg/t3code/releases",
"",
].join("\\n"),
);
process.exit(1);
}
const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3");
const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" });
if (result.error) {
process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n");
process.exit(1);
}
// A child killed by a signal has no status; report it the way a shell would.
process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1));
`;
const runCommand = Effect.fn("runCommand")(function* (
command: ChildProcess.StandardCommand,
label: string,
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawner.spawn(
ChildProcess.make(command.command, command.args, {
...command.options,
stdout: "inherit",
stderr: "inherit",
}),
);
const exitCode = Number(yield* child.exitCode);
if (exitCode !== 0) {
return yield* new NpmPackagesCommandFailedError({ command: label, exitCode });
}
});
/**
* Extracts an archive and returns its single top-level directory. `.tar.gz`
* goes through tar everywhere; `.zip` through the bsdtar Windows ships or,
* elsewhere, `unzip`, since GNU tar cannot read zip.
*/
const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
if (!archive.endsWith(".zip")) {
yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf");
} else if (platform === "win32") {
yield* runCommand(
ChildProcess.make(windowsSystemTar(), ["-xf", archive, "-C", into]),
"tar.exe -xf (zip)",
);
} else {
if (!(yield* isCommandAvailable("unzip"))) {
return yield* new NpmPackagesToolMissingError({
tool: "unzip",
purpose: `extract ${path.basename(archive)} (GNU tar cannot read zip)`,
});
}
yield* runCommand(ChildProcess.make("unzip", ["-q", archive, "-d", into]), "unzip");
}
const entries = yield* fs.readDirectory(into);
const [root] = entries;
if (root === undefined || entries.length !== 1) {
return yield* new NpmPackagesArchiveLayoutError({
archive: path.basename(archive),
detail: `expected exactly one top-level directory, found ${String(entries.length)} entries`,
});
}
return path.join(into, root);
});
/** Tar to build npm tarballs with; see build-cli-archive.ts for why Windows names bsdtar by path. */
const hostTar = Effect.map(HostProcessPlatform, (platform) =>
platform === "win32" ? windowsSystemTar() : "tar",
);
/**
* Writes `stageDir/package` as a gzipped npm tarball and then moves the tree
* to `packageDir` so the contents stay inspectable beside the tarball.
*/
const packAndPlace = Effect.fn("packAndPlace")(function* (input: {
readonly stageDir: string;
readonly packageDir: string;
readonly tarball: string;
}) {
const fs = yield* FileSystem.FileSystem;
yield* fs.remove(input.tarball, { force: true });
yield* runCommand(
ChildProcess.make(yield* hostTar, ["-czf", input.tarball, "-C", input.stageDir, "package"]),
`tar (${input.tarball})`,
);
yield* fs.remove(input.packageDir, { recursive: true, force: true });
yield* fs.rename(`${input.stageDir}/package`, input.packageDir);
});
export interface NpmPackageOutput {
readonly name: string;
readonly packageDir: string;
readonly tarball: string;
}
/**
* Extracts one archive, adds its package.json, and emits the package dir and
* tarball. The scratch dir lives inside the output dir so the extracted tree
* is renamed into place rather than copied across filesystems.
*/
const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: {
readonly key: CliArchivePlatformKey;
readonly archive: string;
readonly outputDir: string;
readonly version: string;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* Effect.log(`[npm-packages] Extracting ${path.basename(input.archive)}...`);
const scratch = yield* fs.makeTempDirectoryScoped({
directory: input.outputDir,
prefix: ".extract-",
});
const extractDir = path.join(scratch, "extract");
yield* fs.makeDirectory(extractDir);
const contentDir = yield* extractArchive(input.archive, extractDir);
const executableName = input.key.startsWith("win32") ? "t3.exe" : "t3";
const executable = path.join(contentDir, executableName);
if (!(yield* fs.exists(executable))) {
return yield* new NpmPackagesArchiveLayoutError({
archive: path.basename(input.archive),
detail: `missing ${executableName} at the archive root`,
});
}
// The tarball carries the on-disk mode, so the bit must be set before packing.
if (executableName === "t3") {
yield* fs.chmod(executable, 0o755);
}
const bundled = yield* readBundledPackages(path.join(contentDir, "node_modules"));
yield* fs.writeFileString(
path.join(contentDir, "package.json"),
`${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version, bundled))}\n`,
);
yield* fs.writeFileString(
path.join(contentDir, "README.md"),
npmPlatformPackageReadme(input.key),
);
// npm tarballs root everything under `package/`.
yield* fs.rename(contentDir, path.join(scratch, "package"));
const name = npmPlatformPackageName(input.key);
const output: NpmPackageOutput = {
name,
packageDir: path.join(input.outputDir, name),
tarball: path.join(input.outputDir, `${name}.tgz`),
};
yield* packAndPlace({ stageDir: scratch, ...output });
return output;
}, Effect.scoped);
/** Writes the launcher package (package.json, bin/t3.js, README) and its tarball. */
const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: {
readonly outputDir: string;
readonly version: string;
readonly platformKeys: ReadonlyArray<CliArchivePlatformKey>;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const scratch = yield* fs.makeTempDirectoryScoped({
directory: input.outputDir,
prefix: ".launcher-",
});
const stageDir = path.join(scratch, "package");
yield* fs.makeDirectory(path.join(stageDir, "bin"), { recursive: true });
yield* fs.writeFileString(
path.join(stageDir, "package.json"),
`${yield* encodePackageJson(npmLauncherPackageManifest(input.version, input.platformKeys))}\n`,
);
const launcherScript = path.join(stageDir, "bin/t3.js");
yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT);
yield* fs.chmod(launcherScript, 0o755);
// Older service updaters and launchers run this exact path with Node.
// Keep it in the package so they can preflight and start the new executable.
yield* fs.makeDirectory(path.join(stageDir, "dist"));
yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript());
const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url));
if (yield* fs.exists(readme)) {
yield* fs.copyFile(readme, path.join(stageDir, "README.md"));
}
const output: NpmPackageOutput = {
name: NPM_LAUNCHER_PACKAGE_NAME,
packageDir: path.join(input.outputDir, NPM_LAUNCHER_PACKAGE_NAME),
tarball: path.join(input.outputDir, `${NPM_LAUNCHER_PACKAGE_NAME}.tgz`),
};
yield* packAndPlace({ stageDir: scratch, ...output });
return output;
}, Effect.scoped);
export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(function* (input: {
readonly archivesDir: string;
readonly version: string;
readonly outputDir: string;
readonly allowMissing: boolean;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const present = yield* fs.readDirectory(input.archivesDir);
const archives = CLI_ARCHIVE_PLATFORM_KEYS.flatMap((key) => {
const fileName = cliArchiveFileName(input.version, key);
return present.includes(fileName)
? [{ key, archive: path.join(input.archivesDir, fileName) }]
: [];
});
const missing = CLI_ARCHIVE_PLATFORM_KEYS.filter(
(key) => !archives.some((entry) => entry.key === key),
);
if (missing.length > 0 && (!input.allowMissing || archives.length === 0)) {
return yield* new NpmPackagesArchivesMissingError({ archivesDir: input.archivesDir, missing });
}
yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), {
recursive: true,
});
// Each archive stages in its own scratch dir, so all of them unpack and
// compress at once. Sequentially this took about 45s for five archives.
const platformOutputs = yield* Effect.forEach(
archives,
({ key, archive }) =>
stagePlatformPackage({ key, archive, outputDir: input.outputDir, version: input.version }),
{ concurrency: "unbounded" },
);
const outputs = [
...platformOutputs,
yield* stageLauncherPackage({
outputDir: input.outputDir,
version: input.version,
platformKeys: archives.map((entry) => entry.key),
}),
];
for (const output of outputs) {
yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`);
}
if (missing.length > 0) {
yield* Effect.logWarning(
`[npm-packages] Launcher omits ${missing.join(", ")} (--allow-missing).`,
);
}
return outputs;
});
const command = Command.make(
"build-npm-platform-packages",
{
archivesDir: Flag.String("archives-dir").pipe(
Flag.withDescription("Directory holding the release's t3-<version>-<platform> archives."),
),
version: Flag.String("version").pipe(
Flag.withDescription(
"Exact release version; selects the archives and versions the packages.",
),
),
outputDir: Flag.String("output-dir").pipe(Flag.withDefault("npm-packages")),
allowMissing: Flag.Boolean("allow-missing").pipe(
Flag.withDefault(false),
Flag.withDescription("Build a launcher that lists only the platforms present."),
),
},
buildNpmPlatformPackages,
).pipe(
Command.withDescription(
"Build the t3 launcher and @t3code/t3-<platform> npm packages from CLI release archives.",
),
);
if (import.meta.main) {
Command.run(command, { version: "0.0.0" }).pipe(
Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),
NodeRuntime.runMain,
);
}