diff --git a/.changeset/cli-utils-init-helpers.md b/.changeset/cli-utils-init-helpers.md
new file mode 100644
index 00000000..e39e6b3f
--- /dev/null
+++ b/.changeset/cli-utils-init-helpers.md
@@ -0,0 +1,5 @@
+---
+"@react-native-node-api/cli-utils": minor
+---
+
+Add the helpers shared by the `ferric init` and `cmake-rn init` commands: planning file changes, updating a `package.json` without overwriting what it declares, printing diffs, and adding a package to a pnpm, npm or yarn workspace.
diff --git a/.changeset/cmake-rn-init-command.md b/.changeset/cmake-rn-init-command.md
new file mode 100644
index 00000000..9af0c4dc
--- /dev/null
+++ b/.changeset/cmake-rn-init-command.md
@@ -0,0 +1,9 @@
+---
+"cmake-rn": minor
+---
+
+Add a `cmake-rn init [path]` command, scaffolding a package building a CMake Node-API module.
+
+It writes the `package.json`, `CMakeLists.txt`, `src/addon.c`, a JavaScript entrypoint with its TypeScript declarations and `.gitignore` a package needs, and adds it to the workspace of the mono-repo it is initialized into. Run against a package that already exists, it bumps outdated dependencies, and inspects an existing `CMakeLists.txt` instead of replacing it, pointing out when it doesn't define or link the `weak-node-api` target. `--weak-node-api ` picks how a new `CMakeLists.txt` gets Node-API: linking the `weak-node-api` target (the default), through the cmake-js variables so it also builds with cmake-js, or not at all. A differing entrypoint or source file is skipped with a diff to merge by hand, unless `--force` is passed. `--dry-run` prints all of this without writing anything.
+
+The build is now the default `build` subcommand, so `cmake-rn` invocations keep working as before, and its options are listed by `cmake-rn build --help`.
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 142733a5..3bf7be7d 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -81,6 +81,12 @@ jobs:
git status --porcelain packages/ferric-example
git diff --exit-code packages/ferric-example
test -z "$(git status --porcelain packages/ferric-example)"
+ - run: pnpm run init-cmake-rn-example
+ - name: Verify cmake-rn-example matches what cmake-rn init generates
+ run: |
+ git status --porcelain packages/cmake-rn-example
+ git diff --exit-code packages/cmake-rn-example
+ test -z "$(git status --porcelain packages/cmake-rn-example)"
- run: pnpm run lint
env:
DEBUG: eslint:eslint
diff --git a/README.md b/README.md
index 308d5407..3a90edfe 100644
--- a/README.md
+++ b/README.md
@@ -75,3 +75,7 @@ A test app using `react-native-test-app` to exercise the implementation of it al
### [`@react-native-node-api/ferric-example`](packages/ferric-example)
A simple example of using the `ferric` CLI to build a Node-API module for React Native in Rust.
+
+### [`@react-native-node-api/cmake-rn-example`](packages/cmake-rn-example)
+
+A simple example of using the `cmake-rn` CLI to build a Node-API module for React Native in C, generated by `cmake-rn init`.
diff --git a/apps/test-app/App.tsx b/apps/test-app/App.tsx
index 3322b136..a34facd9 100644
--- a/apps/test-app/App.tsx
+++ b/apps/test-app/App.tsx
@@ -24,6 +24,7 @@ type Context = {
nodeAddonExamples?: boolean;
nodeTests?: boolean;
ferricExample?: boolean;
+ cmakeRnExample?: boolean;
};
function loadTests({
@@ -31,6 +32,7 @@ function loadTests({
nodeAddonExamples = allTests,
nodeTests = allTests,
ferricExample = allTests,
+ cmakeRnExample = allTests,
}: Context) {
describeIf(nodeAddonExamples, "Node Addon Examples", () => {
for (const [suiteName, examples] of Object.entries(
@@ -85,6 +87,18 @@ function loadTests({
}
});
});
+
+ describeIf(cmakeRnExample, "cmake-rn-example", () => {
+ it("exports a callable sum function", () => {
+ const exampleAddon =
+ /* eslint-disable-next-line @typescript-eslint/no-require-imports -- TODO: Determine why a dynamic import doesn't work on Android */
+ require("@react-native-node-api/cmake-rn-example") as typeof import("@react-native-node-api/cmake-rn-example");
+ const result = exampleAddon.sum(1, 3);
+ if (result !== 4) {
+ throw new Error(`Expected 1 + 3 to equal 4, but got ${result}`);
+ }
+ });
+ });
}
export default function App() {
diff --git a/apps/test-app/package.json b/apps/test-app/package.json
index 1383025e..0faa4f9a 100644
--- a/apps/test-app/package.json
+++ b/apps/test-app/package.json
@@ -15,11 +15,13 @@
"test:android:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:android -- ",
"test:android:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:android -- ",
"test:android:ferricExample": "MOCHA_REMOTE_CONTEXT=ferricExample node --run test:android -- ",
+ "test:android:cmakeRnExample": "MOCHA_REMOTE_CONTEXT=cmakeRnExample node --run test:android -- ",
"test:ios": "mocha-remote --exit-on-error -- concurrently --passthrough-arguments --kill-others-on-fail npm:metro 'npm:ios -- {@}' --",
"test:ios:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:ios -- ",
"test:ios:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:ios -- ",
"test:ios:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:ios -- ",
- "test:ios:ferricExample": "MOCHA_REMOTE_CONTEXT=ferricExample node --run test:ios -- "
+ "test:ios:ferricExample": "MOCHA_REMOTE_CONTEXT=ferricExample node --run test:ios -- ",
+ "test:ios:cmakeRnExample": "MOCHA_REMOTE_CONTEXT=cmakeRnExample node --run test:ios -- "
},
"dependencies": {
"@babel/core": "^7.26.10",
@@ -28,6 +30,7 @@
"@react-native-community/cli": "^20.0.2",
"@react-native-community/cli-platform-android": "^20.0.2",
"@react-native-community/cli-platform-ios": "^20.0.2",
+ "@react-native-node-api/cmake-rn-example": "workspace:*",
"@react-native-node-api/ferric-example": "workspace:*",
"@react-native-node-api/node-addon-examples": "workspace:*",
"@react-native-node-api/node-tests": "workspace:*",
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 9d8e53b8..64da700a 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -39,6 +39,9 @@ We will be implementing this `add` function.
## Steps needed for the author of the `calculator-lib` library
+> [!TIP]
+> Run `npx cmake-rn init` (or `npx ferric init` for Rust) to scaffold a package with the steps below already taken care of, or to bring an existing package up to date.
+
### Install `react-native-node-api` as a dev-dependency and declare a peer dependency
```bash
diff --git a/eslint.config.js b/eslint.config.js
index 7e4d196d..edd84ac6 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,8 @@ export default tseslint.config(
"packages/ferric-example/ferric_example.js",
"packages/ferric-example/ferric_example.d.ts",
"packages/ferric-example/target/**",
+ "packages/cmake-rn-example/cmake-rn-example.js",
+ "packages/cmake-rn-example/cmake-rn-example.d.ts",
"packages/node-tests/node/**",
"packages/node-tests/tests/**",
"packages/node-tests/*.generated.js",
diff --git a/package.json b/package.json
index 56a80946..c973e1b2 100644
--- a/package.json
+++ b/package.json
@@ -14,13 +14,14 @@
"publint": "node scripts/run-in-published.ts pnpm exec publint --strict",
"prettier:check": "prettier --experimental-cli --check .",
"prettier:write": "prettier --experimental-cli --write .",
- "test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake --filter ferric-cli run test",
+ "test": "pnpm --filter @react-native-node-api/cli-utils --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake --filter ferric-cli run test",
"bootstrap": "node --run build && pnpm --recursive run bootstrap",
"changeset": "changeset",
"release": "node --run prerelease && changeset publish",
"prerelease": "node --run build && pnpm --recursive run prerelease && node --run publint",
"init-macos-test-app": "node scripts/init-macos-test-app.ts",
- "init-ferric-example": "node scripts/init-ferric-example.ts"
+ "init-ferric-example": "node scripts/init-ferric-example.ts",
+ "init-cmake-rn-example": "node scripts/init-cmake-rn-example.ts"
},
"author": {
"name": "Callstack",
diff --git a/packages/cli-utils/package.json b/packages/cli-utils/package.json
index 47bc0e4e..1b79b4c4 100644
--- a/packages/cli-utils/package.json
+++ b/packages/cli-utils/package.json
@@ -9,17 +9,29 @@
},
"type": "module",
"files": [
- "dist"
+ "dist",
+ "!dist/**/*.test.d.ts",
+ "!dist/**/*.test.d.ts.map"
],
"exports": {
".": "./dist/index.js"
},
+ "scripts": {
+ "test": "tsx --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout"
+ },
"dependencies": {
"@commander-js/extra-typings": "^14.0.0",
"bufout": "^1.0.0",
"chalk": "^5.4.1",
"commander": "^14.0.1",
+ "diff": "^9.0.0",
"ora": "^8.2.0",
- "p-limit": "^7.2.0"
+ "p-limit": "^7.2.0",
+ "picomatch": "^4.0.5",
+ "semver": "^7.8.5"
+ },
+ "devDependencies": {
+ "@types/picomatch": "^4.0.3",
+ "@types/semver": "^7.8.0"
}
}
diff --git a/packages/cli-utils/src/index.ts b/packages/cli-utils/src/index.ts
index 712adfe8..7bf84052 100644
--- a/packages/cli-utils/src/index.ts
+++ b/packages/cli-utils/src/index.ts
@@ -7,3 +7,6 @@ export { default as pLimit } from "p-limit";
export * from "./actions.js";
export * from "./errors.js";
export * from "./paths.js";
+export * from "./init.js";
+export * from "./json.js";
+export * from "./workspaces.js";
diff --git a/packages/cli-utils/src/init.test.ts b/packages/cli-utils/src/init.test.ts
new file mode 100644
index 00000000..fabe92d2
--- /dev/null
+++ b/packages/cli-utils/src/init.test.ts
@@ -0,0 +1,172 @@
+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 {
+ appendMissingLines,
+ assertDistinctChanges,
+ determineDependencySpecifiers,
+ formatDiff,
+ isOutdatedRange,
+ planTemplateFile,
+ stripPackageScope,
+ withDependency,
+ withFiles,
+} from "./init.js";
+
+function setupTempDirectory(context: TestContext) {
+ const tempPath = fs.realpathSync(
+ fs.mkdtempSync(path.join(os.tmpdir(), "cli-utils-init-test-")),
+ );
+ context.after(() => fs.rmSync(tempPath, { recursive: true, force: true }));
+ return tempPath;
+}
+
+describe("assertDistinctChanges", () => {
+ it("rejects two changes to the same file", () => {
+ assertDistinctChanges([{ path: "a" }, { path: "b" }]);
+ assert.throws(() =>
+ assertDistinctChanges([{ path: "a" }, { path: "b" }, { path: "a" }]),
+ );
+ });
+});
+
+describe("stripPackageScope", () => {
+ it("strips the scope", () => {
+ assert.equal(stripPackageScope("@my-org/my-addon"), "my-addon");
+ assert.equal(stripPackageScope("my-addon"), "my-addon");
+ });
+});
+
+describe("isOutdatedRange", () => {
+ it("compares the lowest versions allowed", () => {
+ assert.equal(isOutdatedRange("^0.1.0", "^0.2.0"), true);
+ assert.equal(isOutdatedRange("^0.2.0", "^0.1.0"), false);
+ assert.equal(isOutdatedRange("^0.2.0", "^0.2.0"), false);
+ });
+
+ it("never considers non-versions outdated", () => {
+ assert.equal(isOutdatedRange("workspace:*", "^1.0.0"), false);
+ assert.equal(isOutdatedRange("*", "^1.0.0"), false);
+ assert.equal(isOutdatedRange("^1.0.0", "latest"), false);
+ });
+});
+
+describe("withDependency", () => {
+ const update = withDependency({
+ name: "dep",
+ specifier: "^1.0.0",
+ keys: ["devDependencies", "dependencies"],
+ });
+
+ it("adds a missing dependency to the first key", () => {
+ assert.deepEqual(update({}), { devDependencies: { dep: "^1.0.0" } });
+ });
+
+ it("bumps an outdated dependency where it's declared", () => {
+ assert.deepEqual(update({ dependencies: { dep: "^0.1.0" } }), {
+ dependencies: { dep: "^1.0.0" },
+ });
+ });
+
+ it("keeps a newer dependency", () => {
+ const packageJson = { dependencies: { dep: "^2.0.0" } };
+ assert.deepEqual(update(packageJson), packageJson);
+ });
+});
+
+describe("withFiles", () => {
+ it("only creates a files array when asked to", () => {
+ assert.deepEqual(withFiles(["a"], false)({}), {});
+ assert.deepEqual(withFiles(["a"], true)({}), { files: ["a"] });
+ assert.deepEqual(withFiles(["a", "b"], false)({ files: ["b"] }), {
+ files: ["b", "a"],
+ });
+ });
+});
+
+describe("appendMissingLines", () => {
+ it("appends the lines a file lacks", () => {
+ assert.equal(appendMissingLines("a", "a\nb\n"), "a\nb\n");
+ assert.equal(appendMissingLines("a\nb\n", "b\n"), undefined);
+ });
+});
+
+describe("planTemplateFile", () => {
+ it("creates, skips or overwrites", async (context) => {
+ const outputPath = path.join(setupTempDirectory(context), "file.txt");
+ const contents = "template\n";
+ assert.deepEqual(
+ await planTemplateFile({ outputPath, contents, force: false }),
+ { changes: [{ kind: "create", path: outputPath, contents }] },
+ );
+ fs.writeFileSync(outputPath, contents);
+ assert.deepEqual(
+ await planTemplateFile({ outputPath, contents, force: false }),
+ {},
+ );
+ fs.writeFileSync(outputPath, "mine\n");
+ assert.deepEqual(
+ await planTemplateFile({ outputPath, contents, force: false }),
+ { skips: [{ path: outputPath, contents, previous: "mine\n" }] },
+ );
+ assert.deepEqual(
+ await planTemplateFile({ outputPath, contents, force: true }),
+ {
+ changes: [
+ {
+ kind: "update",
+ path: outputPath,
+ contents,
+ previous: "mine\n",
+ forced: true,
+ },
+ ],
+ },
+ );
+ });
+});
+
+describe("determineDependencySpecifiers", () => {
+ it("uses released versions for a published CLI", (context) => {
+ const cliPackagePath = setupTempDirectory(context);
+ fs.writeFileSync(
+ path.join(cliPackagePath, "package.json"),
+ JSON.stringify({
+ version: "1.2.3",
+ dependencies: { "react-native-node-api": "~0.9.0" },
+ }),
+ );
+ assert.deepEqual(
+ determineDependencySpecifiers({ cliPackagePath, workspace: undefined }),
+ { hostSpecifier: "^0.9.0", cliSpecifier: "^1.2.3" },
+ );
+ });
+
+ it("uses released versions outside the workspace of a linked CLI", (context) => {
+ const cliPackagePath = setupTempDirectory(context);
+ fs.writeFileSync(
+ path.join(cliPackagePath, "package.json"),
+ JSON.stringify({
+ version: "1.2.3",
+ dependencies: { "react-native-node-api": "workspace:*" },
+ }),
+ );
+ assert.deepEqual(
+ determineDependencySpecifiers({ cliPackagePath, workspace: undefined }),
+ { hostSpecifier: "latest", cliSpecifier: "^1.2.3" },
+ );
+ });
+});
+
+describe("formatDiff", () => {
+ it("renders the changed lines with context", () => {
+ const lines = formatDiff("a\nb\nc\n", "a\nb\nB\nc\n").map((line) =>
+ // eslint-disable-next-line no-control-regex -- dropping the colors
+ line.replace(/\u001b\[\d+m/g, ""),
+ );
+ assert.deepEqual(lines, ["@@ -1,3 +1,4 @@", " a", " b", "+B", " c"]);
+ });
+});
diff --git a/packages/cli-utils/src/init.ts b/packages/cli-utils/src/init.ts
new file mode 100644
index 00000000..1ccf40cc
--- /dev/null
+++ b/packages/cli-utils/src/init.ts
@@ -0,0 +1,530 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { isDeepStrictEqual } from "node:util";
+
+import chalk from "chalk";
+import { structuredPatch } from "diff";
+import semver from "semver";
+
+import {
+ detectJsonIndentation,
+ getObjectProperty,
+ getStringArrayProperty,
+ parseJsonObject,
+ stringifyJson,
+} from "./json.js";
+import { prettyPath } from "./paths.js";
+import {
+ addWorkspacePattern,
+ findWorkspace,
+ isPathCovered,
+ relativeWorkspacePath,
+ type Workspace,
+} from "./workspaces.js";
+
+export type Change =
+ | { kind: "create"; path: string; contents: string }
+ | {
+ kind: "update";
+ path: string;
+ contents: string;
+ previous: string;
+ forced?: boolean;
+ };
+
+/** A difference left alone, unless the caller forces the overwrite. */
+export type Skip = {
+ path: string;
+ contents: string;
+ previous: string;
+ hint?: string;
+};
+
+export type StepResult = {
+ changes?: Change[];
+ skips?: Skip[];
+ notices?: string[];
+};
+
+export type PlanResult = Required;
+
+export type PackageJson = Record;
+
+export function mergeStepResults(results: StepResult[]): PlanResult {
+ return {
+ changes: results.flatMap(({ changes = [] }) => changes),
+ skips: results.flatMap(({ skips = [] }) => skips),
+ notices: results.flatMap(({ notices = [] }) => notices),
+ };
+}
+
+/**
+ * Applying a later change to a file would silently discard an earlier one.
+ */
+export function assertDistinctChanges(changes: Pick[]) {
+ const paths = changes.map(({ path: filePath }) => filePath);
+ assert.deepEqual(
+ paths.filter((filePath, index) => paths.indexOf(filePath) !== index),
+ [],
+ "Expected every init step to change distinct files",
+ );
+}
+
+export async function applyChanges(changes: Change[]) {
+ for (const { path: filePath, contents } of changes) {
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.promises.writeFile(filePath, contents, "utf8");
+ }
+}
+
+export async function readFileIfExists(filePath: string) {
+ try {
+ return await fs.promises.readFile(filePath, "utf8");
+ } catch (error) {
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
+ return undefined;
+ }
+ throw error;
+ }
+}
+
+/**
+ * Strip any npm scope from a package name.
+ */
+export function stripPackageScope(packageName: string) {
+ return packageName.replace(/^@[^/]+\//, "");
+}
+
+/**
+ * Read the package.json of a package which may not exist yet, deriving its
+ * name from the directory when it doesn't declare one.
+ */
+export async function readPackage(packagePath: string) {
+ const existingPackageJson = await readFileIfExists(
+ path.join(packagePath, "package.json"),
+ );
+ const packageJson = existingPackageJson
+ ? parseJsonObject(existingPackageJson)
+ : undefined;
+ const name = packageJson?.["name"];
+ return {
+ existingPackageJson,
+ packageJson,
+ packageName: typeof name === "string" ? name : path.basename(packagePath),
+ };
+}
+
+/**
+ * Plan a file which should match a template: created when missing, skipped
+ * with a diff when it differs, unless forced.
+ */
+export async function planTemplateFile({
+ outputPath,
+ contents,
+ force,
+}: {
+ outputPath: string;
+ contents: string;
+ force: boolean;
+}): Promise {
+ const previous = await readFileIfExists(outputPath);
+ if (previous === undefined) {
+ return { changes: [{ kind: "create", path: outputPath, contents }] };
+ } else if (previous === contents) {
+ return {};
+ } else if (force) {
+ return {
+ changes: [
+ { kind: "update", path: outputPath, contents, previous, forced: true },
+ ],
+ };
+ }
+ return { skips: [{ path: outputPath, contents, previous }] };
+}
+
+/**
+ * Plan a .gitignore with the lines of a template, appending those an existing
+ * one lacks.
+ */
+export async function planGitignoreLines({
+ packagePath,
+ template,
+}: {
+ packagePath: string;
+ template: string;
+}): Promise {
+ const gitignorePath = path.join(packagePath, ".gitignore");
+ const previous = await readFileIfExists(gitignorePath);
+ if (previous === undefined) {
+ return {
+ changes: [{ kind: "create", path: gitignorePath, contents: template }],
+ };
+ }
+ const contents = appendMissingLines(previous, template);
+ return contents
+ ? { changes: [{ kind: "update", path: gitignorePath, contents, previous }] }
+ : {};
+}
+
+/**
+ * Plan adding a package to the workspace of the mono-repo containing it.
+ */
+export async function planWorkspace({
+ workspace,
+ packagePath,
+}: {
+ workspace: Workspace | undefined;
+ packagePath: string;
+}): Promise {
+ if (!workspace) {
+ return {};
+ }
+ const relativePath = relativeWorkspacePath(workspace, packagePath);
+ const { patterns, configPath } = workspace;
+ if (patterns && isPathCovered(patterns, relativePath)) {
+ return {};
+ }
+ const previous = await readFileIfExists(configPath);
+ const contents =
+ patterns && previous
+ ? addWorkspacePattern(workspace, previous, relativePath)
+ : undefined;
+ if (!contents || previous === undefined) {
+ return {
+ notices: [
+ `Add ${chalk.blue(relativePath)} to the workspace packages declared by ${prettyPath(configPath)}`,
+ ],
+ };
+ }
+ return {
+ changes: [{ kind: "update", path: configPath, contents, previous }],
+ };
+}
+
+export type PackageJsonUpdate = (packageJson: PackageJson) => PackageJson;
+
+/**
+ * Plan a package.json with updates applied. The updates are expected to add
+ * the entrypoints only when missing: a differing value is skipped with a diff,
+ * unless forced.
+ */
+export function planPackageJsonUpdates({
+ packagePath,
+ packageJson,
+ existingPackageJson,
+ updates,
+ entrypoints,
+ force,
+}: {
+ packagePath: string;
+ packageJson: PackageJson | undefined;
+ existingPackageJson: string | undefined;
+ updates: PackageJsonUpdate[];
+ entrypoints: { main: string; types: string };
+ force: boolean;
+}): StepResult {
+ const packageJsonPath = path.join(packagePath, "package.json");
+ const updated = updates.reduce(
+ (result, update) => update(result),
+ packageJson ?? {},
+ );
+ const stringify = (value: PackageJson) =>
+ stringifyJson(
+ value,
+ existingPackageJson
+ ? detectJsonIndentation(existingPackageJson)
+ : undefined,
+ );
+
+ if (existingPackageJson === undefined) {
+ return {
+ changes: [
+ { kind: "create", path: packageJsonPath, contents: stringify(updated) },
+ ],
+ };
+ }
+
+ const conflicts = Object.fromEntries(
+ Object.entries(entrypoints).filter(
+ ([key, value]) => updated[key] !== value,
+ ),
+ );
+ const overwritten = { ...updated, ...conflicts };
+ const hasConflicts = Object.keys(conflicts).length > 0;
+ const result = force ? overwritten : updated;
+ return {
+ changes: isDeepStrictEqual(result, packageJson)
+ ? []
+ : [
+ {
+ kind: "update",
+ path: packageJsonPath,
+ contents: stringify(result),
+ previous: existingPackageJson,
+ forced: force && hasConflicts,
+ },
+ ],
+ skips:
+ hasConflicts && !force
+ ? [
+ {
+ path: packageJsonPath,
+ previous: stringify(updated),
+ contents: stringify(overwritten),
+ hint: conflicts["main"]
+ ? `To keep ${chalk.blue(String(updated["main"]))} as the entrypoint, re-export the addon from it: ${chalk.blue(reExportStatement(updated, conflicts["main"]))}`
+ : undefined,
+ },
+ ]
+ : [],
+ };
+}
+
+function reExportStatement(packageJson: PackageJson, entrypoint: string) {
+ return packageJson["type"] === "module"
+ ? `export { default } from "./${entrypoint}";`
+ : `module.exports = require("./${entrypoint}");`;
+}
+
+/**
+ * Add the entries a package.json lacks, keeping any value it already declares.
+ */
+export function withMissing(
+ entries: Record,
+): PackageJsonUpdate {
+ return (packageJson) => ({
+ ...packageJson,
+ ...Object.fromEntries(
+ Object.entries(entries).filter(([key]) => packageJson[key] === undefined),
+ ),
+ });
+}
+
+export function withMissingScripts(
+ scripts: Record,
+): PackageJsonUpdate {
+ return (packageJson) => ({
+ ...packageJson,
+ scripts: withMissing(scripts)(
+ getObjectProperty(packageJson, "scripts") ?? {},
+ ),
+ });
+}
+
+/**
+ * Only extends an existing "files" array, unless asked to create one: adding one
+ * to a package without it would suddenly narrow down what that package publishes.
+ */
+export function withFiles(files: string[], create: boolean): PackageJsonUpdate {
+ return (packageJson) => {
+ const existing = getStringArrayProperty(packageJson, "files");
+ if (existing === undefined && !create) {
+ return packageJson;
+ }
+ const declared = existing ?? [];
+ return {
+ ...packageJson,
+ files: [...declared, ...files.filter((file) => !declared.includes(file))],
+ };
+ };
+}
+
+/**
+ * Whether a declared version range allows only versions below the wanted one.
+ * Ranges which aren't versions at all (such as the workspace protocol) are never
+ * considered outdated.
+ */
+export function isOutdatedRange(declared: string, wanted: string) {
+ const declaredRange = semver.validRange(declared);
+ const wantedRange = semver.validRange(wanted);
+ if (!declaredRange || !wantedRange || declaredRange === "*") {
+ return false;
+ }
+ return semver.gt(
+ semver.minVersion(wantedRange) ?? "0.0.0",
+ semver.minVersion(declaredRange) ?? "0.0.0",
+ );
+}
+
+/**
+ * Add a dependency to the first of the keys, unless one of them declares it
+ * already, in which case it's bumped if outdated.
+ */
+export function withDependency({
+ name,
+ specifier,
+ keys,
+}: {
+ name: string;
+ specifier: string;
+ keys: string[];
+}): PackageJsonUpdate {
+ return (packageJson) => {
+ const declaredIn = keys.find(
+ (key) => getObjectProperty(packageJson, key)?.[name] !== undefined,
+ );
+ const key = declaredIn ?? keys[0];
+ const dependencies = getObjectProperty(packageJson, key) ?? {};
+ const declared = dependencies[name];
+ return declaredIn === undefined ||
+ (typeof declared === "string" && isOutdatedRange(declared, specifier))
+ ? { ...packageJson, [key]: { ...dependencies, [name]: specifier } }
+ : packageJson;
+ };
+}
+
+/**
+ * The specifiers to declare dependencies on a CLI and the host package with.
+ * A package initialized into the very workspace the CLI is linked from gets
+ * the workspace protocol, everything else the released versions. Publishing
+ * replaces the workspace protocol in the CLI's manifest with a version, which
+ * is what makes it a reliable signal of running from a checkout of the
+ * mono-repo.
+ */
+export function determineDependencySpecifiers({
+ cliPackagePath,
+ workspace,
+}: {
+ /** Directory of the package declaring the CLI. */
+ cliPackagePath: string;
+ /** Workspace of the package being initialized. */
+ workspace: Workspace | undefined;
+}) {
+ const cliPackageJson = parseJsonObject(
+ fs.readFileSync(path.join(cliPackagePath, "package.json"), "utf8"),
+ );
+ const dependencies = getObjectProperty(cliPackageJson, "dependencies") ?? {};
+ const hostDependency = dependencies["react-native-node-api"];
+ const cliVersion = cliPackageJson["version"];
+ const linkedFromWorkspace =
+ typeof hostDependency === "string" &&
+ hostDependency.startsWith("workspace:");
+ if (
+ linkedFromWorkspace &&
+ workspace !== undefined &&
+ workspace.rootPath === findWorkspace(cliPackagePath)?.rootPath
+ ) {
+ return { hostSpecifier: "workspace:*", cliSpecifier: "workspace:*" };
+ }
+ return {
+ hostSpecifier:
+ typeof hostDependency === "string" && !linkedFromWorkspace
+ ? `^${hostDependency.replace(/^[=^~]/, "")}`
+ : "latest",
+ cliSpecifier: typeof cliVersion === "string" ? `^${cliVersion}` : "latest",
+ };
+}
+
+/**
+ * Append the lines of a template missing from an existing file, or undefined if
+ * it already has them all.
+ */
+export function appendMissingLines(contents: string, template: string) {
+ const existing = new Set(contents.split(/\r?\n/).map((line) => line.trim()));
+ const missing = template
+ .split("\n")
+ .filter((line) => line.trim().length > 0 && !existing.has(line.trim()));
+ if (missing.length === 0) {
+ return undefined;
+ }
+ const separator = contents.endsWith("\n") ? "" : "\n";
+ return contents + separator + missing.join("\n") + "\n";
+}
+
+/**
+ * The hunks of a unified diff, colored like `git diff` prints them.
+ */
+export function formatDiff(previous: string, contents: string, context = 2) {
+ const { hunks } = structuredPatch(
+ "previous",
+ "contents",
+ previous,
+ contents,
+ undefined,
+ undefined,
+ { context },
+ );
+ return hunks.flatMap(({ oldStart, oldLines, newStart, newLines, lines }) => [
+ chalk.cyan(`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`),
+ ...lines.map((line) => {
+ if (line.startsWith("+")) {
+ return chalk.green(line);
+ } else if (line.startsWith("-")) {
+ return chalk.red(line);
+ } else {
+ return chalk.dim(line);
+ }
+ }),
+ ]);
+}
+
+function printDiff(previous: string, contents: string) {
+ for (const line of formatDiff(previous, contents)) {
+ console.log(chalk.dim("│ ") + line);
+ }
+}
+
+function changeLabel(change: Change) {
+ if (change.kind === "create") {
+ return chalk.green("create");
+ }
+ return change.forced ? chalk.red("overwrite") : chalk.yellow("update");
+}
+
+export function printPlan({
+ plan,
+ dryRun,
+ command,
+ packagePath,
+ targetName,
+}: {
+ plan: PlanResult;
+ dryRun: boolean;
+ /** The command which planned it, such as "ferric init". */
+ command: string;
+ packagePath: string;
+ /** What the package is being set up to build. */
+ targetName: string;
+}) {
+ for (const change of plan.changes) {
+ console.log(
+ `${dryRun ? chalk.dim("would ") : ""}${changeLabel(change)} ${prettyPath(change.path)}`,
+ );
+ // Creating writes a template verbatim, printing it would drown the diffs of
+ // the files being changed around it
+ if (change.kind === "update") {
+ printDiff(change.previous, change.contents);
+ }
+ }
+ for (const skip of plan.skips) {
+ console.log(
+ `${chalk.yellow("skip")} ${prettyPath(skip.path)} ${chalk.dim(`differs from what ${command} would write, merge this in by hand:`)}`,
+ );
+ printDiff(skip.previous, skip.contents);
+ if (skip.hint) {
+ console.log(`${chalk.yellow("ℹ︎")} ${skip.hint}`);
+ }
+ }
+ for (const notice of plan.notices) {
+ console.log(`${chalk.yellow("ℹ︎")} ${notice}`);
+ }
+ if (plan.skips.length > 0) {
+ console.log(
+ `${chalk.yellow("ℹ︎")} Pass ${chalk.blue("--force")} to overwrite the skipped entrypoints and source files`,
+ );
+ }
+ if (plan.changes.length === 0 && plan.skips.length === 0) {
+ console.log(
+ `${chalk.green("✔")} ${prettyPath(packagePath)} is already set up to build ${chalk.blue(targetName)}`,
+ );
+ } else if (dryRun) {
+ console.log(
+ `${chalk.dim("Nothing was written, drop")} ${chalk.blue("--dry-run")} ${chalk.dim("to apply the changes above")}`,
+ );
+ } else {
+ console.log(
+ `${chalk.green("✔")} Initialized ${chalk.blue(targetName)} in ${prettyPath(packagePath)}`,
+ );
+ }
+}
diff --git a/packages/cli-utils/src/json.test.ts b/packages/cli-utils/src/json.test.ts
new file mode 100644
index 00000000..a541031a
--- /dev/null
+++ b/packages/cli-utils/src/json.test.ts
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import { detectJsonIndentation } from "./json.js";
+
+describe("detectJsonIndentation", () => {
+ it("uses the indentation of the first indented line", () => {
+ assert.equal(detectJsonIndentation('{\n "a": 1\n}'), " ");
+ assert.equal(detectJsonIndentation('{\r\n\t"a": 1\r\n}'), "\t");
+ });
+
+ it("skips blank lines", () => {
+ assert.equal(detectJsonIndentation('{\n\n \n "a": 1\n}'), " ");
+ });
+
+ it("defaults to two spaces", () => {
+ assert.equal(detectJsonIndentation("{}"), " ");
+ });
+
+ it("stays fast on many blank lines", () => {
+ const start = performance.now();
+ detectJsonIndentation("\n ".repeat(100_000));
+ assert.ok(performance.now() - start < 1000);
+ });
+});
diff --git a/packages/ferric/src/json.ts b/packages/cli-utils/src/json.ts
similarity index 96%
rename from packages/ferric/src/json.ts
rename to packages/cli-utils/src/json.ts
index f4bf9e02..9f32a6bf 100644
--- a/packages/ferric/src/json.ts
+++ b/packages/cli-utils/src/json.ts
@@ -9,7 +9,7 @@ export function isRecord(value: unknown): value is Record {
* JSON file keep the style it was checked in with.
*/
export function detectJsonIndentation(contents: string) {
- const match = contents.match(/\n(\s+)\S/);
+ const match = contents.match(/\n([ \t]+)\S/);
return match ? match[1] : " ";
}
diff --git a/packages/ferric/src/workspaces.test.ts b/packages/cli-utils/src/workspaces.test.ts
similarity index 100%
rename from packages/ferric/src/workspaces.test.ts
rename to packages/cli-utils/src/workspaces.test.ts
diff --git a/packages/ferric/src/workspaces.ts b/packages/cli-utils/src/workspaces.ts
similarity index 100%
rename from packages/ferric/src/workspaces.ts
rename to packages/cli-utils/src/workspaces.ts
diff --git a/packages/cli-utils/tsconfig.json b/packages/cli-utils/tsconfig.json
index f183b9a9..95abca17 100644
--- a/packages/cli-utils/tsconfig.json
+++ b/packages/cli-utils/tsconfig.json
@@ -1,3 +1,10 @@
{
- "extends": "../../configs/tsconfig.node.json"
+ "compilerOptions": {
+ "composite": true
+ },
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.node-tests.json" }
+ ]
}
diff --git a/packages/cli-utils/tsconfig.node-tests.json b/packages/cli-utils/tsconfig.node-tests.json
new file mode 100644
index 00000000..cabcdca7
--- /dev/null
+++ b/packages/cli-utils/tsconfig.node-tests.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.node.json",
+ "compilerOptions": {
+ "composite": true,
+ "emitDeclarationOnly": true
+ },
+ "include": ["src/**/*.test.ts"],
+ "exclude": [],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/packages/cli-utils/tsconfig.node.json b/packages/cli-utils/tsconfig.node.json
new file mode 100644
index 00000000..f183b9a9
--- /dev/null
+++ b/packages/cli-utils/tsconfig.node.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../configs/tsconfig.node.json"
+}
diff --git a/packages/cmake-rn-example/.gitignore b/packages/cmake-rn-example/.gitignore
new file mode 100644
index 00000000..84c048a7
--- /dev/null
+++ b/packages/cmake-rn-example/.gitignore
@@ -0,0 +1 @@
+/build/
diff --git a/packages/cmake-rn-example/CMakeLists.txt b/packages/cmake-rn-example/CMakeLists.txt
new file mode 100644
index 00000000..869e51b6
--- /dev/null
+++ b/packages/cmake-rn-example/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.15...3.31)
+project(cmake-rn-example)
+
+# Defines the "weak-node-api" target, passed in by cmake-rn
+include(${WEAK_NODE_API_CONFIG})
+
+add_library(cmake-rn-example SHARED src/addon.c)
+target_link_libraries(cmake-rn-example PRIVATE weak-node-api)
+set_target_properties(cmake-rn-example PROPERTIES PREFIX "" SUFFIX .node)
diff --git a/packages/cmake-rn-example/cmake-rn-example.d.ts b/packages/cmake-rn-example/cmake-rn-example.d.ts
new file mode 100644
index 00000000..3f529c12
--- /dev/null
+++ b/packages/cmake-rn-example/cmake-rn-example.d.ts
@@ -0,0 +1 @@
+export declare function sum(a: number, b: number): number;
diff --git a/packages/cmake-rn-example/cmake-rn-example.js b/packages/cmake-rn-example/cmake-rn-example.js
new file mode 100644
index 00000000..8b7e03d2
--- /dev/null
+++ b/packages/cmake-rn-example/cmake-rn-example.js
@@ -0,0 +1 @@
+module.exports = require("./build/Release/cmake-rn-example.node");
diff --git a/packages/cmake-rn-example/package.json b/packages/cmake-rn-example/package.json
new file mode 100644
index 00000000..758d2f7a
--- /dev/null
+++ b/packages/cmake-rn-example/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@react-native-node-api/cmake-rn-example",
+ "version": "0.1.0",
+ "private": true,
+ "homepage": "https://github.com/callstackincubator/react-native-node-api",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/callstackincubator/react-native-node-api.git",
+ "directory": "packages/cmake-rn-example"
+ },
+ "type": "commonjs",
+ "main": "cmake-rn-example.js",
+ "types": "cmake-rn-example.d.ts",
+ "files": [
+ "cmake-rn-example.js",
+ "cmake-rn-example.d.ts",
+ "build/Release/cmake-rn-example.apple.node",
+ "build/Release/cmake-rn-example.android.node"
+ ],
+ "scripts": {
+ "build": "cmake-rn",
+ "bootstrap": "node --run build"
+ },
+ "dependencies": {
+ "react-native-node-api": "workspace:*"
+ },
+ "devDependencies": {
+ "cmake-rn": "workspace:*"
+ }
+}
diff --git a/packages/cmake-rn-example/src/addon.c b/packages/cmake-rn-example/src/addon.c
new file mode 100644
index 00000000..3cd224b5
--- /dev/null
+++ b/packages/cmake-rn-example/src/addon.c
@@ -0,0 +1,25 @@
+#include
+
+static napi_value Sum(napi_env env, napi_callback_info info) {
+ size_t argc = 2;
+ napi_value args[2];
+ napi_get_cb_info(env, info, &argc, args, NULL, NULL);
+
+ double a, b;
+ if (napi_get_value_double(env, args[0], &a) != napi_ok ||
+ napi_get_value_double(env, args[1], &b) != napi_ok) {
+ napi_throw_type_error(env, NULL, "Expected two numbers");
+ return NULL;
+ }
+
+ napi_value result;
+ napi_create_double(env, a + b, &result);
+ return result;
+}
+
+NAPI_MODULE_INIT() {
+ napi_value sum;
+ napi_create_function(env, "sum", NAPI_AUTO_LENGTH, Sum, NULL, &sum);
+ napi_set_named_property(env, exports, "sum", sum);
+ return exports;
+}
diff --git a/packages/cmake-rn/README.md b/packages/cmake-rn/README.md
index 61772ce2..d3584a29 100644
--- a/packages/cmake-rn/README.md
+++ b/packages/cmake-rn/README.md
@@ -4,6 +4,52 @@ A wrapper around Cmake making it easier to produce prebuilt binaries targeting i
Serves the same purpose as `cmake-js` does for the Node.js community and could potentially be upstreamed into `cmake-js` eventually.
+## Commands
+
+`cmake-rn` (or `cmake-rn build`) configures and builds the CMake project in the current directory for the requested triplets. Run `cmake-rn build --help` for its options.
+
+## `cmake-rn init [path]`
+
+Scaffold a package building a CMake Node-API module, or bring an existing package
+up to what `cmake-rn build` expects of it:
+
+```
+npx cmake-rn init packages/my-addon
+```
+
+The command creates whatever is missing — `package.json`, `CMakeLists.txt`,
+`src/addon.c`, the `my-addon.js` entrypoint loading the addon, its
+`my-addon.d.ts` declarations and `.gitignore` — and adds the package to the
+`pnpm-workspace.yaml` or `"workspaces"` of the mono-repo it is initialized into.
+
+Running it against a package which already exists updates rather than overwrites:
+
+- `package.json` gains the scripts, dependencies and prebuilt artifacts it lacks, and outdated dependencies are bumped.
+- An existing `CMakeLists.txt` is left alone. The command points out when it doesn't get Node-API the way `--weak-node-api` asks for, or doesn't build the library the entrypoint loads.
+- A `main`, `types`, entrypoint or `src/addon.c` that differs is skipped, printing a diff to merge by hand. Pass `--force` to overwrite them, along with an existing `CMakeLists.txt`.
+
+A new `CMakeLists.txt` gets Node-API in the way `--weak-node-api ` asks for:
+
+- `link` (the default): links the `weak-node-api` target, defined by `include(${WEAK_NODE_API_CONFIG})`.
+- `cmake-js`: uses the `CMAKE_JS_*` variables instead, so the project builds with both `cmake-js` (for Node.js) and `cmake-rn --cmake-js`, which the `build` script passes.
+- `omit`: leaves Node-API out of the project for you to provide, which it won't build without.
+
+The mode also decides what is pointed out about an existing `CMakeLists.txt`.
+
+The name of the CMake target and the library it builds defaults to the package
+name without its npm scope (`@my-org/my-addon` becomes `my-addon`) and can be set
+with `--name`. The entrypoint loads the library from `build/Release`, where
+`cmake-rn build` emits a `Release` build by default.
+
+Every file it updates is printed as a diff, and `--dry-run` prints the whole
+plan without writing anything:
+
+```
+npx cmake-rn init packages/my-addon --dry-run
+```
+
+The [`cmake-rn-example`](../cmake-rn-example) package is generated by this command: run `pnpm run init-cmake-rn-example` from the root of the repository after changing the templates, which CI asserts leaves no diff.
+
## Linking against Node-API
Android's dynamic linker imposes restrictions on the access to global symbols (such as the Node-API free functions): A dynamic library must explicitly declare any dependency bringing symbols it needs as `DT_NEEDED`.
diff --git a/packages/cmake-rn/package.json b/packages/cmake-rn/package.json
index b705ecb9..9773dbad 100644
--- a/packages/cmake-rn/package.json
+++ b/packages/cmake-rn/package.json
@@ -16,7 +16,8 @@
"bin",
"dist",
"!dist/**/*.test.d.ts",
- "!dist/**/*.test.d.ts.map"
+ "!dist/**/*.test.d.ts.map",
+ "templates"
],
"scripts": {
"build": "tsc",
diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts
index be687979..41a362a9 100644
--- a/packages/cmake-rn/src/cli.ts
+++ b/packages/cmake-rn/src/cli.ts
@@ -24,6 +24,7 @@ import {
import { Platform } from "./platforms/types.js";
import { getCcachePath } from "./ccache.js";
import { createOutputPathResolver, expandTemplate } from "./output-path.js";
+import { initCommand } from "./init.js";
const verboseOption = new Option(
"--verbose",
@@ -145,7 +146,7 @@ const concurrencyOption = new Option(
`${os.availableParallelism()} or 1 when --verbose is enabled`,
);
-let program = new Command("cmake-rn")
+let buildCommand = new Command("build")
.description("Build React Native Node API modules with CMake")
.addOption(tripletOption)
.addOption(verboseOption)
@@ -168,11 +169,11 @@ for (const platform of platforms) {
`--${platform.id}`,
`Enable all ${platform.name} triplets`,
);
- program = program.addOption(allOption);
- program = platform.amendCommand(program);
+ buildCommand = buildCommand.addOption(allOption);
+ buildCommand = platform.amendCommand(buildCommand);
}
-program = program.action(
+buildCommand = buildCommand.action(
wrapAction(async ({ triplet: requestedTriplets, ...baseOptions }) => {
baseOptions.build = path.resolve(
process.cwd(),
@@ -375,4 +376,9 @@ function getTripletsSummary(
.join(" / ");
}
-export { program };
+const program = new Command("cmake-rn")
+ .description("Build React Native Node API modules with CMake")
+ .addCommand(buildCommand, { isDefault: true })
+ .addCommand(initCommand);
+
+export { buildCommand, program };
diff --git a/packages/cmake-rn/src/init.test.ts b/packages/cmake-rn/src/init.test.ts
new file mode 100644
index 00000000..4ad28915
--- /dev/null
+++ b/packages/cmake-rn/src/init.test.ts
@@ -0,0 +1,375 @@
+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 {
+ applyChanges,
+ parseJsonObject,
+} from "@react-native-node-api/cli-utils";
+
+import { inspectCmakeProject, planInit, type InitPlan } from "./init.js";
+
+const EXAMPLE_PATH = path.resolve(
+ import.meta.dirname,
+ "..",
+ "..",
+ "cmake-rn-example",
+);
+
+interface FileMap {
+ [filePath: string]: string;
+}
+
+function setupTempDirectory(context: TestContext, files: FileMap = {}) {
+ const tempPath = fs.realpathSync(
+ fs.mkdtempSync(path.join(os.tmpdir(), "cmake-rn-init-test-")),
+ );
+ context.after(() => {
+ if (!process.env.KEEP_TEMP_DIRS) {
+ fs.rmSync(tempPath, { recursive: true, force: true });
+ }
+ });
+ for (const [filePath, contents] of Object.entries(files)) {
+ const fullPath = path.join(tempPath, filePath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, contents, "utf8");
+ }
+ return tempPath;
+}
+
+/** Templates are checked out with CRLF line endings on Windows. */
+function readText(filePath: string) {
+ return fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
+}
+
+function readPackageJson(packagePath: string) {
+ return parseJsonObject(
+ fs.readFileSync(path.join(packagePath, "package.json"), "utf8"),
+ );
+}
+
+function relativePaths(
+ entries: { path: string }[],
+ packagePath: string,
+): string[] {
+ return entries
+ .map(({ path: filePath }) => path.relative(packagePath, filePath))
+ .map((filePath) => filePath.split(path.sep).join("/"))
+ .sort();
+}
+
+function changedPaths(plan: InitPlan, packagePath: string) {
+ return relativePaths(plan.changes, packagePath);
+}
+
+const LINKED_CMAKE_LISTS = [
+ "cmake_minimum_required(VERSION 3.15)",
+ "project(mine)",
+ "include(${WEAK_NODE_API_CONFIG})",
+ "add_library(my-addon SHARED addon.cpp)",
+ "target_link_libraries(my-addon PRIVATE weak-node-api)",
+ "",
+].join("\n");
+
+describe("planInit", () => {
+ it("scaffolds a package from scratch", async (context) => {
+ const tempPath = setupTempDirectory(context);
+ const packagePath = path.join(tempPath, "my-addon");
+ const plan = await planInit({ packagePath });
+ await applyChanges(plan.changes);
+
+ assert.equal(plan.libraryName, "my-addon");
+ assert.deepEqual(changedPaths(plan, packagePath), [
+ ".gitignore",
+ "CMakeLists.txt",
+ "my-addon.d.ts",
+ "my-addon.js",
+ "package.json",
+ "src/addon.c",
+ ]);
+ assert.deepEqual(plan.skips, []);
+ assert.deepEqual(plan.notices, []);
+
+ const packageJson = readPackageJson(packagePath);
+ assert.equal(packageJson["main"], "my-addon.js");
+ assert.equal(packageJson["types"], "my-addon.d.ts");
+ assert.deepEqual(packageJson["scripts"], { build: "cmake-rn" });
+ assert.deepEqual(packageJson["files"], [
+ "my-addon.js",
+ "my-addon.d.ts",
+ "build/Release/my-addon.apple.node",
+ "build/Release/my-addon.android.node",
+ ]);
+
+ const cmakeLists = readText(path.join(packagePath, "CMakeLists.txt"));
+ assert.match(cmakeLists, /^project\(my-addon\)$/m);
+ assert.match(cmakeLists, /include\(\$\{WEAK_NODE_API_CONFIG\}\)/);
+ assert.match(
+ cmakeLists,
+ /target_link_libraries\(my-addon PRIVATE weak-node-api\)/,
+ );
+ assert.deepEqual(inspectCmakeProject(cmakeLists, "my-addon"), []);
+ assert.equal(
+ readText(path.join(packagePath, "my-addon.js")),
+ 'module.exports = require("./build/Release/my-addon.node");\n',
+ );
+ });
+
+ it("is a no-op when run again", async (context) => {
+ const packagePath = setupTempDirectory(context);
+ await applyChanges((await planInit({ packagePath })).changes);
+ const plan = await planInit({ packagePath });
+ assert.deepEqual(plan.changes, []);
+ assert.deepEqual(plan.skips, []);
+ assert.deepEqual(plan.notices, []);
+ });
+
+ it("writes nothing while planning", async (context) => {
+ const packagePath = setupTempDirectory(context);
+ await planInit({ packagePath });
+ assert.deepEqual(fs.readdirSync(packagePath), []);
+ });
+
+ it("derives the library name from a scoped package name", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "package.json": JSON.stringify({ name: "@my-org/my-addon" }),
+ });
+ const plan = await planInit({ packagePath });
+ assert.equal(plan.packageName, "@my-org/my-addon");
+ assert.equal(plan.libraryName, "my-addon");
+ });
+
+ it("uses the library name passed by the caller", async (context) => {
+ const packagePath = setupTempDirectory(context);
+ const plan = await planInit({ packagePath, libraryName: "custom" });
+ assert.ok(changedPaths(plan, packagePath).includes("custom.js"));
+ });
+
+ it("rejects a library name it cannot use", async (context) => {
+ const packagePath = setupTempDirectory(context);
+ await assert.rejects(planInit({ packagePath, libraryName: "1 addon" }));
+ });
+
+ it("keeps the values an existing package declares", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "package.json": JSON.stringify(
+ {
+ name: "my-addon",
+ version: "1.2.3",
+ scripts: { build: "cmake-rn --strip", test: "node --test" },
+ dependencies: { "react-native-node-api": "workspace:*" },
+ },
+ null,
+ 4,
+ ),
+ });
+ const plan = await planInit({ packagePath });
+ await applyChanges(plan.changes);
+
+ const contents = fs.readFileSync(
+ path.join(packagePath, "package.json"),
+ "utf8",
+ );
+ assert.match(contents, /^ {4}"name"/m, "Expected indentation to be kept");
+ const packageJson = parseJsonObject(contents);
+ assert.equal(packageJson["version"], "1.2.3");
+ assert.deepEqual(packageJson["scripts"], {
+ build: "cmake-rn --strip",
+ test: "node --test",
+ });
+ assert.equal(packageJson["files"], undefined);
+ assert.deepEqual(packageJson["dependencies"], {
+ "react-native-node-api": "workspace:*",
+ });
+ });
+
+ it("inspects an existing CMake project instead of replacing it", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "package.json": JSON.stringify({ name: "my-addon" }),
+ "CMakeLists.txt": LINKED_CMAKE_LISTS,
+ });
+ const plan = await planInit({ packagePath });
+ assert.deepEqual(changedPaths(plan, packagePath), [
+ ".gitignore",
+ "my-addon.d.ts",
+ "my-addon.js",
+ "package.json",
+ ]);
+ assert.deepEqual(plan.skips, []);
+ assert.deepEqual(plan.notices, []);
+ });
+
+ it("overwrites the CMake project when forced", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "CMakeLists.txt": LINKED_CMAKE_LISTS,
+ });
+ const plan = await planInit({ packagePath, force: true });
+ assert.ok(
+ plan.changes.some(
+ (change) =>
+ change.kind === "update" &&
+ change.forced &&
+ change.path.endsWith("CMakeLists.txt"),
+ ),
+ );
+ assert.ok(changedPaths(plan, packagePath).includes("src/addon.c"));
+ });
+
+ it("skips differing entrypoints and source files", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "package.json": JSON.stringify({ name: "my-addon", main: "index.js" }),
+ "my-addon.js": "// mine\n",
+ "src/addon.c": "// mine\n",
+ });
+ const plan = await planInit({ packagePath });
+ assert.deepEqual(relativePaths(plan.skips, packagePath), [
+ "my-addon.js",
+ "package.json",
+ "src/addon.c",
+ ]);
+
+ const forced = await planInit({ packagePath, force: true });
+ assert.deepEqual(forced.skips, []);
+ await applyChanges(forced.changes);
+ assert.equal(readPackageJson(packagePath)["main"], "my-addon.js");
+ });
+
+ it("adds the package to an npm workspace", async (context) => {
+ const tempPath = setupTempDirectory(context, {
+ "package.json": JSON.stringify({ workspaces: ["apps/*"] }),
+ });
+ const packagePath = path.join(tempPath, "packages", "my-addon");
+ const plan = await planInit({ packagePath });
+ await applyChanges(plan.changes);
+ assert.deepEqual(readPackageJson(tempPath)["workspaces"], [
+ "apps/*",
+ "packages/my-addon",
+ ]);
+ });
+
+ it("writes a cmake-js project building with --cmake-js", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "package.json": JSON.stringify({ name: "my-addon" }),
+ });
+ const plan = await planInit({ packagePath, weakNodeApi: "cmake-js" });
+ await applyChanges(plan.changes);
+
+ assert.deepEqual(plan.notices, []);
+ assert.deepEqual(readPackageJson(packagePath)["scripts"], {
+ build: "cmake-rn --cmake-js",
+ });
+ const cmakeLists = readText(path.join(packagePath, "CMakeLists.txt"));
+ assert.match(cmakeLists, /\$\{CMAKE_JS_INC\}/);
+ assert.match(cmakeLists, /\$\{CMAKE_JS_LIB\}/);
+ assert.doesNotMatch(cmakeLists, /weak-node-api|WEAK_NODE_API/);
+ assert.deepEqual(
+ inspectCmakeProject(cmakeLists, "my-addon", "cmake-js"),
+ [],
+ );
+
+ const rerun = await planInit({ packagePath, weakNodeApi: "cmake-js" });
+ assert.deepEqual(rerun.changes, []);
+ assert.deepEqual(rerun.notices, []);
+ });
+
+ it("omits Node-API from the project when asked to", async (context) => {
+ const packagePath = setupTempDirectory(context, {
+ "package.json": JSON.stringify({ name: "my-addon" }),
+ });
+ const plan = await planInit({ packagePath, weakNodeApi: "omit" });
+ await applyChanges(plan.changes);
+
+ assert.equal(plan.notices.length, 1);
+ assert.match(plan.notices[0], /doesn't link against Node-API/);
+ assert.deepEqual(readPackageJson(packagePath)["scripts"], {
+ build: "cmake-rn",
+ });
+ const cmakeLists = readText(path.join(packagePath, "CMakeLists.txt"));
+ assert.doesNotMatch(cmakeLists, /weak-node-api|WEAK_NODE_API|CMAKE_JS/);
+
+ const rerun = await planInit({ packagePath, weakNodeApi: "omit" });
+ assert.deepEqual(rerun.changes, []);
+ assert.deepEqual(rerun.notices, []);
+ });
+
+ it("has nothing to change about the cmake-rn-example package", async () => {
+ const plan = await planInit({ packagePath: EXAMPLE_PATH });
+ assert.deepEqual(changedPaths(plan, EXAMPLE_PATH), []);
+ assert.deepEqual(plan.skips, []);
+ assert.deepEqual(plan.notices, []);
+ });
+});
+
+describe("inspectCmakeProject", () => {
+ it("accepts a project linking weak-node-api", () => {
+ assert.deepEqual(inspectCmakeProject(LINKED_CMAKE_LISTS, "my-addon"), []);
+ assert.deepEqual(
+ inspectCmakeProject(
+ [
+ "find_package(weak-node-api REQUIRED CONFIG)",
+ "add_library(my-addon SHARED addon.c)",
+ "target_link_libraries(my-addon",
+ " PRIVATE",
+ " weak-node-api",
+ ")",
+ ].join("\n"),
+ "my-addon",
+ ),
+ [],
+ );
+ });
+
+ it("notices a project not importing or linking weak-node-api", () => {
+ const notices = inspectCmakeProject(
+ [
+ "# include(${WEAK_NODE_API_CONFIG})",
+ "add_library(my-addon SHARED addon.c)",
+ ].join("\n"),
+ "my-addon",
+ );
+ assert.equal(notices.length, 2);
+ assert.match(notices[0], /WEAK_NODE_API_CONFIG/);
+ assert.match(notices[1], /target_link_libraries/);
+ });
+
+ it("points a cmake-js project at --cmake-js", () => {
+ const notices = inspectCmakeProject(
+ [
+ "add_library(my-addon SHARED addon.c ${CMAKE_JS_SRC})",
+ "target_link_libraries(my-addon ${CMAKE_JS_LIB})",
+ ].join("\n"),
+ "my-addon",
+ );
+ assert.equal(notices.length, 1);
+ assert.match(notices[0], /--cmake-js/);
+ });
+
+ it("expects the cmake-js variables in cmake-js mode", () => {
+ const notices = inspectCmakeProject(
+ LINKED_CMAKE_LISTS,
+ "my-addon",
+ "cmake-js",
+ );
+ assert.equal(notices.length, 1);
+ assert.match(notices[0], /CMAKE_JS_INC/);
+ });
+
+ it("leaves Node-API to the author in omit mode", () => {
+ assert.deepEqual(
+ inspectCmakeProject(
+ "add_library(my-addon SHARED addon.c)",
+ "my-addon",
+ "omit",
+ ),
+ [],
+ );
+ });
+
+ it("notices a project not building the library", () => {
+ const notices = inspectCmakeProject(LINKED_CMAKE_LISTS, "other-addon");
+ assert.equal(notices.length, 1);
+ assert.match(notices[0], /--name/);
+ });
+});
diff --git a/packages/cmake-rn/src/init.ts b/packages/cmake-rn/src/init.ts
new file mode 100644
index 00000000..c3263997
--- /dev/null
+++ b/packages/cmake-rn/src/init.ts
@@ -0,0 +1,388 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import {
+ chalk,
+ Command,
+ Option,
+ applyChanges,
+ assertDistinctChanges,
+ assertFixable,
+ determineDependencySpecifiers,
+ findWorkspace,
+ mergeStepResults,
+ planGitignoreLines,
+ planPackageJsonUpdates,
+ planTemplateFile,
+ planWorkspace,
+ printPlan,
+ readFileIfExists,
+ readPackage,
+ stripPackageScope,
+ withDependency,
+ withFiles,
+ withMissing,
+ withMissingScripts,
+ wrapAction,
+ type Change,
+ type PackageJson,
+ type Skip,
+ type StepResult,
+ type Workspace,
+} from "@react-native-node-api/cli-utils";
+
+const PACKAGE_ROOT = path.join(import.meta.dirname, "..");
+const TEMPLATES_PATH = path.join(PACKAGE_ROOT, "templates");
+
+/** Where `cmake-rn build` emits a target's prebuilds by default. */
+const OUTPUT_DIRECTORY = "build/Release";
+
+export const WEAK_NODE_API_MODES = ["link", "cmake-js", "omit"] as const;
+
+/**
+ * How a new CMake project gets Node-API: linking the weak-node-api target,
+ * through the cmake-js variables, or left to the author.
+ */
+export type WeakNodeApiMode = (typeof WEAK_NODE_API_MODES)[number];
+
+export type InitPlan = {
+ packagePath: string;
+ packageName: string;
+ libraryName: string;
+ changes: Change[];
+ skips: Skip[];
+ notices: string[];
+};
+
+export type InitOptions = {
+ /** Directory of the package to initialize. */
+ packagePath: string;
+ /** Name of the CMake target and library, derived from the package name when omitted. */
+ libraryName?: string;
+ /** How a new CMake project gets Node-API, defaulting to "link". */
+ weakNodeApi?: WeakNodeApiMode;
+ /** Overwrite entrypoints, the CMake project and source files instead of skipping them. */
+ force?: boolean;
+};
+
+type InitContext = {
+ packagePath: string;
+ packageName: string;
+ libraryName: string;
+ /** The package.json of an existing package, as read from disk. */
+ existingPackageJson: string | undefined;
+ packageJson: PackageJson | undefined;
+ workspace: Workspace | undefined;
+ weakNodeApi: WeakNodeApiMode;
+ force: boolean;
+};
+
+/** Plans a part of the initialization, without writing anything. */
+type InitStep = (context: InitContext) => StepResult | Promise;
+
+/**
+ * Determine the changes needed to make a package build a CMake Node-API
+ * module, without applying any of them.
+ */
+export async function planInit({
+ packagePath,
+ libraryName: libraryNameOption,
+ weakNodeApi = "link",
+ force = false,
+}: InitOptions): Promise {
+ const resolvedPath = path.resolve(packagePath);
+ const { existingPackageJson, packageJson, packageName } =
+ await readPackage(resolvedPath);
+ const libraryName = libraryNameOption ?? stripPackageScope(packageName);
+ assertFixable(
+ /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(libraryName),
+ `Cannot use ${chalk.blue(libraryName)} as a library name`,
+ {
+ instructions:
+ "Pass --name with a name starting with a letter, followed by letters, digits, dashes or underscores",
+ },
+ );
+
+ const context: InitContext = {
+ packagePath: resolvedPath,
+ packageName,
+ libraryName,
+ existingPackageJson,
+ packageJson,
+ workspace: findWorkspace(resolvedPath),
+ weakNodeApi,
+ force,
+ };
+ const { changes, skips, notices } = mergeStepResults(
+ await Promise.all(INIT_STEPS.map(async (step) => step(context))),
+ );
+ assertDistinctChanges(changes);
+
+ return {
+ packagePath: resolvedPath,
+ packageName,
+ libraryName,
+ changes,
+ skips,
+ notices,
+ };
+}
+
+const planPackageJson: InitStep = (context) =>
+ planPackageJsonUpdates({
+ packagePath: context.packagePath,
+ packageJson: context.packageJson,
+ existingPackageJson: context.existingPackageJson,
+ updates: getPackageJsonUpdates(context),
+ entrypoints: getEntrypoints(context.libraryName),
+ force: context.force,
+ });
+
+const planEntrypoints: InitStep = async ({
+ packagePath,
+ libraryName,
+ force,
+}) => {
+ const { main, types } = getEntrypoints(libraryName);
+ return mergeStepResults(
+ await Promise.all([
+ planTemplateFile({
+ outputPath: path.join(packagePath, main),
+ contents: await readTemplate("entrypoint.js.template", { libraryName }),
+ force,
+ }),
+ planTemplateFile({
+ outputPath: path.join(packagePath, types),
+ contents: await readTemplate("entrypoint.d.ts.template", {
+ libraryName,
+ }),
+ force,
+ }),
+ ]),
+ );
+};
+
+/**
+ * An existing CMake project is the package's own: rather than diffing it with
+ * the template, it's inspected for what `cmake-rn build` needs from it. The
+ * source file is only written along with the project that builds it.
+ */
+const planCmakeProject: InitStep = async ({
+ packagePath,
+ libraryName,
+ weakNodeApi,
+ force,
+}) => {
+ const cmakeListsPath = path.join(packagePath, "CMakeLists.txt");
+ const nodeApi = await readTemplate(`node-api-${weakNodeApi}.cmake`, {
+ libraryName,
+ });
+ const contents = (
+ await readTemplate("CMakeLists.txt", { libraryName })
+ ).replace(/\{\{nodeApi\}\}\r?\n/, () => nodeApi);
+ const previous = await readFileIfExists(cmakeListsPath);
+ if (previous !== undefined && previous !== contents && !force) {
+ return {
+ notices: inspectCmakeProject(previous, libraryName, weakNodeApi),
+ };
+ }
+ const result = mergeStepResults(
+ await Promise.all([
+ planTemplateFile({ outputPath: cmakeListsPath, contents, force }),
+ planTemplateFile({
+ outputPath: path.join(packagePath, "src", "addon.c"),
+ contents: await readTemplate("addon.c", { libraryName }),
+ force,
+ }),
+ ]),
+ );
+ const writesCmakeLists = result.changes.some(
+ ({ path: filePath }) => filePath === cmakeListsPath,
+ );
+ if (weakNodeApi === "omit" && writesCmakeLists) {
+ result.notices.push(
+ `${chalk.blue("CMakeLists.txt")} doesn't link against Node-API: provide its headers and symbols to the ${chalk.blue(libraryName)} target, for example with ${chalk.blue("include(${WEAK_NODE_API_CONFIG})")} and the ${chalk.blue("weak-node-api")} target`,
+ );
+ }
+ return result;
+};
+
+const planGitignore: InitStep = async ({ packagePath, libraryName }) =>
+ planGitignoreLines({
+ packagePath,
+ // The template is named without a leading dot, as npm won't publish a
+ // .gitignore file inside a package
+ template: await readTemplate("gitignore", { libraryName }),
+ });
+
+const INIT_STEPS: InitStep[] = [
+ planPackageJson,
+ planEntrypoints,
+ planCmakeProject,
+ planGitignore,
+ planWorkspace,
+];
+
+/**
+ * Point out what an existing CMakeLists.txt lacks to build a Node-API module
+ * with `cmake-rn build`, getting Node-API the way the mode asks for.
+ */
+export function inspectCmakeProject(
+ contents: string,
+ libraryName: string,
+ weakNodeApi: WeakNodeApiMode = "link",
+) {
+ const code = contents.replace(/#.*$/gm, "");
+ const cmakeListsName = chalk.blue("CMakeLists.txt");
+ const notices: string[] = [];
+ const usesCmakeJs = /\bCMAKE_JS_(INC|LIB|SRC)\b/.test(code);
+ const importsWeakNodeApi =
+ /\$\{WEAK_NODE_API_CONFIG\}/.test(code) ||
+ /find_package\s*\(\s*weak-node-api\b/.test(code);
+ const linksWeakNodeApi =
+ /target_link_libraries\s*\([^)]*\bweak-node-api\b/.test(code);
+ if (weakNodeApi === "cmake-js") {
+ if (!usesCmakeJs) {
+ notices.push(
+ `${cmakeListsName} doesn't use the cmake-js variables: add ${chalk.blue("${CMAKE_JS_INC}")} to the include directories and ${chalk.blue("${CMAKE_JS_LIB}")} to the ${chalk.blue("target_link_libraries")} of the addon`,
+ );
+ }
+ } else if (weakNodeApi === "omit") {
+ // Left to the author
+ } else if (usesCmakeJs && !importsWeakNodeApi) {
+ notices.push(
+ `${cmakeListsName} uses the cmake-js variables: pass ${chalk.blue("--cmake-js")} when building, or link the ${chalk.blue("weak-node-api")} target instead`,
+ );
+ } else {
+ if (!importsWeakNodeApi) {
+ notices.push(
+ `${cmakeListsName} doesn't define the ${chalk.blue("weak-node-api")} target: add ${chalk.blue("include(${WEAK_NODE_API_CONFIG})")}`,
+ );
+ }
+ if (!linksWeakNodeApi) {
+ notices.push(
+ `${cmakeListsName} doesn't link against Node-API: add ${chalk.blue("weak-node-api")} to the ${chalk.blue("target_link_libraries")} of the addon`,
+ );
+ }
+ }
+ if (!code.includes(libraryName)) {
+ notices.push(
+ `${cmakeListsName} doesn't mention ${chalk.blue(libraryName)}, the library the entrypoint loads: pass ${chalk.blue("--name")} with the name of the addon's ${chalk.blue(".node")} file`,
+ );
+ }
+ return notices;
+}
+
+function getEntrypoints(libraryName: string) {
+ return { main: `${libraryName}.js`, types: `${libraryName}.d.ts` };
+}
+
+/**
+ * What a package needs to build a CMake Node-API module, leaving any value it
+ * already declares alone besides outdated dependencies.
+ */
+function getPackageJsonUpdates({
+ packageJson,
+ packageName,
+ libraryName,
+ workspace,
+ weakNodeApi,
+}: InitContext) {
+ const { hostSpecifier, cliSpecifier } = determineDependencySpecifiers({
+ cliPackagePath: PACKAGE_ROOT,
+ workspace,
+ });
+ return [
+ withMissing({
+ name: packageName,
+ version: "0.1.0",
+ type: "commonjs",
+ ...getEntrypoints(libraryName),
+ }),
+ withFiles(
+ [
+ `${libraryName}.js`,
+ `${libraryName}.d.ts`,
+ `${OUTPUT_DIRECTORY}/${libraryName}.apple.node`,
+ `${OUTPUT_DIRECTORY}/${libraryName}.android.node`,
+ ],
+ packageJson === undefined,
+ ),
+ withMissingScripts({
+ build: weakNodeApi === "cmake-js" ? "cmake-rn --cmake-js" : "cmake-rn",
+ }),
+ withDependency({
+ name: "react-native-node-api",
+ specifier: hostSpecifier,
+ keys: ["dependencies", "devDependencies", "peerDependencies"],
+ }),
+ withDependency({
+ name: "cmake-rn",
+ specifier: cliSpecifier,
+ keys: ["devDependencies", "dependencies", "peerDependencies"],
+ }),
+ ];
+}
+
+async function readTemplate(
+ filename: string,
+ { libraryName }: { libraryName: string },
+) {
+ const template = await fs.promises.readFile(
+ path.join(TEMPLATES_PATH, filename),
+ "utf8",
+ );
+ return template.replaceAll("{{name}}", libraryName);
+}
+
+const nameOption = new Option(
+ "--name ",
+ "Name of the CMake target and the library it builds",
+).default(undefined, "derived from the package name");
+
+const dryRunOption = new Option(
+ "--dry-run",
+ "Print the changes instead of applying them",
+).default(false);
+
+const weakNodeApiOption = new Option(
+ "--weak-node-api ",
+ "How a new CMakeLists.txt gets Node-API: link the weak-node-api target, use the cmake-js variables (building with both cmake-js and cmake-rn --cmake-js) or omit it",
+)
+ .choices(WEAK_NODE_API_MODES)
+ .default("link" satisfies WeakNodeApiMode);
+
+const forceOption = new Option(
+ "--force",
+ "Overwrite entrypoints, the CMake project and source files which differ, instead of skipping them",
+).default(false);
+
+export const initCommand = new Command("init")
+ .description("Scaffold a package building a CMake Node-API module")
+ .argument("[path]", "Path of the package directory", ".")
+ .addOption(nameOption)
+ .addOption(dryRunOption)
+ .addOption(weakNodeApiOption)
+ .addOption(forceOption)
+ .action(
+ wrapAction(
+ async (packagePath: string, { name, dryRun, weakNodeApi, force }) => {
+ const plan = await planInit({
+ packagePath,
+ libraryName: name,
+ weakNodeApi,
+ force,
+ });
+ if (!dryRun) {
+ await applyChanges(plan.changes);
+ }
+ printPlan({
+ plan,
+ dryRun,
+ command: "cmake-rn init",
+ packagePath: plan.packagePath,
+ targetName: plan.libraryName,
+ });
+ },
+ ),
+ );
diff --git a/packages/cmake-rn/src/platforms/types.ts b/packages/cmake-rn/src/platforms/types.ts
index 98e68f2a..b4538ec2 100644
--- a/packages/cmake-rn/src/platforms/types.ts
+++ b/packages/cmake-rn/src/platforms/types.ts
@@ -1,19 +1,19 @@
import * as cli from "@react-native-node-api/cli-utils";
-import type { program } from "../cli.js";
+import type { buildCommand } from "../cli.js";
type InferOptionValues = ReturnType<
Command["opts"]
>;
-type BaseCommand = typeof program;
+type BaseCommand = typeof buildCommand;
type ExtendedCommand = cli.Command<
[],
Opts & InferOptionValues,
Record // Global opts are not supported
>;
-export type BaseOpts = Omit, "triplet">;
+export type BaseOpts = Omit, "triplet">;
export type TripletContext = {
triplet: Triplet;
diff --git a/packages/cmake-rn/templates/CMakeLists.txt b/packages/cmake-rn/templates/CMakeLists.txt
new file mode 100644
index 00000000..c2bdc319
--- /dev/null
+++ b/packages/cmake-rn/templates/CMakeLists.txt
@@ -0,0 +1,5 @@
+cmake_minimum_required(VERSION 3.15...3.31)
+project({{name}})
+
+{{nodeApi}}
+set_target_properties({{name}} PROPERTIES PREFIX "" SUFFIX .node)
diff --git a/packages/cmake-rn/templates/addon.c b/packages/cmake-rn/templates/addon.c
new file mode 100644
index 00000000..3cd224b5
--- /dev/null
+++ b/packages/cmake-rn/templates/addon.c
@@ -0,0 +1,25 @@
+#include
+
+static napi_value Sum(napi_env env, napi_callback_info info) {
+ size_t argc = 2;
+ napi_value args[2];
+ napi_get_cb_info(env, info, &argc, args, NULL, NULL);
+
+ double a, b;
+ if (napi_get_value_double(env, args[0], &a) != napi_ok ||
+ napi_get_value_double(env, args[1], &b) != napi_ok) {
+ napi_throw_type_error(env, NULL, "Expected two numbers");
+ return NULL;
+ }
+
+ napi_value result;
+ napi_create_double(env, a + b, &result);
+ return result;
+}
+
+NAPI_MODULE_INIT() {
+ napi_value sum;
+ napi_create_function(env, "sum", NAPI_AUTO_LENGTH, Sum, NULL, &sum);
+ napi_set_named_property(env, exports, "sum", sum);
+ return exports;
+}
diff --git a/packages/cmake-rn/templates/entrypoint.d.ts.template b/packages/cmake-rn/templates/entrypoint.d.ts.template
new file mode 100644
index 00000000..3f529c12
--- /dev/null
+++ b/packages/cmake-rn/templates/entrypoint.d.ts.template
@@ -0,0 +1 @@
+export declare function sum(a: number, b: number): number;
diff --git a/packages/cmake-rn/templates/entrypoint.js.template b/packages/cmake-rn/templates/entrypoint.js.template
new file mode 100644
index 00000000..f761340c
--- /dev/null
+++ b/packages/cmake-rn/templates/entrypoint.js.template
@@ -0,0 +1 @@
+module.exports = require("./build/Release/{{name}}.node");
diff --git a/packages/cmake-rn/templates/gitignore b/packages/cmake-rn/templates/gitignore
new file mode 100644
index 00000000..84c048a7
--- /dev/null
+++ b/packages/cmake-rn/templates/gitignore
@@ -0,0 +1 @@
+/build/
diff --git a/packages/cmake-rn/templates/node-api-cmake-js.cmake b/packages/cmake-rn/templates/node-api-cmake-js.cmake
new file mode 100644
index 00000000..30b052b1
--- /dev/null
+++ b/packages/cmake-rn/templates/node-api-cmake-js.cmake
@@ -0,0 +1,10 @@
+# Set by cmake-js, and by cmake-rn when passed --cmake-js
+add_library({{name}} SHARED src/addon.c ${CMAKE_JS_SRC})
+target_include_directories({{name}} PRIVATE ${CMAKE_JS_INC})
+target_link_libraries({{name}} PRIVATE ${CMAKE_JS_LIB})
+
+if(MSVC AND CMAKE_JS_NODELIB_DEF AND CMAKE_JS_NODELIB_TARGET)
+ # Generate node.lib
+ execute_process(COMMAND ${CMAKE_AR} /def:${CMAKE_JS_NODELIB_DEF} /out:${CMAKE_JS_NODELIB_TARGET} ${CMAKE_STATIC_LINKER_FLAGS})
+endif()
+
diff --git a/packages/cmake-rn/templates/node-api-link.cmake b/packages/cmake-rn/templates/node-api-link.cmake
new file mode 100644
index 00000000..eb0d3e25
--- /dev/null
+++ b/packages/cmake-rn/templates/node-api-link.cmake
@@ -0,0 +1,5 @@
+# Defines the "weak-node-api" target, passed in by cmake-rn
+include(${WEAK_NODE_API_CONFIG})
+
+add_library({{name}} SHARED src/addon.c)
+target_link_libraries({{name}} PRIVATE weak-node-api)
diff --git a/packages/cmake-rn/templates/node-api-omit.cmake b/packages/cmake-rn/templates/node-api-omit.cmake
new file mode 100644
index 00000000..33ad9286
--- /dev/null
+++ b/packages/cmake-rn/templates/node-api-omit.cmake
@@ -0,0 +1 @@
+add_library({{name}} SHARED src/addon.c)
diff --git a/packages/ferric/package.json b/packages/ferric/package.json
index e49a0922..79253e1e 100644
--- a/packages/ferric/package.json
+++ b/packages/ferric/package.json
@@ -26,8 +26,6 @@
"dependencies": {
"@napi-rs/cli": "~3.10.8",
"@react-native-node-api/cli-utils": "workspace:*",
- "diff": "^9.0.0",
- "picomatch": "^4.0.5",
"react-native-node-api": "workspace:*",
"semver": "^7.8.5",
"smol-toml": "^1.8.0",
@@ -35,7 +33,6 @@
"zod": "^4.1.11"
},
"devDependencies": {
- "@types/picomatch": "^4.0.3",
"@types/semver": "^7.8.0"
}
}
diff --git a/packages/ferric/src/build.test.ts b/packages/ferric/src/build.test.ts
index 0acd9f53..336eca81 100644
--- a/packages/ferric/src/build.test.ts
+++ b/packages/ferric/src/build.test.ts
@@ -4,8 +4,10 @@ import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
+import { applyChanges } from "@react-native-node-api/cli-utils";
+
import { buildCommand } from "./build.js";
-import { applyChanges, planInit } from "./init.js";
+import { planInit } from "./init.js";
describe("ferric build", { skip: process.platform === "win32" }, () => {
it("builds a crate outside the current directory with --cwd", async (context) => {
diff --git a/packages/ferric/src/cargo-manifest.ts b/packages/ferric/src/cargo-manifest.ts
index 9b4c916e..8e6e4c92 100644
--- a/packages/ferric/src/cargo-manifest.ts
+++ b/packages/ferric/src/cargo-manifest.ts
@@ -3,7 +3,7 @@ import semver from "semver";
import { parse as parseToml } from "smol-toml";
import { MAX_NODE_API_VERSION, getNodeApiVersion } from "./features.js";
-import { isRecord } from "./json.js";
+import { isRecord } from "@react-native-node-api/cli-utils";
export type CrateVersions = {
napi: string;
diff --git a/packages/ferric/src/init.test.ts b/packages/ferric/src/init.test.ts
index f7929a17..8d4dd8b9 100644
--- a/packages/ferric/src/init.test.ts
+++ b/packages/ferric/src/init.test.ts
@@ -8,17 +8,18 @@ import { parse as parseToml } from "smol-toml";
import {
applyChanges,
- assertDistinctChanges,
+ determineDependencySpecifiers,
+ findWorkspace,
+ parseJsonObject,
+} from "@react-native-node-api/cli-utils";
+
+import {
crateNameFromPackageName,
DEFAULT_CRATE_VERSIONS,
DEFAULT_NODE_API_VERSION,
- determineDependencySpecifiers,
- formatDiff,
planInit,
type InitPlan,
} from "./init.js";
-import { parseJsonObject } from "./json.js";
-import { findWorkspace } from "./workspaces.js";
const FERRIC_EXAMPLE_PATH = path.resolve(
import.meta.dirname,
@@ -66,11 +67,15 @@ function changedPaths(plan: InitPlan, packagePath: string) {
.sort();
}
-describe("assertDistinctChanges", () => {
- it("rejects two changes to the same file", () => {
- assertDistinctChanges([{ path: "a" }, { path: "b" }]);
- assert.throws(() =>
- assertDistinctChanges([{ path: "a" }, { path: "b" }, { path: "a" }]),
+describe("determineDependencySpecifiers", () => {
+ it("uses the workspace protocol inside the mono-repo", () => {
+ const cliPackagePath = path.resolve(import.meta.dirname, "..");
+ assert.deepEqual(
+ determineDependencySpecifiers({
+ cliPackagePath,
+ workspace: findWorkspace(cliPackagePath),
+ }),
+ { hostSpecifier: "workspace:*", cliSpecifier: "workspace:*" },
);
});
});
@@ -82,21 +87,6 @@ describe("crateNameFromPackageName", () => {
});
});
-describe("determineDependencySpecifiers", () => {
- it("uses the workspace protocol inside the mono-repo", () => {
- const workspace = findWorkspace(path.resolve(import.meta.dirname, ".."));
- assert.deepEqual(determineDependencySpecifiers(workspace), {
- hostSpecifier: "workspace:*",
- ferricSpecifier: "workspace:*",
- });
- });
-
- it("uses released versions elsewhere", () => {
- const { ferricSpecifier } = determineDependencySpecifiers(undefined);
- assert.match(ferricSpecifier, /^\^\d+\.\d+\.\d+/);
- });
-});
-
describe("planInit", () => {
it("scaffolds a package from scratch", async (context) => {
const tempPath = setupTempDirectory(context);
@@ -412,7 +402,10 @@ describe("planInit", () => {
const plan = await planInit({ packagePath });
await applyChanges(plan.changes);
const packageJson = readPackageJson(packagePath);
- const { ferricSpecifier } = determineDependencySpecifiers(undefined);
+ const { cliSpecifier: ferricSpecifier } = determineDependencySpecifiers({
+ cliPackagePath: path.resolve(import.meta.dirname, ".."),
+ workspace: undefined,
+ });
assert.deepEqual(packageJson["peerDependencies"], {
"react-native-node-api": "^999.0.0",
});
@@ -530,13 +523,3 @@ describe("planInit", () => {
assert.deepEqual(plan.notices, []);
});
});
-
-describe("formatDiff", () => {
- it("renders the changed lines with context", () => {
- const lines = formatDiff("a\nb\nc\n", "a\nb\nB\nc\n").map((line) =>
- // eslint-disable-next-line no-control-regex -- dropping the colors
- line.replace(/\u001b\[\d+m/g, ""),
- );
- assert.deepEqual(lines, ["@@ -1,3 +1,4 @@", " a", " b", "+B", " c"]);
- });
-});
diff --git a/packages/ferric/src/init.ts b/packages/ferric/src/init.ts
index 13a2f619..c860f040 100644
--- a/packages/ferric/src/init.ts
+++ b/packages/ferric/src/init.ts
@@ -1,39 +1,43 @@
-import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
-import { isDeepStrictEqual } from "node:util";
import {
chalk,
Command,
Option,
+ applyChanges,
assertFixable,
- prettyPath,
+ determineDependencySpecifiers,
+ findWorkspace,
+ mergeStepResults,
+ planGitignoreLines,
+ planPackageJsonUpdates,
+ planTemplateFile,
+ planWorkspace,
+ printPlan,
+ readFileIfExists,
+ readPackage,
+ stripPackageScope,
+ withDependency,
+ withFiles,
+ withMissing,
+ withMissingScripts,
wrapAction,
+ assertDistinctChanges,
+ type Change,
+ type PackageJson,
+ type PackageJsonUpdate,
+ type Skip,
+ type StepResult,
+ type Workspace,
} from "@react-native-node-api/cli-utils";
-import { structuredPatch } from "diff";
import {
isCrateVersion,
- isOutdated,
updateCargoManifest,
type CrateVersions,
} from "./cargo-manifest.js";
import { MAX_NODE_API_VERSION } from "./features.js";
-import {
- detectJsonIndentation,
- getObjectProperty,
- getStringArrayProperty,
- parseJsonObject,
- stringifyJson,
-} from "./json.js";
-import {
- addWorkspacePattern,
- findWorkspace,
- isPathCovered,
- relativeWorkspacePath,
- type Workspace,
-} from "./workspaces.js";
const PACKAGE_ROOT = path.join(import.meta.dirname, "..");
const TEMPLATES_PATH = path.join(PACKAGE_ROOT, "templates");
@@ -56,24 +60,6 @@ const RELEASE_PROFILES = {
export type ReleaseProfile = keyof typeof RELEASE_PROFILES;
-export type Change =
- | { kind: "create"; path: string; contents: string }
- | {
- kind: "update";
- path: string;
- contents: string;
- previous: string;
- forced?: boolean;
- };
-
-/** A difference left alone, unless the caller forces the overwrite. */
-export type Skip = {
- path: string;
- contents: string;
- previous: string;
- hint?: string;
-};
-
export type InitPlan = {
packagePath: string;
packageName: string;
@@ -99,8 +85,6 @@ export type InitOptions = {
force?: boolean;
};
-type PackageJson = Record;
-
type InitContext = {
packagePath: string;
packageName: string;
@@ -116,31 +100,9 @@ type InitContext = {
force: boolean;
};
-type StepResult = { changes?: Change[]; skips?: Skip[]; notices?: string[] };
-
/** Plans a part of the initialization, without writing anything. */
type InitStep = (context: InitContext) => StepResult | Promise;
-function mergeStepResults(results: StepResult[]) {
- return {
- changes: results.flatMap(({ changes = [] }) => changes),
- skips: results.flatMap(({ skips = [] }) => skips),
- notices: results.flatMap(({ notices = [] }) => notices),
- };
-}
-
-/**
- * Applying a later change to a file would silently discard an earlier one.
- */
-export function assertDistinctChanges(changes: Pick[]) {
- const paths = changes.map(({ path: filePath }) => filePath);
- assert.deepEqual(
- paths.filter((filePath, index) => paths.indexOf(filePath) !== index),
- [],
- "Expected every init step to change distinct files",
- );
-}
-
/**
* Determine the changes needed to make a package build a Rust Node-API module,
* without applying any of them.
@@ -173,17 +135,8 @@ export async function planInit({
},
);
- const existingPackageJson = await readFileIfExists(
- path.join(resolvedPath, "package.json"),
- );
- const packageJson = existingPackageJson
- ? parseJsonObject(existingPackageJson)
- : undefined;
- const packageNameValue = packageJson?.["name"];
- const packageName =
- typeof packageNameValue === "string"
- ? packageNameValue
- : path.basename(resolvedPath);
+ const { existingPackageJson, packageJson, packageName } =
+ await readPackage(resolvedPath);
const crateName = crateNameOption ?? crateNameFromPackageName(packageName);
assertFixable(
/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(crateName),
@@ -226,64 +179,15 @@ export async function planInit({
};
}
-const planPackageJson: InitStep = (context) => {
- const { packageJson, existingPackageJson, libraryName, force } = context;
- const packageJsonPath = path.join(context.packagePath, "package.json");
- const updated = getPackageJsonUpdates(context).reduce(
- (result, update) => update(result),
- packageJson ?? {},
- );
- const stringify = (value: PackageJson) =>
- stringifyJson(
- value,
- existingPackageJson
- ? detectJsonIndentation(existingPackageJson)
- : undefined,
- );
-
- if (existingPackageJson === undefined) {
- return {
- changes: [
- { kind: "create", path: packageJsonPath, contents: stringify(updated) },
- ],
- };
- }
-
- const conflicts = Object.fromEntries(
- Object.entries(getEntrypoints(libraryName)).filter(
- ([key, value]) => updated[key] !== value,
- ),
- );
- const overwritten = { ...updated, ...conflicts };
- const hasConflicts = Object.keys(conflicts).length > 0;
- const result = force ? overwritten : updated;
- return {
- changes: isDeepStrictEqual(result, packageJson)
- ? []
- : [
- {
- kind: "update",
- path: packageJsonPath,
- contents: stringify(result),
- previous: existingPackageJson,
- forced: force && hasConflicts,
- },
- ],
- skips:
- hasConflicts && !force
- ? [
- {
- path: packageJsonPath,
- previous: stringify(updated),
- contents: stringify(overwritten),
- hint: conflicts["main"]
- ? `To keep ${chalk.blue(String(updated["main"]))} as the entrypoint, re-export the addon from it: ${chalk.blue(reExportStatement(updated, conflicts["main"]))}`
- : undefined,
- },
- ]
- : [],
- };
-};
+const planPackageJson: InitStep = (context) =>
+ planPackageJsonUpdates({
+ packagePath: context.packagePath,
+ packageJson: context.packageJson,
+ existingPackageJson: context.existingPackageJson,
+ updates: getPackageJsonUpdates(context),
+ entrypoints: getEntrypoints(context.libraryName),
+ force: context.force,
+ });
const planCargoManifest: InitStep = async ({
packagePath,
@@ -335,21 +239,11 @@ async function planSourceFile(
template: string,
relativePath: string,
): Promise {
- const outputPath = path.join(packagePath, relativePath);
- const contents = await readTemplate(template);
- const previous = await readFileIfExists(outputPath);
- if (previous === undefined) {
- return { changes: [{ kind: "create", path: outputPath, contents }] };
- } else if (previous === contents) {
- return {};
- } else if (force) {
- return {
- changes: [
- { kind: "update", path: outputPath, contents, previous, forced: true },
- ],
- };
- }
- return { skips: [{ path: outputPath, contents, previous }] };
+ return planTemplateFile({
+ outputPath: path.join(packagePath, relativePath),
+ contents: await readTemplate(template),
+ force,
+ });
}
const planSourceFiles: InitStep = async (context) =>
@@ -360,48 +254,13 @@ const planSourceFiles: InitStep = async (context) =>
]),
);
-const planGitignore: InitStep = async ({ packagePath }) => {
- const gitignorePath = path.join(packagePath, ".gitignore");
- // The template is named without a leading dot, as npm won't publish a
- // .gitignore file inside a package
- const template = await readTemplate("gitignore");
- const previous = await readFileIfExists(gitignorePath);
- if (previous === undefined) {
- return {
- changes: [{ kind: "create", path: gitignorePath, contents: template }],
- };
- }
- const contents = appendMissingLines(previous, template);
- return contents
- ? { changes: [{ kind: "update", path: gitignorePath, contents, previous }] }
- : {};
-};
-
-const planWorkspace: InitStep = async ({ workspace, packagePath }) => {
- if (!workspace) {
- return {};
- }
- const relativePath = relativeWorkspacePath(workspace, packagePath);
- const { patterns, configPath } = workspace;
- if (patterns && isPathCovered(patterns, relativePath)) {
- return {};
- }
- const previous = await readFileIfExists(configPath);
- const contents =
- patterns && previous
- ? addWorkspacePattern(workspace, previous, relativePath)
- : undefined;
- if (!contents || previous === undefined) {
- return {
- notices: [
- `Add ${chalk.blue(relativePath)} to the workspace packages declared by ${prettyPath(configPath)}`,
- ],
- };
- }
- return {
- changes: [{ kind: "update", path: configPath, contents, previous }],
- };
-};
+const planGitignore: InitStep = async ({ packagePath }) =>
+ planGitignoreLines({
+ packagePath,
+ // The template is named without a leading dot, as npm won't publish a
+ // .gitignore file inside a package
+ template: await readTemplate("gitignore"),
+ });
const INIT_STEPS: InitStep[] = [
planPackageJson,
@@ -440,103 +299,17 @@ function renderCargoManifest(
);
}
-function reExportStatement(
- packageJson: Record,
- entrypoint: string,
-) {
- return packageJson["type"] === "module"
- ? `export { default } from "./${entrypoint}";`
- : `module.exports = require("./${entrypoint}");`;
-}
-
-export async function applyChanges(changes: Change[]) {
- for (const { path: filePath, contents } of changes) {
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
- await fs.promises.writeFile(filePath, contents, "utf8");
- }
-}
-
/**
* Strip any npm scope from a package name, to use it as a Cargo crate name.
*/
export function crateNameFromPackageName(packageName: string) {
- return packageName.replace(/^@[^/]+\//, "");
+ return stripPackageScope(packageName);
}
function getEntrypoints(libraryName: string) {
return { main: `${libraryName}.js`, types: `${libraryName}.d.ts` };
}
-type PackageJsonUpdate = (packageJson: PackageJson) => PackageJson;
-
-/**
- * Add the entries a package.json lacks, keeping any value it already declares.
- */
-function withMissing(entries: Record): PackageJsonUpdate {
- return (packageJson) => ({
- ...packageJson,
- ...Object.fromEntries(
- Object.entries(entries).filter(([key]) => packageJson[key] === undefined),
- ),
- });
-}
-
-function withMissingScripts(
- scripts: Record,
-): PackageJsonUpdate {
- return (packageJson) => ({
- ...packageJson,
- scripts: withMissing(scripts)(
- getObjectProperty(packageJson, "scripts") ?? {},
- ),
- });
-}
-
-/**
- * Only extends an existing "files" array, unless asked to create one: adding one
- * to a package without it would suddenly narrow down what that package publishes.
- */
-function withFiles(files: string[], create: boolean): PackageJsonUpdate {
- return (packageJson) => {
- const existing = getStringArrayProperty(packageJson, "files");
- if (existing === undefined && !create) {
- return packageJson;
- }
- const declared = existing ?? [];
- return {
- ...packageJson,
- files: [...declared, ...files.filter((file) => !declared.includes(file))],
- };
- };
-}
-
-/**
- * Add a dependency to the first of the keys, unless one of them declares it
- * already, in which case it's bumped if outdated.
- */
-function withDependency({
- name,
- specifier,
- keys,
-}: {
- name: string;
- specifier: string;
- keys: string[];
-}): PackageJsonUpdate {
- return (packageJson) => {
- const declaredIn = keys.find(
- (key) => getObjectProperty(packageJson, key)?.[name] !== undefined,
- );
- const key = declaredIn ?? keys[0];
- const dependencies = getObjectProperty(packageJson, key) ?? {};
- const declared = dependencies[name];
- return declaredIn === undefined ||
- (typeof declared === "string" && isOutdated(declared, specifier))
- ? { ...packageJson, [key]: { ...dependencies, [name]: specifier } }
- : packageJson;
- };
-}
-
/**
* What a package needs to build a Rust Node-API module, leaving any value it
* already declares alone besides outdated dependencies.
@@ -547,8 +320,8 @@ function getPackageJsonUpdates({
libraryName,
workspace,
}: InitContext): PackageJsonUpdate[] {
- const { hostSpecifier, ferricSpecifier } =
- determineDependencySpecifiers(workspace);
+ const { hostSpecifier, cliSpecifier: ferricSpecifier } =
+ determineDependencySpecifiers({ cliPackagePath: PACKAGE_ROOT, workspace });
return [
withMissing({
name: packageName,
@@ -582,72 +355,10 @@ function getPackageJsonUpdates({
];
}
-/**
- * The specifiers to declare dependencies on this CLI and the host package with.
- * A package initialized into the very workspace this CLI is linked from gets
- * the workspace protocol, everything else the released versions. Publishing
- * replaces the workspace protocol in our own manifest with a version, which is
- * what makes it a reliable signal of running from a checkout of the mono-repo.
- */
-export function determineDependencySpecifiers(
- workspace: Workspace | undefined,
-) {
- const ownPackageJson = parseJsonObject(
- fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"),
- );
- const dependencies = getObjectProperty(ownPackageJson, "dependencies") ?? {};
- const hostDependency = dependencies["react-native-node-api"];
- const ownVersion = ownPackageJson["version"];
- const linkedFromWorkspace =
- typeof hostDependency === "string" &&
- hostDependency.startsWith("workspace:");
- if (
- linkedFromWorkspace &&
- workspace?.rootPath === findWorkspace(PACKAGE_ROOT)?.rootPath
- ) {
- return { hostSpecifier: "workspace:*", ferricSpecifier: "workspace:*" };
- }
- return {
- hostSpecifier:
- typeof hostDependency === "string" && !linkedFromWorkspace
- ? `^${hostDependency.replace(/^[=^~]/, "")}`
- : "latest",
- ferricSpecifier:
- typeof ownVersion === "string" ? `^${ownVersion}` : "latest",
- };
-}
-
-/**
- * Append the lines of a template missing from an existing file, or undefined if
- * it already has them all.
- */
-function appendMissingLines(contents: string, template: string) {
- const existing = new Set(contents.split(/\r?\n/).map((line) => line.trim()));
- const missing = template
- .split("\n")
- .filter((line) => line.trim().length > 0 && !existing.has(line.trim()));
- if (missing.length === 0) {
- return undefined;
- }
- const separator = contents.endsWith("\n") ? "" : "\n";
- return contents + separator + missing.join("\n") + "\n";
-}
-
function readTemplate(filename: string) {
return fs.promises.readFile(path.join(TEMPLATES_PATH, filename), "utf8");
}
-async function readFileIfExists(filePath: string) {
- try {
- return await fs.promises.readFile(filePath, "utf8");
- } catch (error) {
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
- return undefined;
- }
- throw error;
- }
-}
-
const nameOption = new Option(
"--name ",
"Name of the Cargo crate",
@@ -735,90 +446,13 @@ export const initCommand = new Command("init")
if (!dryRun) {
await applyChanges(plan.changes);
}
- printPlan(plan, dryRun);
+ printPlan({
+ plan,
+ dryRun,
+ command: "ferric init",
+ packagePath: plan.packagePath,
+ targetName: plan.crateName,
+ });
},
),
);
-
-/**
- * The hunks of a unified diff, colored like `git diff` prints them.
- */
-export function formatDiff(previous: string, contents: string, context = 2) {
- const { hunks } = structuredPatch(
- "previous",
- "contents",
- previous,
- contents,
- undefined,
- undefined,
- { context },
- );
- return hunks.flatMap(({ oldStart, oldLines, newStart, newLines, lines }) => [
- chalk.cyan(`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`),
- ...lines.map((line) => {
- if (line.startsWith("+")) {
- return chalk.green(line);
- } else if (line.startsWith("-")) {
- return chalk.red(line);
- } else {
- return chalk.dim(line);
- }
- }),
- ]);
-}
-
-function printDiff(previous: string, contents: string) {
- for (const line of formatDiff(previous, contents)) {
- console.log(chalk.dim("│ ") + line);
- }
-}
-
-function changeLabel(change: Change) {
- if (change.kind === "create") {
- return chalk.green("create");
- }
- return change.forced ? chalk.red("overwrite") : chalk.yellow("update");
-}
-
-function printPlan(plan: InitPlan, dryRun: boolean) {
- for (const change of plan.changes) {
- console.log(
- `${dryRun ? chalk.dim("would ") : ""}${changeLabel(change)} ${prettyPath(change.path)}`,
- );
- // Creating writes a template verbatim, printing it would drown the diffs of
- // the files being changed around it
- if (change.kind === "update") {
- printDiff(change.previous, change.contents);
- }
- }
- for (const skip of plan.skips) {
- console.log(
- `${chalk.yellow("skip")} ${prettyPath(skip.path)} ${chalk.dim("differs from what ferric init would write, merge this in by hand:")}`,
- );
- printDiff(skip.previous, skip.contents);
- if (skip.hint) {
- console.log(`${chalk.yellow("ℹ︎")} ${skip.hint}`);
- }
- }
- for (const notice of plan.notices) {
- console.log(`${chalk.yellow("ℹ︎")} ${notice}`);
- }
- if (plan.skips.length > 0) {
- console.log(
- `${chalk.yellow("ℹ︎")} Pass ${chalk.blue("--force")} to overwrite the skipped entrypoints and source files`,
- );
- }
- if (plan.changes.length === 0 && plan.skips.length === 0) {
- console.log(
- `${chalk.green("✔")} ${prettyPath(plan.packagePath)} is already set up to build ${chalk.blue(plan.crateName)}`,
- );
- } else if (dryRun) {
- console.log(
- `${chalk.dim("Nothing was written, drop")} ${chalk.blue("--dry-run")} ${chalk.dim("to apply the changes above")}`,
- );
- } else {
- console.log(
- `${chalk.green("✔")} Initialized ${chalk.blue(plan.crateName)} in ${prettyPath(plan.packagePath)}`,
- );
- }
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 32790f22..e43f63a1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
'@react-native-community/cli-platform-ios':
specifier: ^20.0.2
version: 20.2.0
+ '@react-native-node-api/cmake-rn-example':
+ specifier: workspace:*
+ version: link:../../packages/cmake-rn-example
'@react-native-node-api/ferric-example':
specifier: workspace:*
version: link:../../packages/ferric-example
@@ -152,12 +155,28 @@ importers:
commander:
specifier: ^14.0.1
version: 14.0.3
+ diff:
+ specifier: ^9.0.0
+ version: 9.0.0
ora:
specifier: ^8.2.0
version: 8.2.0
p-limit:
specifier: ^7.2.0
version: 7.3.0
+ picomatch:
+ specifier: ^4.0.5
+ version: 4.0.5
+ semver:
+ specifier: ^7.8.5
+ version: 7.8.5
+ devDependencies:
+ '@types/picomatch':
+ specifier: ^4.0.3
+ version: 4.0.3
+ '@types/semver':
+ specifier: ^7.8.0
+ version: 7.8.0
packages/cmake-file-api:
dependencies:
@@ -189,6 +208,16 @@ importers:
specifier: ^4.1.11
version: 4.4.3
+ packages/cmake-rn-example:
+ dependencies:
+ react-native-node-api:
+ specifier: workspace:*
+ version: link:../host
+ devDependencies:
+ cmake-rn:
+ specifier: workspace:*
+ version: link:../cmake-rn
+
packages/ferric:
dependencies:
'@napi-rs/cli':
@@ -197,12 +226,6 @@ importers:
'@react-native-node-api/cli-utils':
specifier: workspace:*
version: link:../cli-utils
- diff:
- specifier: ^9.0.0
- version: 9.0.0
- picomatch:
- specifier: ^4.0.5
- version: 4.0.5
react-native-node-api:
specifier: workspace:*
version: link:../host
@@ -219,9 +242,6 @@ importers:
specifier: ^4.1.11
version: 4.4.3
devDependencies:
- '@types/picomatch':
- specifier: ^4.0.3
- version: 4.0.3
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
diff --git a/scripts/init-cmake-rn-example.ts b/scripts/init-cmake-rn-example.ts
new file mode 100644
index 00000000..833da33d
--- /dev/null
+++ b/scripts/init-cmake-rn-example.ts
@@ -0,0 +1,20 @@
+import path from "node:path";
+
+import { regenerateExample } from "./regenerate-example.ts";
+
+const ROOT_PATH = path.join(import.meta.dirname, "..");
+const EXAMPLE_PATH = path.join(ROOT_PATH, "packages", "cmake-rn-example");
+const LIBRARY_NAME = "cmake-rn-example";
+
+await regenerateExample({
+ examplePath: EXAMPLE_PATH,
+ binPath: path.join(ROOT_PATH, "packages", "cmake-rn", "bin", "cmake-rn.js"),
+ generatedPaths: [
+ ".gitignore",
+ "CMakeLists.txt",
+ `${LIBRARY_NAME}.d.ts`,
+ `${LIBRARY_NAME}.js`,
+ "package.json",
+ "src/addon.c",
+ ],
+});
diff --git a/scripts/init-ferric-example.ts b/scripts/init-ferric-example.ts
index 58402cb0..99187a5b 100644
--- a/scripts/init-ferric-example.ts
+++ b/scripts/init-ferric-example.ts
@@ -1,67 +1,25 @@
import assert from "node:assert/strict";
-import cp from "node:child_process";
import fs from "node:fs";
import path from "node:path";
-import { readPackage } from "read-pkg";
+import { regenerateExample } from "./regenerate-example.ts";
const ROOT_PATH = path.join(import.meta.dirname, "..");
const EXAMPLE_PATH = path.join(ROOT_PATH, "packages", "ferric-example");
-const FERRIC_PATH = path.join(
- ROOT_PATH,
- "packages",
- "ferric",
- "bin",
- "ferric.js",
-);
-
-const GENERATED_PATHS = [
- ".gitignore",
- "Cargo.toml",
- "build.rs",
- "package.json",
- "src/lib.rs",
-];
-
-const packageJsonPath = path.join(EXAMPLE_PATH, "package.json");
-const {
- name,
- version,
- private: isPrivate,
- homepage,
- repository,
- scripts,
-} = await readPackage({ cwd: EXAMPLE_PATH, normalize: false });
-assert(scripts?.bootstrap, "Expected the example to have a bootstrap script");
const manifest = fs.readFileSync(path.join(EXAMPLE_PATH, "Cargo.toml"), "utf8");
const nodeApiVersion = manifest.match(/^features = \["napi(\d+)"\]$/m)?.[1];
assert(nodeApiVersion, "Expected the example to enable a napiN feature");
-for (const generatedPath of GENERATED_PATHS) {
- fs.rmSync(path.join(EXAMPLE_PATH, generatedPath));
-}
-
-const { status } = cp.spawnSync(
- process.execPath,
- [FERRIC_PATH, "init", EXAMPLE_PATH, "--node-api-version", nodeApiVersion],
- { stdio: "inherit" },
-);
-assert.equal(status, 0, "Failed to execute 'ferric init'");
-
-const generated = await readPackage({ cwd: EXAMPLE_PATH, normalize: false });
-const preserved = { name, version, private: isPrivate, homepage, repository };
-
-fs.writeFileSync(
- packageJsonPath,
- JSON.stringify(
- {
- ...preserved,
- ...generated,
- ...preserved,
- scripts: { ...generated.scripts, bootstrap: scripts.bootstrap },
- },
- null,
- 2,
- ) + "\n",
-);
+await regenerateExample({
+ examplePath: EXAMPLE_PATH,
+ binPath: path.join(ROOT_PATH, "packages", "ferric", "bin", "ferric.js"),
+ args: ["--node-api-version", nodeApiVersion],
+ generatedPaths: [
+ ".gitignore",
+ "Cargo.toml",
+ "build.rs",
+ "package.json",
+ "src/lib.rs",
+ ],
+});
diff --git a/scripts/init-macos-test-app.ts b/scripts/init-macos-test-app.ts
index bf3d962c..0d28e717 100644
--- a/scripts/init-macos-test-app.ts
+++ b/scripts/init-macos-test-app.ts
@@ -86,6 +86,8 @@ async function patchPackageJson() {
"test:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test -- ",
"test:ferricExample":
"MOCHA_REMOTE_CONTEXT=ferricExample node --run test -- ",
+ "test:cmakeRnExample":
+ "MOCHA_REMOTE_CONTEXT=cmakeRnExample node --run test -- ",
};
const transferredDependencies = new Set([
@@ -114,6 +116,10 @@ async function patchPackageJson() {
APP_PATH,
path.join(ROOT_PATH, "packages", "ferric-example"),
),
+ "@react-native-node-api/cmake-rn-example": path.relative(
+ APP_PATH,
+ path.join(ROOT_PATH, "packages", "cmake-rn-example"),
+ ),
"react-native-node-api": path.relative(
APP_PATH,
path.join(ROOT_PATH, "packages", "host"),
diff --git a/scripts/regenerate-example.ts b/scripts/regenerate-example.ts
new file mode 100644
index 00000000..39b4082d
--- /dev/null
+++ b/scripts/regenerate-example.ts
@@ -0,0 +1,66 @@
+import assert from "node:assert/strict";
+import cp from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+import { readPackage } from "read-pkg";
+
+/**
+ * Regenerate an example package by deleting everything an init command writes,
+ * running it, and reapplying the mono-repo fields of the package.json.
+ */
+export async function regenerateExample({
+ examplePath,
+ binPath,
+ args = [],
+ generatedPaths,
+}: {
+ examplePath: string;
+ /** The CLI providing the init command. */
+ binPath: string;
+ /** Extra arguments passed to the init command. */
+ args?: string[];
+ /** Paths relative to the example, which the init command writes. */
+ generatedPaths: string[];
+}) {
+ const packageJsonPath = path.join(examplePath, "package.json");
+ const {
+ name,
+ version,
+ private: isPrivate,
+ homepage,
+ repository,
+ scripts,
+ } = await readPackage({ cwd: examplePath, normalize: false });
+ assert(scripts?.bootstrap, "Expected the example to have a bootstrap script");
+
+ for (const generatedPath of generatedPaths) {
+ fs.rmSync(path.join(examplePath, generatedPath));
+ }
+
+ const { status } = cp.spawnSync(
+ process.execPath,
+ [binPath, "init", examplePath, ...args],
+ { stdio: "inherit" },
+ );
+ assert.equal(status, 0, `Failed to execute '${binPath} init'`);
+
+ const generated = await readPackage({ cwd: examplePath, normalize: false });
+ const preserved = { name, version, private: isPrivate, homepage, repository };
+ const generatedFields = Object.fromEntries(
+ Object.entries(generated).filter(([key]) => !(key in preserved)),
+ );
+
+ fs.writeFileSync(
+ packageJsonPath,
+ JSON.stringify(
+ {
+ ...preserved,
+ ...generatedFields,
+ scripts: { ...generated.scripts, bootstrap: scripts.bootstrap },
+ },
+ null,
+ 2,
+ ) + "\n",
+ );
+}
diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json
index 88041106..d8e6aff9 100644
--- a/tsconfig.scripts.json
+++ b/tsconfig.scripts.json
@@ -4,7 +4,8 @@
"composite": true,
"emitDeclarationOnly": true,
"declarationMap": false,
- "rootDir": "scripts"
+ "rootDir": "scripts",
+ "allowImportingTsExtensions": true
},
"include": ["scripts"]
}