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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-utils-init-helpers.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/cmake-rn-init-command.md
Original file line number Diff line number Diff line change
@@ -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 <link|cmake-js|omit>` 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`.
6 changes: 6 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
14 changes: 14 additions & 0 deletions apps/test-app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ type Context = {
nodeAddonExamples?: boolean;
nodeTests?: boolean;
ferricExample?: boolean;
cmakeRnExample?: boolean;
};

function loadTests({
allTests = false,
nodeAddonExamples = allTests,
nodeTests = allTests,
ferricExample = allTests,
cmakeRnExample = allTests,
}: Context) {
describeIf(nodeAddonExamples, "Node Addon Examples", () => {
for (const [suiteName, examples] of Object.entries(
Expand Down Expand Up @@ -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() {
Expand Down
5 changes: 4 additions & 1 deletion apps/test-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:*",
Expand Down
3 changes: 3 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions packages/cli-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions packages/cli-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
172 changes: 172 additions & 0 deletions packages/cli-utils/src/init.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading
Loading