Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ferric-build-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ferric-cli": minor
---

Add a `--cwd <path>` option to `ferric build`, to build a crate outside the
current directory. Outputs default to that directory, unless `--output` is
passed.
32 changes: 32 additions & 0 deletions packages/ferric/src/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";

import { buildCommand } from "./build.js";
import { applyChanges, planInit } from "./init.js";

describe("ferric build", { skip: process.platform === "win32" }, () => {
it("builds a crate outside the current directory with --cwd", async (context) => {
const tempPath = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "ferric-build-test-")),
);
context.after(() => {
fs.rmSync(tempPath, { recursive: true, force: true });
});
const packagePath = path.join(tempPath, "addon");
const { changes } = await planInit({ packagePath, crateName: "addon" });
await applyChanges(changes);

await buildCommand.parseAsync(["--dts-only", "--cwd", packagePath], {
from: "user",
});

assert.match(
fs.readFileSync(path.join(packagePath, "addon.d.ts"), "utf8"),
/export declare function /,
);
assert(fs.existsSync(path.join(packagePath, "addon.js")));
});
});
35 changes: 25 additions & 10 deletions packages/ferric/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,14 @@ const dtsOnlyOption = new Option(

const outputPathOption = new Option(
"--output <path>",
"Writing outputs to this directory",
).default(process.cwd());
"Writing outputs to this directory (defaults to --cwd)",
);
const cwdOption = new Option(
"--cwd <path>",
"Path to the directory containing the Cargo.toml file",
)
.argParser((value) => path.resolve(value))
.default(process.cwd());
const configurationOption = new Option(
"--configuration <configuration>",
"Build configuration",
Expand Down Expand Up @@ -157,6 +163,7 @@ export const buildCommand = new Command("build")
.addOption(androidTarget)
.addOption(ndkVersionOption)
.addOption(outputPathOption)
.addOption(cwdOption)
.addOption(configurationOption)
.addOption(xcframeworkExtensionOption)
.addOption(appleBundleIdentifierOption)
Expand All @@ -172,7 +179,8 @@ export const buildCommand = new Command("build")
apple,
android,
ndkVersion,
output: outputPath,
output,
cwd,
configuration,
xcframeworkExtension,
appleBundleIdentifier,
Expand All @@ -181,6 +189,12 @@ export const buildCommand = new Command("build")
dtsOnly,
skipFeatureCheck,
}) => {
assertFixable(
fs.existsSync(cwd),
`Expected --cwd directory ${prettyPath(cwd)} to exist`,
{ instructions: "Pass the path of a directory with a Cargo.toml" },
);
const outputPath = path.resolve(output ?? cwd);
if (dtsOnly) {
assertFixable(
targetArg.length === 0 && !apple && !android && !clean,
Expand All @@ -191,13 +205,13 @@ export const buildCommand = new Command("build")
},
);
ensureCargo();
const libraryName = determineCargoLibraryName(process.cwd());
const libraryName = determineCargoLibraryName(cwd);
const declarationsFilename = `${libraryName}.d.ts`;
const declarationsPath = path.join(outputPath, declarationsFilename);
await oraPromise(
generateTypeScriptDeclarations({
outputFilename: declarationsFilename,
createPath: process.cwd(),
createPath: cwd,
outputPath,
}),
{
Expand Down Expand Up @@ -228,7 +242,7 @@ export const buildCommand = new Command("build")
}
if (clean) {
await oraPromise(
() => spawn("cargo", ["clean"], { outputMode: "buffered" }),
() => spawn("cargo", ["clean"], { cwd, outputMode: "buffered" }),
{
text: "Cleaning target directory",
successText: "Cleaned target directory",
Expand Down Expand Up @@ -277,9 +291,9 @@ export const buildCommand = new Command("build")
);
}
ensureCargo();
ensureAvailableTargets(targets);
ensureAvailableTargets(targets, cwd);
if (!skipFeatureCheck) {
assertNapiFeatures(process.cwd(), targets);
assertNapiFeatures(cwd, targets);
}

const appleTargets = filterTargetsByPlatform(targets, "apple");
Expand All @@ -298,7 +312,7 @@ export const buildCommand = new Command("build")
async () =>
[
target,
await build({ configuration, target, verbose }),
await build({ configuration, target, verbose, cwd }),
] as const,
),
),
Expand All @@ -313,6 +327,7 @@ export const buildCommand = new Command("build")
configuration,
target,
verbose,
cwd,
ndkVersion,
androidApiLevel: ANDROID_API_LEVEL,
}),
Expand Down Expand Up @@ -420,7 +435,7 @@ export const buildCommand = new Command("build")
await oraPromise(
generateTypeScriptDeclarations({
outputFilename: declarationsFilename,
createPath: process.cwd(),
createPath: cwd,
outputPath,
}),
{
Expand Down
75 changes: 75 additions & 0 deletions packages/ferric/src/cargo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, TestContext } from "node:test";

import { determineCargoLibraryName, getCargoMetadata } from "./cargo.js";

function setupWorkspace(context: TestContext) {
const workspacePath = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "ferric-cargo-test-")),
);
context.after(() => {
fs.rmSync(workspacePath, { recursive: true, force: true });
});
fs.writeFileSync(
path.join(workspacePath, "Cargo.toml"),
'[workspace]\nmembers = ["alpha", "beta"]\nresolver = "2"\n',
);
for (const name of ["alpha", "beta"]) {
const srcPath = path.join(workspacePath, name, "src");
fs.mkdirSync(srcPath, { recursive: true });
fs.writeFileSync(path.join(srcPath, "lib.rs"), "");
fs.writeFileSync(
path.join(workspacePath, name, "Cargo.toml"),
`[package]\nname = "${name}"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\ncrate-type = ["cdylib"]\n`,
);
}
return workspacePath;
}

