mirror of
https://github.com/aphrody-labs/bun-distribution.git
synced 2026-10-14 07:59:17 +00:00
463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { mkdir, realpath } from "node:fs/promises";
|
|
import { hostname } from "node:os";
|
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
import { parseArgs } from "node:util";
|
|
import {
|
|
appCapabilities,
|
|
appHostname,
|
|
assertRuntimeFacts,
|
|
cargoModule,
|
|
checkoutPath,
|
|
currentRuntimeFacts,
|
|
findModule,
|
|
hashFile,
|
|
inspectModuleCoverage,
|
|
isObject,
|
|
loadConfig,
|
|
repositoryRoot,
|
|
resolveModuleBinary,
|
|
runtimePinFor,
|
|
runProcess,
|
|
type Config,
|
|
type RuntimeFacts,
|
|
type RuntimePin,
|
|
type RuntimeTarget,
|
|
} from "@aphrody/bun-workspace";
|
|
import { validateAssetReference, type AssetReference } from "@aphrody/bun-workspace/assets";
|
|
import {
|
|
buildAppArtifact,
|
|
hostMatchesTarget,
|
|
parseArtifactTarget,
|
|
type AppManifest,
|
|
type ArtifactTarget,
|
|
type BuiltAppArtifact,
|
|
type NativeArtifactReceipt,
|
|
type NativeFileInput,
|
|
type RuntimeArtifactReceipt,
|
|
} from "@aphrody/bun-artifacts";
|
|
|
|
type Protocol = AppManifest["engine"]["protocol"];
|
|
type Qualification = NativeArtifactReceipt["qualification"];
|
|
type Stage = "plan" | "source" | "native-build" | "native-receipt" | "compile" | "extract";
|
|
|
|
interface AppBuildResult {
|
|
appId: string;
|
|
libraryId: string;
|
|
hostname: string;
|
|
protocol: Protocol;
|
|
sourceRevision: string;
|
|
status: "planned" | "built" | "owner-unavailable" | "failed";
|
|
stage: Stage;
|
|
nativeReceipt?: NativeArtifactReceipt;
|
|
artifact?: BuiltAppArtifact;
|
|
extraction?: "qualified";
|
|
error?: { code: string; message: string };
|
|
}
|
|
|
|
interface BuildReport {
|
|
schemaVersion: 1;
|
|
identity: "Aphrody / Bun";
|
|
action: "plan" | "build";
|
|
target: ArtifactTarget;
|
|
qualification: Qualification;
|
|
expectedRuntime: RuntimePin;
|
|
runtimeReceipt?: RuntimeArtifactReceipt;
|
|
sharedAssets?: AssetReference;
|
|
results: AppBuildResult[];
|
|
complete: boolean;
|
|
}
|
|
|
|
const usage = `Aphrody / Bun app builds
|
|
build-apps.ts [plan|build] [--app APP[,APP] ...] [--target TARGET]
|
|
[--bun FILE] [--runtime-pin JSON] [--runtime-receipt JSON]
|
|
[--shared-assets JSON] [--resources JSON] [--protocol cli|cli-json|jsonl|mcp-stdio]
|
|
[--root DIR] [--output RELATIVE_DIR] [--report FILE] [--host HOST]
|
|
[--mode native|ssh|docker] [--native-timeout SECONDS] [--compile-timeout SECONDS]
|
|
Planning is the default. Builds require a qualified native host and a fresh output.
|
|
`;
|
|
const maxMetadataBytes = 1024 * 1024;
|
|
|
|
async function readJSON(path: string): Promise<unknown> {
|
|
const file = Bun.file(path);
|
|
if (!file.size || file.size > maxMetadataBytes) throw new Error("Build metadata must fit within 1 MiB.");
|
|
return file.json();
|
|
}
|
|
|
|
function runtimePin(value: unknown): RuntimePin {
|
|
if (
|
|
!isObject(value) ||
|
|
value.version !== "1.4.4" ||
|
|
value.repository !== "aphrody-labs/bun" ||
|
|
typeof value.revision !== "string" ||
|
|
!/^[0-9a-f]{40}$/.test(value.revision)
|
|
)
|
|
throw new Error("An explicit pinned Aphrody Bun 1.4.4 runtime is required.");
|
|
return { version: value.version, repository: value.repository, revision: value.revision };
|
|
}
|
|
|
|
function deadline(value: string | undefined, fallback: number, maximum: number): number {
|
|
const seconds = value === undefined ? fallback : Number(value);
|
|
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > maximum) throw new Error("Invalid build deadline.");
|
|
return seconds * 1000;
|
|
}
|
|
|
|
function errorDetails(error: unknown): { code: string; message: string } {
|
|
return {
|
|
code: isObject(error) && typeof error.code === "string" ? error.code : "BUILD_FAILED",
|
|
message: error instanceof Error ? error.message : "The application build failed.",
|
|
};
|
|
}
|
|
|
|
function observed(qualification: Qualification): Qualification {
|
|
return { ...qualification, observedAt: new Date().toISOString() };
|
|
}
|
|
|
|
function nativeHostTarget(): ArtifactTarget {
|
|
const platform = process.platform === "win32" ? "windows" : process.platform;
|
|
const target = `bun-${platform}-${process.arch}`;
|
|
if (process.platform !== "linux") return parseArtifactTarget(target);
|
|
return parseArtifactTarget(hostMatchesTarget(parseArtifactTarget(target)) ? target : `${target}-musl`);
|
|
}
|
|
|
|
async function qualifyRuntime(
|
|
path: string,
|
|
expected: RuntimePin,
|
|
target: ArtifactTarget,
|
|
root: string,
|
|
qualification: Qualification,
|
|
supplied?: unknown,
|
|
): Promise<{ executable: string; receipt: RuntimeArtifactReceipt }> {
|
|
const executable = await realpath(path);
|
|
const result = await runProcess(
|
|
[
|
|
executable,
|
|
"--no-env-file",
|
|
"-e",
|
|
"console.log(JSON.stringify({version:Bun.version,revision:Bun.revision,sourceUrl:process.release.sourceUrl??null,standalone:Bun.isStandaloneExecutable,platform:process.platform,arch:process.arch}))",
|
|
],
|
|
root,
|
|
{
|
|
timeoutMs: 5_000,
|
|
maxOutputBytes: 4096,
|
|
stderr: "discard",
|
|
env: { ...Bun.env, BUN_BE_BUN: undefined },
|
|
},
|
|
);
|
|
const value: unknown = result.code === 0 ? JSON.parse(result.stdout.trim()) : undefined;
|
|
if (
|
|
!isObject(value) ||
|
|
value.standalone !== false ||
|
|
value.platform !== process.platform ||
|
|
value.arch !== process.arch ||
|
|
typeof value.version !== "string" ||
|
|
typeof value.revision !== "string" ||
|
|
typeof value.sourceUrl !== "string"
|
|
)
|
|
throw new Error("The selected Bun executable could not qualify on this native host.");
|
|
const facts: RuntimeFacts = {
|
|
version: value.version,
|
|
revision: value.revision,
|
|
sourceUrl: value.sourceUrl,
|
|
executable,
|
|
};
|
|
assertRuntimeFacts(expected, facts);
|
|
const sha256 = await hashFile(executable, { maxBytes: 200 * 1024 * 1024 });
|
|
if (supplied !== undefined) {
|
|
if (!isObject(supplied) || supplied.target !== target || supplied.sha256 !== sha256 || !isObject(supplied.facts)) {
|
|
throw new Error("The selected Bun bytes differ from their runtime receipt.");
|
|
}
|
|
const previous = supplied.facts;
|
|
if (
|
|
previous.version !== facts.version ||
|
|
previous.revision !== facts.revision ||
|
|
previous.sourceUrl !== facts.sourceUrl
|
|
) {
|
|
throw new Error("The selected Bun provenance differs from its runtime receipt.");
|
|
}
|
|
}
|
|
return { executable, receipt: { target, sha256, facts, qualification: observed(qualification) } };
|
|
}
|
|
|
|
async function writeJSON(path: string, value: unknown): Promise<void> {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await Bun.write(path, JSON.stringify(value, null, 2) + "\n");
|
|
}
|
|
|
|
async function resourceInputs(
|
|
path: string | undefined,
|
|
appIds: Set<string>,
|
|
): Promise<Record<string, NativeFileInput[]>> {
|
|
if (!path) return {};
|
|
const value = await readJSON(path);
|
|
if (!isObject(value)) throw new Error("Native resources must map application IDs to selected files.");
|
|
const result: Record<string, NativeFileInput[]> = {};
|
|
for (const [id, inputs] of Object.entries(value)) {
|
|
if (!appIds.has(id) || !Array.isArray(inputs) || inputs.length > 63)
|
|
throw new Error("Invalid native application resources.");
|
|
result[id] = inputs.map(input => {
|
|
if (
|
|
!isObject(input) ||
|
|
typeof input.source !== "string" ||
|
|
!isAbsolute(input.source) ||
|
|
typeof input.path !== "string" ||
|
|
!["library", "resource", "notice"].includes(String(input.kind))
|
|
)
|
|
throw new Error("Select absolute native resource inputs and their payload paths.");
|
|
return {
|
|
source: input.source,
|
|
path: input.path,
|
|
kind: input.kind as NativeFileInput["kind"],
|
|
};
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function buildOne(
|
|
config: Config,
|
|
result: AppBuildResult,
|
|
report: BuildReport,
|
|
options: {
|
|
root: string;
|
|
output: string;
|
|
bun: string;
|
|
resources: NativeFileInput[];
|
|
nativeTimeout: number;
|
|
compileTimeout: number;
|
|
},
|
|
): Promise<void> {
|
|
const module = findModule(config, result.libraryId);
|
|
if (!module.binary) {
|
|
result.status = "owner-unavailable";
|
|
result.error = {
|
|
code: "OWNER_UNAVAILABLE",
|
|
message: "The application has no existing native engine CLI.",
|
|
};
|
|
return;
|
|
}
|
|
try {
|
|
result.stage = "source";
|
|
const coverage = await inspectModuleCoverage(module, options.root);
|
|
if (coverage.errors.length || coverage.missingTargets.length || coverage.missingWorkspaceMembers.length) {
|
|
throw new Error("The native source owner has missing Cargo members or targets.");
|
|
}
|
|
const output = join(
|
|
options.output,
|
|
report.target,
|
|
result.appId,
|
|
`aphrody-${result.appId}${report.target.startsWith("bun-windows-") ? ".exe" : ""}`,
|
|
);
|
|
if (await Bun.file(output).exists()) throw new Error("Select a fresh application output path.");
|
|
result.stage = "native-build";
|
|
console.error(`Aphrody ${result.appId}: native build`);
|
|
const nativeConfig: Config = { ...config, runtime: report.expectedRuntime };
|
|
const code = await cargoModule(nativeConfig, module.id, "build", [], {
|
|
root: options.root,
|
|
timeoutMs: options.nativeTimeout,
|
|
});
|
|
if (code !== 0) throw new Error(`The native library build failed (${code}).`);
|
|
result.stage = "native-receipt";
|
|
const binary = await resolveModuleBinary(nativeConfig, module.id, { root: options.root });
|
|
if (dirname(binary).split(/[\\/]/).at(-1) !== "release")
|
|
throw new Error("Cargo did not produce the release engine artifact.");
|
|
const version = await runProcess([binary, "--version"], options.root, {
|
|
timeoutMs: 5_000,
|
|
maxOutputBytes: 4096,
|
|
stderr: "discard",
|
|
});
|
|
const match =
|
|
version.code === 0 ? version.stdout.trim().match(/(?:^|\s)(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)(?:\s|$)/) : null;
|
|
if (!match) throw new Error("The native engine did not report its version.");
|
|
result.nativeReceipt = {
|
|
target: report.target,
|
|
sourceRevision: module.revision,
|
|
sha256: await hashFile(binary, { maxBytes: 1024 * 1024 * 1024 }),
|
|
version: match[1]!,
|
|
qualification: observed(report.qualification),
|
|
};
|
|
await writeJSON(`${output}.native.json`, result.nativeReceipt);
|
|
result.stage = "compile";
|
|
console.error(`Aphrody ${result.appId}: Bun compile`);
|
|
result.artifact = await buildAppArtifact({
|
|
appId: result.appId,
|
|
target: report.target,
|
|
expectedRuntime: report.expectedRuntime,
|
|
bunExecutable: options.bun,
|
|
nativeBinary: binary,
|
|
nativeReceipt: result.nativeReceipt,
|
|
nativeFiles: options.resources,
|
|
engineProtocol: result.protocol,
|
|
targetRuntimeReceipt: report.runtimeReceipt,
|
|
sharedAssets: report.sharedAssets,
|
|
root: options.root,
|
|
output,
|
|
timeoutMs: options.compileTimeout,
|
|
});
|
|
result.stage = "extract";
|
|
const doctor = await runProcess([result.artifact.path, "--doctor"], options.root, {
|
|
timeoutMs: 180_000,
|
|
maxOutputBytes: 256 * 1024,
|
|
stderr: "discard",
|
|
env: { ...Bun.env, BUN_BE_BUN: undefined },
|
|
});
|
|
const qualification: unknown = doctor.code === 0 ? JSON.parse(doctor.stdout.trim()) : undefined;
|
|
if (
|
|
!isObject(qualification) ||
|
|
qualification.identity !== "Aphrody / Bun" ||
|
|
qualification.app !== result.appId ||
|
|
qualification.target !== report.target ||
|
|
!isObject(qualification.engine) ||
|
|
qualification.engine.available !== true ||
|
|
qualification.engine.sourceRevision !== module.revision ||
|
|
!isObject(qualification.runtime) ||
|
|
qualification.runtime.revision !== report.expectedRuntime.revision
|
|
) {
|
|
throw new Error("The independent executable failed native extraction qualification.");
|
|
}
|
|
result.extraction = "qualified";
|
|
result.status = "built";
|
|
console.error(`Aphrody ${result.appId}: built`);
|
|
} catch (error) {
|
|
result.status = "failed";
|
|
result.error = errorDetails(error);
|
|
console.error(`Aphrody ${result.appId}: ${result.stage} failed`);
|
|
}
|
|
}
|
|
|
|
export async function main(args = Bun.argv.slice(2)): Promise<number> {
|
|
const { values, positionals } = parseArgs({
|
|
args,
|
|
allowPositionals: true,
|
|
strict: true,
|
|
options: {
|
|
help: { type: "boolean", short: "h" },
|
|
app: { type: "string", multiple: true },
|
|
target: { type: "string" },
|
|
bun: { type: "string" },
|
|
"runtime-pin": { type: "string" },
|
|
"runtime-receipt": { type: "string" },
|
|
"shared-assets": { type: "string" },
|
|
resources: { type: "string" },
|
|
protocol: { type: "string" },
|
|
root: { type: "string" },
|
|
output: { type: "string" },
|
|
report: { type: "string" },
|
|
host: { type: "string" },
|
|
mode: { type: "string" },
|
|
"native-timeout": { type: "string" },
|
|
"compile-timeout": { type: "string" },
|
|
},
|
|
});
|
|
if (values.help) {
|
|
console.log(usage);
|
|
return 0;
|
|
}
|
|
const [action = "plan", ...extra] = positionals;
|
|
if (extra.length || (action !== "plan" && action !== "build")) throw new Error("Use plan or build.");
|
|
const root = resolve(values.root ?? repositoryRoot);
|
|
const config = await loadConfig(root);
|
|
const target = values.target ? parseArtifactTarget(values.target) : nativeHostTarget();
|
|
const runtimeTarget = target.slice(4) as RuntimeTarget;
|
|
const expectedRuntime = values["runtime-pin"]
|
|
? runtimePin(await readJSON(resolve(root, values["runtime-pin"])))
|
|
: runtimePinFor(config, runtimeTarget);
|
|
const mode = values.mode ?? "native";
|
|
const host = values.host ?? hostname();
|
|
if (!["native", "ssh", "docker"].includes(mode) || !/^[A-Za-z0-9._-]{1,128}$/.test(host))
|
|
throw new Error("Select a native host qualification context.");
|
|
const qualification = {
|
|
host,
|
|
mode: mode as Qualification["mode"],
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
const capabilities = appCapabilities(config);
|
|
const ids = new Set(capabilities.map(app => app.id));
|
|
const selected = new Set((values.app ?? []).flatMap(value => value.split(",")));
|
|
if ([...selected].some(id => !ids.has(id))) throw new Error("Select configured application IDs.");
|
|
if (values.protocol && !["cli", "cli-json", "jsonl", "mcp-stdio"].includes(values.protocol))
|
|
throw new Error("Select an existing native engine protocol.");
|
|
const report: BuildReport = {
|
|
schemaVersion: 1,
|
|
identity: "Aphrody / Bun",
|
|
action,
|
|
target,
|
|
qualification,
|
|
expectedRuntime,
|
|
results: capabilities
|
|
.filter(app => !selected.size || selected.has(app.id))
|
|
.map(app => {
|
|
const module = findModule(config, app.libraryId);
|
|
return {
|
|
appId: app.id,
|
|
libraryId: app.libraryId,
|
|
hostname: appHostname(app.id, config),
|
|
protocol: (values.protocol ?? module.protocol ?? "cli") as Protocol,
|
|
sourceRevision: module.revision,
|
|
stage: "plan",
|
|
status: module.binary ? "planned" : "owner-unavailable",
|
|
...(!module.binary
|
|
? {
|
|
error: {
|
|
code: "OWNER_UNAVAILABLE",
|
|
message: "The application has no existing native engine CLI.",
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}),
|
|
complete: false,
|
|
};
|
|
if (values["shared-assets"]) {
|
|
report.sharedAssets = validateAssetReference(
|
|
await readJSON(resolve(root, values["shared-assets"])),
|
|
config.deployment?.assets.url,
|
|
);
|
|
}
|
|
const reportPath = resolve(root, values.report ?? ".local/builds/report.json");
|
|
if (action === "build") {
|
|
if (!report.sharedAssets) throw new Error("Builds require one immutable shared asset manifest reference.");
|
|
if (!hostMatchesTarget(target)) throw new Error("Native engine builds require their qualified target host.");
|
|
assertRuntimeFacts(expectedRuntime, currentRuntimeFacts());
|
|
const selectedBun = values.bun ? resolve(root, values.bun) : Bun.which("bun");
|
|
if (!selectedBun) throw new Error("Select the qualified Aphrody Bun executable.");
|
|
const runtime = await qualifyRuntime(
|
|
selectedBun,
|
|
expectedRuntime,
|
|
target,
|
|
root,
|
|
qualification,
|
|
values["runtime-receipt"] ? await readJSON(resolve(root, values["runtime-receipt"])) : undefined,
|
|
);
|
|
report.runtimeReceipt = runtime.receipt;
|
|
const resources = await resourceInputs(values.resources ? resolve(root, values.resources) : undefined, ids);
|
|
const output = checkoutPath(root, values.output ?? "build/apps");
|
|
const nativeTimeout = deadline(values["native-timeout"], 1800, 43_200);
|
|
const compileTimeout = deadline(values["compile-timeout"], 180, 600);
|
|
for (const result of report.results) {
|
|
await buildOne(config, result, report, {
|
|
root,
|
|
output,
|
|
bun: runtime.executable,
|
|
resources: resources[result.appId] ?? [],
|
|
nativeTimeout,
|
|
compileTimeout,
|
|
});
|
|
await writeJSON(reportPath, report);
|
|
}
|
|
report.complete = report.results.every(result => result.status === "built");
|
|
await writeJSON(reportPath, report);
|
|
} else if (values.report) await writeJSON(reportPath, report);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
return action === "build" && !report.complete ? 1 : 0;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
process.exitCode = await main();
|
|
} catch (error) {
|
|
console.error(JSON.stringify(errorDetails(error)));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|