describe("determineCargoLibraryName", () => {
it("returns the library of the current workspace member", (context) => {
const workspacePath = setupWorkspace(context);
assert.equal(
determineCargoLibraryName(path.join(workspacePath, "beta")),
"beta",
);
});

it("throws at a workspace root with multiple libraries", (context) => {
const workspacePath = setupWorkspace(context);
assert.throws(
() => determineCargoLibraryName(workspacePath),
/Expected exactly one cdylib target, got: alpha, beta/,
);
});
});

describe("getCargoMetadata", () => {
it("resolves the target directory shared by a workspace", (context) => {
context.mock.property(process, "env", {
...process.env,
CARGO_TARGET_DIR: undefined,
});
const workspacePath = setupWorkspace(context);
assert.equal(
getCargoMetadata(path.join(workspacePath, "alpha")).target_directory,
path.join(workspacePath, "target"),
);
});

it("respects CARGO_TARGET_DIR", (context) => {
const workspacePath = setupWorkspace(context);
const targetPath = path.join(workspacePath, "custom-target");
context.mock.property(process, "env", {
...process.env,
CARGO_TARGET_DIR: targetPath,
});
assert.equal(
getCargoMetadata(path.join(workspacePath, "alpha")).target_directory,
targetPath,
);
});
});
54 changes: 35 additions & 19 deletions packages/ferric/src/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
spawn,
} from "@react-native-node-api/cli-utils";
import { applePrebuildPath, androidPrebuildPath } from "weak-node-api";
import * as z from "zod";

import {
AndroidTargetName,
Expand Down Expand Up @@ -93,9 +94,26 @@ export function ensureCargo() {
}
}

type CargoMetadata = {
packages: { targets: { name: string; kind: string[] }[] }[];
};
const CargoMetadataSchema = z.object({
target_directory: z.string(),
packages: z.array(
z.object({
manifest_path: z.string(),
targets: z.array(
z.object({ name: z.string(), kind: z.array(z.string()) }),
),
}),
),
});

export function getCargoMetadata(cwd: string) {
const output = cp.execFileSync(
"cargo",
["metadata", "--no-deps", "--format-version", "1"],
{ cwd, encoding: "utf-8" },
);
return CargoMetadataSchema.parse(JSON.parse(output));
}

/**
* Determine the name of the crate's "cdylib" target, without building anything,
Expand All @@ -104,13 +122,13 @@ type CargoMetadata = {
* normalizes the crate name (dashes to underscores) for the compiled artifact.
*/
export function determineCargoLibraryName(cwd: string): string {
const output = cp.execFileSync(
"cargo",
["metadata", "--no-deps", "--format-version", "1"],
{ cwd, encoding: "utf-8" },
const { packages } = getCargoMetadata(cwd);
const manifestPath = fs.realpathSync(path.join(cwd, "Cargo.toml"));
// Metadata lists every workspace member, but cargo only builds the current one
const currentPackage = packages.find(
(pkg) => fs.realpathSync(pkg.manifest_path) === manifestPath,
);
const { packages } = JSON.parse(output) as CargoMetadata;
const cdylibNames = packages
const cdylibNames = (currentPackage ? [currentPackage] : packages)
.flatMap((pkg) => pkg.targets)
.filter((target) => target.kind.includes("cdylib"))
.map((target) => target.name);
Expand All @@ -126,6 +144,7 @@ export function determineCargoLibraryName(cwd: string): string {
type BuildOptions = {
configuration: "debug" | "release";
verbose: boolean;
cwd: string;
} & (
| {
target: AndroidTargetName;
Expand All @@ -140,7 +159,7 @@ type BuildOptions = {
);

export async function build(options: BuildOptions) {
const { target, configuration, verbose } = options;
const { target, configuration, verbose, cwd } = options;
const args = ["build", "--target", target];
if (configuration.toLowerCase() === "release") {
args.push("--release");
Expand All @@ -154,6 +173,7 @@ export async function build(options: BuildOptions) {
args.push("-Z", "build-std=std,panic_abort");
}
await spawn("cargo", args, {
cwd,
outputMode: verbose ? "inherit" : "buffered",
outputPrefix: verbose ? chalk.dim(`[${target}]`) : undefined,
env: {
Expand All @@ -162,19 +182,15 @@ export async function build(options: BuildOptions) {
},
});
const targetOutputPath = joinPathAndAssertExistence(
process.cwd(),
"target",
getCargoMetadata(cwd).target_directory,
target,
configuration,
);
const dynamicLibraryFile = fs
.readdirSync(targetOutputPath)
.filter((file) => file.endsWith(".so") || file.endsWith(".dylib"));
assert(
dynamicLibraryFile.length === 1,
`Expected a single shared object file in ${targetOutputPath}`,
const libraryName = determineCargoLibraryName(cwd);
return joinPathAndAssertExistence(
targetOutputPath,
`lib${libraryName}.${isAndroidTarget(target) ? "so" : "dylib"}`,
);
return joinPathAndAssertExistence(targetOutputPath, dynamicLibraryFile[0]);
}

export function getLLVMToolchainBinPath(ndkPath: string) {
Expand Down
3 changes: 2 additions & 1 deletion packages/ferric/src/rustup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import cp from "node:child_process";

import { UsageError } from "@react-native-node-api/cli-utils";

export function getInstalledTargets() {
export function getInstalledTargets(cwd: string) {
try {
return new Set(
cp
.execFileSync("rustup", ["target", "list", "--installed"], {
cwd,
encoding: "utf-8",
})
.split("\n"),
Expand Down
Loading
Loading