From c941cfc14e21e7dadd1b6c8824a960ed8cdaca19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 11:47:43 +0000 Subject: [PATCH 1/8] Add a `cmake-rn init` command Scaffolds a package building a CMake Node-API module, or brings an existing one up to what `cmake-rn build` expects, mirroring `ferric init`. - The build moves into a default `build` subcommand, so existing `cmake-rn` invocations keep working, and a stray path argument is rejected as an excess argument rather than taken for something else. - Workspace handling, package.json updates, diff printing and plan output move from ferric into @react-native-node-api/cli-utils, shared by both init commands. - An existing CMakeLists.txt is inspected rather than replaced, noticing when it doesn't define or link weak-node-api or build the expected library. - packages/cmake-rn-example is generated by `pnpm run init-cmake-rn-example`, which the lint job checks for drift. Its bootstrap script builds it with cmake-rn in the native CI jobs. Closes #483 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne --- .changeset/cli-utils-init-helpers.md | 5 + .changeset/cmake-rn-init-command.md | 9 + .github/workflows/check.yml | 6 + README.md | 4 + docs/USAGE.md | 3 + eslint.config.js | 2 + package.json | 5 +- packages/cli-utils/package.json | 16 +- packages/cli-utils/src/index.ts | 3 + packages/cli-utils/src/init.test.ts | 172 ++++++ packages/cli-utils/src/init.ts | 530 ++++++++++++++++++ packages/{ferric => cli-utils}/src/json.ts | 0 .../src/workspaces.test.ts | 0 .../{ferric => cli-utils}/src/workspaces.ts | 0 packages/cli-utils/tsconfig.json | 9 +- packages/cli-utils/tsconfig.node-tests.json | 14 + packages/cli-utils/tsconfig.node.json | 3 + packages/cmake-rn-example/.gitignore | 1 + packages/cmake-rn-example/CMakeLists.txt | 9 + .../cmake-rn-example/cmake-rn-example.d.ts | 1 + packages/cmake-rn-example/cmake-rn-example.js | 1 + packages/cmake-rn-example/package.json | 30 + packages/cmake-rn-example/src/addon.c | 36 ++ packages/cmake-rn/README.md | 38 ++ packages/cmake-rn/package.json | 3 +- packages/cmake-rn/src/cli.ts | 16 +- packages/cmake-rn/src/init.test.ts | 307 ++++++++++ packages/cmake-rn/src/init.ts | 332 +++++++++++ packages/cmake-rn/src/platforms/types.ts | 6 +- packages/cmake-rn/templates/CMakeLists.txt | 9 + packages/cmake-rn/templates/addon.c | 36 ++ .../templates/entrypoint.d.ts.template | 1 + .../cmake-rn/templates/entrypoint.js.template | 1 + packages/cmake-rn/templates/gitignore | 1 + packages/ferric/package.json | 3 - packages/ferric/src/cargo-manifest.ts | 2 +- packages/ferric/src/init.test.ts | 55 +- packages/ferric/src/init.ts | 478 ++-------------- pnpm-lock.yaml | 35 +- scripts/init-cmake-rn-example.ts | 20 + scripts/init-ferric-example.ts | 68 +-- scripts/regenerate-example.ts | 64 +++ tsconfig.scripts.json | 3 +- 43 files changed, 1796 insertions(+), 541 deletions(-) create mode 100644 .changeset/cli-utils-init-helpers.md create mode 100644 .changeset/cmake-rn-init-command.md create mode 100644 packages/cli-utils/src/init.test.ts create mode 100644 packages/cli-utils/src/init.ts rename packages/{ferric => cli-utils}/src/json.ts (100%) rename packages/{ferric => cli-utils}/src/workspaces.test.ts (100%) rename packages/{ferric => cli-utils}/src/workspaces.ts (100%) create mode 100644 packages/cli-utils/tsconfig.node-tests.json create mode 100644 packages/cli-utils/tsconfig.node.json create mode 100644 packages/cmake-rn-example/.gitignore create mode 100644 packages/cmake-rn-example/CMakeLists.txt create mode 100644 packages/cmake-rn-example/cmake-rn-example.d.ts create mode 100644 packages/cmake-rn-example/cmake-rn-example.js create mode 100644 packages/cmake-rn-example/package.json create mode 100644 packages/cmake-rn-example/src/addon.c create mode 100644 packages/cmake-rn/src/init.test.ts create mode 100644 packages/cmake-rn/src/init.ts create mode 100644 packages/cmake-rn/templates/CMakeLists.txt create mode 100644 packages/cmake-rn/templates/addon.c create mode 100644 packages/cmake-rn/templates/entrypoint.d.ts.template create mode 100644 packages/cmake-rn/templates/entrypoint.js.template create mode 100644 packages/cmake-rn/templates/gitignore create mode 100644 scripts/init-cmake-rn-example.ts create mode 100644 scripts/regenerate-example.ts 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..26550ec8 --- /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. 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/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..e495492b --- /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 planGitignore({ + 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 planPackageJson({ + 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/ferric/src/json.ts b/packages/cli-utils/src/json.ts similarity index 100% rename from packages/ferric/src/json.ts rename to packages/cli-utils/src/json.ts 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..c11f1242 --- /dev/null +++ b/packages/cmake-rn-example/src/addon.c @@ -0,0 +1,36 @@ +#include + +#define NAPI_CALL(env, call) \ + do { \ + if ((call) != napi_ok) { \ + napi_throw_error((env), NULL, "Failed to call " #call); \ + return NULL; \ + } \ + } while (0) + +static napi_value Sum(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + + // Missing arguments are undefined, which isn't a number either + double a; + double 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_CALL(env, napi_create_double(env, a + b, &result)); + return result; +} + +NAPI_MODULE_INIT() { + napi_value sum; + NAPI_CALL(env, + napi_create_function(env, "sum", NAPI_AUTO_LENGTH, Sum, NULL, &sum)); + NAPI_CALL(env, 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..835b2abe 100644 --- a/packages/cmake-rn/README.md +++ b/packages/cmake-rn/README.md @@ -4,6 +4,44 @@ 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 define or link the `weak-node-api` target, 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`. + +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..8863ab06 --- /dev/null +++ b/packages/cmake-rn/src/init.test.ts @@ -0,0 +1,307 @@ +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; +} + +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 = fs.readFileSync( + path.join(packagePath, "CMakeLists.txt"), + "utf8", + ); + 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( + fs.readFileSync(path.join(packagePath, "my-addon.js"), "utf8"), + '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("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("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..aa212500 --- /dev/null +++ b/packages/cmake-rn/src/init.ts @@ -0,0 +1,332 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + chalk, + Command, + Option, + applyChanges, + assertDistinctChanges, + assertFixable, + determineDependencySpecifiers, + findWorkspace, + mergeStepResults, + planGitignore, + planPackageJson, + 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 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; + /** 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; + 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, + 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), + 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 planPackageJsonStep: InitStep = (context) => + planPackageJson({ + 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, + force, +}) => { + const cmakeListsPath = path.join(packagePath, "CMakeLists.txt"); + const contents = await readTemplate("CMakeLists.txt", { libraryName }); + const previous = await readFileIfExists(cmakeListsPath); + if (previous !== undefined && previous !== contents && !force) { + return { notices: inspectCmakeProject(previous, libraryName) }; + } + return 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 planGitignoreStep: InitStep = async ({ packagePath, libraryName }) => + planGitignore({ + 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[] = [ + planPackageJsonStep, + planEntrypoints, + planCmakeProject, + planGitignoreStep, + planWorkspace, +]; + +/** + * Point out what an existing CMakeLists.txt lacks to build a Node-API module + * with `cmake-rn build`. + */ +export function inspectCmakeProject(contents: string, libraryName: string) { + 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 (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, +}: 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: "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 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(forceOption) + .action( + wrapAction(async (packagePath: string, { name, dryRun, force }) => { + const plan = await planInit({ + packagePath, + libraryName: name, + 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..fccb3908 --- /dev/null +++ b/packages/cmake-rn/templates/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.15...3.31) +project({{name}}) + +# 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) +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..c11f1242 --- /dev/null +++ b/packages/cmake-rn/templates/addon.c @@ -0,0 +1,36 @@ +#include + +#define NAPI_CALL(env, call) \ + do { \ + if ((call) != napi_ok) { \ + napi_throw_error((env), NULL, "Failed to call " #call); \ + return NULL; \ + } \ + } while (0) + +static napi_value Sum(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + + // Missing arguments are undefined, which isn't a number either + double a; + double 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_CALL(env, napi_create_double(env, a + b, &result)); + return result; +} + +NAPI_MODULE_INIT() { + napi_value sum; + NAPI_CALL(env, + napi_create_function(env, "sum", NAPI_AUTO_LENGTH, Sum, NULL, &sum)); + NAPI_CALL(env, 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/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/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..48f971a9 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, + planGitignore as planGitignoreFromTemplate, + planPackageJson as 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 }) => + planGitignoreFromTemplate({ + 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..21cd6cee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,12 +152,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 +205,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 +223,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 +239,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/regenerate-example.ts b/scripts/regenerate-example.ts new file mode 100644 index 00000000..a5154684 --- /dev/null +++ b/scripts/regenerate-example.ts @@ -0,0 +1,64 @@ +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 }; + + fs.writeFileSync( + packageJsonPath, + JSON.stringify( + { + ...preserved, + ...generated, + ...preserved, + 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"] } From 342787047afd7375d1e42438f208a42619e47276 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:44:54 +0000 Subject: [PATCH 2/8] Simplify the cmake-rn init template and shared helper names - Drop the NAPI_CALL macro from the addon template, keeping only the check that guards against reading uninitialized numbers. - Rename the cli-utils package.json and .gitignore planners, so neither CLI has to alias them next to its own steps. - Spread the preserved package.json fields once when regenerating examples. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne --- packages/cli-utils/src/init.ts | 4 ++-- packages/cmake-rn-example/src/addon.c | 21 +++++---------------- packages/cmake-rn/src/init.ts | 16 ++++++++-------- packages/cmake-rn/templates/addon.c | 21 +++++---------------- packages/ferric/src/init.ts | 6 +++--- scripts/regenerate-example.ts | 6 ++++-- 6 files changed, 27 insertions(+), 47 deletions(-) diff --git a/packages/cli-utils/src/init.ts b/packages/cli-utils/src/init.ts index e495492b..1ccf40cc 100644 --- a/packages/cli-utils/src/init.ts +++ b/packages/cli-utils/src/init.ts @@ -147,7 +147,7 @@ export async function planTemplateFile({ * Plan a .gitignore with the lines of a template, appending those an existing * one lacks. */ -export async function planGitignore({ +export async function planGitignoreLines({ packagePath, template, }: { @@ -209,7 +209,7 @@ export type PackageJsonUpdate = (packageJson: PackageJson) => PackageJson; * the entrypoints only when missing: a differing value is skipped with a diff, * unless forced. */ -export function planPackageJson({ +export function planPackageJsonUpdates({ packagePath, packageJson, existingPackageJson, diff --git a/packages/cmake-rn-example/src/addon.c b/packages/cmake-rn-example/src/addon.c index c11f1242..3cd224b5 100644 --- a/packages/cmake-rn-example/src/addon.c +++ b/packages/cmake-rn-example/src/addon.c @@ -1,21 +1,11 @@ #include -#define NAPI_CALL(env, call) \ - do { \ - if ((call) != napi_ok) { \ - napi_throw_error((env), NULL, "Failed to call " #call); \ - return NULL; \ - } \ - } while (0) - static napi_value Sum(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value args[2]; - NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + napi_get_cb_info(env, info, &argc, args, NULL, NULL); - // Missing arguments are undefined, which isn't a number either - double a; - double b; + 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"); @@ -23,14 +13,13 @@ static napi_value Sum(napi_env env, napi_callback_info info) { } napi_value result; - NAPI_CALL(env, napi_create_double(env, a + b, &result)); + napi_create_double(env, a + b, &result); return result; } NAPI_MODULE_INIT() { napi_value sum; - NAPI_CALL(env, - napi_create_function(env, "sum", NAPI_AUTO_LENGTH, Sum, NULL, &sum)); - NAPI_CALL(env, napi_set_named_property(env, exports, "sum", 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/src/init.ts b/packages/cmake-rn/src/init.ts index aa212500..95988f98 100644 --- a/packages/cmake-rn/src/init.ts +++ b/packages/cmake-rn/src/init.ts @@ -11,8 +11,8 @@ import { determineDependencySpecifiers, findWorkspace, mergeStepResults, - planGitignore, - planPackageJson, + planGitignoreLines, + planPackageJsonUpdates, planTemplateFile, planWorkspace, printPlan, @@ -115,8 +115,8 @@ export async function planInit({ }; } -const planPackageJsonStep: InitStep = (context) => - planPackageJson({ +const planPackageJson: InitStep = (context) => + planPackageJsonUpdates({ packagePath: context.packagePath, packageJson: context.packageJson, existingPackageJson: context.existingPackageJson, @@ -177,8 +177,8 @@ const planCmakeProject: InitStep = async ({ ); }; -const planGitignoreStep: InitStep = async ({ packagePath, libraryName }) => - planGitignore({ +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 @@ -186,10 +186,10 @@ const planGitignoreStep: InitStep = async ({ packagePath, libraryName }) => }); const INIT_STEPS: InitStep[] = [ - planPackageJsonStep, + planPackageJson, planEntrypoints, planCmakeProject, - planGitignoreStep, + planGitignore, planWorkspace, ]; diff --git a/packages/cmake-rn/templates/addon.c b/packages/cmake-rn/templates/addon.c index c11f1242..3cd224b5 100644 --- a/packages/cmake-rn/templates/addon.c +++ b/packages/cmake-rn/templates/addon.c @@ -1,21 +1,11 @@ #include -#define NAPI_CALL(env, call) \ - do { \ - if ((call) != napi_ok) { \ - napi_throw_error((env), NULL, "Failed to call " #call); \ - return NULL; \ - } \ - } while (0) - static napi_value Sum(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value args[2]; - NAPI_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + napi_get_cb_info(env, info, &argc, args, NULL, NULL); - // Missing arguments are undefined, which isn't a number either - double a; - double b; + 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"); @@ -23,14 +13,13 @@ static napi_value Sum(napi_env env, napi_callback_info info) { } napi_value result; - NAPI_CALL(env, napi_create_double(env, a + b, &result)); + napi_create_double(env, a + b, &result); return result; } NAPI_MODULE_INIT() { napi_value sum; - NAPI_CALL(env, - napi_create_function(env, "sum", NAPI_AUTO_LENGTH, Sum, NULL, &sum)); - NAPI_CALL(env, napi_set_named_property(env, exports, "sum", 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/ferric/src/init.ts b/packages/ferric/src/init.ts index 48f971a9..c860f040 100644 --- a/packages/ferric/src/init.ts +++ b/packages/ferric/src/init.ts @@ -10,8 +10,8 @@ import { determineDependencySpecifiers, findWorkspace, mergeStepResults, - planGitignore as planGitignoreFromTemplate, - planPackageJson as planPackageJsonUpdates, + planGitignoreLines, + planPackageJsonUpdates, planTemplateFile, planWorkspace, printPlan, @@ -255,7 +255,7 @@ const planSourceFiles: InitStep = async (context) => ); const planGitignore: InitStep = async ({ packagePath }) => - planGitignoreFromTemplate({ + planGitignoreLines({ packagePath, // The template is named without a leading dot, as npm won't publish a // .gitignore file inside a package diff --git a/scripts/regenerate-example.ts b/scripts/regenerate-example.ts index a5154684..39b4082d 100644 --- a/scripts/regenerate-example.ts +++ b/scripts/regenerate-example.ts @@ -47,14 +47,16 @@ export async function regenerateExample({ 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, - ...generated, - ...preserved, + ...generatedFields, scripts: { ...generated.scripts, bootstrap: scripts.bootstrap }, }, null, From 4fb9dad2c0508338a21ada8c55e4ba8a4c535aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:57:23 +0000 Subject: [PATCH 3/8] Add a --weak-node-api option to cmake-rn init Picks how a new CMakeLists.txt gets Node-API: - link (default): include(${WEAK_NODE_API_CONFIG}) and link weak-node-api. - cmake-js: use the CMAKE_JS_* variables, building with both cmake-js and `cmake-rn --cmake-js`, which the generated build script passes. - omit: leave Node-API out for the author to provide, with a notice. An existing CMakeLists.txt is inspected against the chosen mode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne --- .changeset/cmake-rn-init-command.md | 2 +- packages/cmake-rn/README.md | 10 +- packages/cmake-rn/src/init.test.ts | 72 ++++++++++++ packages/cmake-rn/src/init.ts | 104 ++++++++++++++---- packages/cmake-rn/templates/CMakeLists.txt | 6 +- .../templates/node-api-cmake-js.cmake | 10 ++ .../cmake-rn/templates/node-api-link.cmake | 5 + .../cmake-rn/templates/node-api-omit.cmake | 1 + 8 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 packages/cmake-rn/templates/node-api-cmake-js.cmake create mode 100644 packages/cmake-rn/templates/node-api-link.cmake create mode 100644 packages/cmake-rn/templates/node-api-omit.cmake diff --git a/.changeset/cmake-rn-init-command.md b/.changeset/cmake-rn-init-command.md index 26550ec8..9af0c4dc 100644 --- a/.changeset/cmake-rn-init-command.md +++ b/.changeset/cmake-rn-init-command.md @@ -4,6 +4,6 @@ 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. 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. +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/packages/cmake-rn/README.md b/packages/cmake-rn/README.md index 835b2abe..d3584a29 100644 --- a/packages/cmake-rn/README.md +++ b/packages/cmake-rn/README.md @@ -25,9 +25,17 @@ The command creates whatever is missing — `package.json`, `CMakeLists.txt`, 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 define or link the `weak-node-api` target, or doesn't build the library the entrypoint loads. +- 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 diff --git a/packages/cmake-rn/src/init.test.ts b/packages/cmake-rn/src/init.test.ts index 8863ab06..5532788e 100644 --- a/packages/cmake-rn/src/init.test.ts +++ b/packages/cmake-rn/src/init.test.ts @@ -247,6 +247,57 @@ describe("planInit", () => { ]); }); + 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 = fs.readFileSync( + path.join(packagePath, "CMakeLists.txt"), + "utf8", + ); + 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 = fs.readFileSync( + path.join(packagePath, "CMakeLists.txt"), + "utf8", + ); + 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), []); @@ -299,6 +350,27 @@ describe("inspectCmakeProject", () => { 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); diff --git a/packages/cmake-rn/src/init.ts b/packages/cmake-rn/src/init.ts index 95988f98..534bcce4 100644 --- a/packages/cmake-rn/src/init.ts +++ b/packages/cmake-rn/src/init.ts @@ -37,6 +37,14 @@ 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; @@ -51,6 +59,8 @@ export type InitOptions = { 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; }; @@ -63,6 +73,7 @@ type InitContext = { existingPackageJson: string | undefined; packageJson: PackageJson | undefined; workspace: Workspace | undefined; + weakNodeApi: WeakNodeApiMode; force: boolean; }; @@ -76,6 +87,7 @@ type InitStep = (context: InitContext) => StepResult | Promise; export async function planInit({ packagePath, libraryName: libraryNameOption, + weakNodeApi = "link", force = false, }: InitOptions): Promise { const resolvedPath = path.resolve(packagePath); @@ -98,6 +110,7 @@ export async function planInit({ existingPackageJson, packageJson, workspace: findWorkspace(resolvedPath), + weakNodeApi, force, }; const { changes, skips, notices } = mergeStepResults( @@ -157,15 +170,23 @@ const planEntrypoints: InitStep = async ({ const planCmakeProject: InitStep = async ({ packagePath, libraryName, + weakNodeApi, force, }) => { const cmakeListsPath = path.join(packagePath, "CMakeLists.txt"); - const contents = await readTemplate("CMakeLists.txt", { libraryName }); + const contents = ( + await readTemplate("CMakeLists.txt", { libraryName }) + ).replace( + "{{nodeApi}}\n", + await readTemplate(`node-api-${weakNodeApi}.cmake`, { libraryName }), + ); const previous = await readFileIfExists(cmakeListsPath); if (previous !== undefined && previous !== contents && !force) { - return { notices: inspectCmakeProject(previous, libraryName) }; + return { + notices: inspectCmakeProject(previous, libraryName, weakNodeApi), + }; } - return mergeStepResults( + const result = mergeStepResults( await Promise.all([ planTemplateFile({ outputPath: cmakeListsPath, contents, force }), planTemplateFile({ @@ -175,6 +196,15 @@ const planCmakeProject: InitStep = async ({ }), ]), ); + 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 }) => @@ -195,9 +225,13 @@ const INIT_STEPS: InitStep[] = [ /** * Point out what an existing CMakeLists.txt lacks to build a Node-API module - * with `cmake-rn build`. + * with `cmake-rn build`, getting Node-API the way the mode asks for. */ -export function inspectCmakeProject(contents: string, libraryName: string) { +export function inspectCmakeProject( + contents: string, + libraryName: string, + weakNodeApi: WeakNodeApiMode = "link", +) { const code = contents.replace(/#.*$/gm, ""); const cmakeListsName = chalk.blue("CMakeLists.txt"); const notices: string[] = []; @@ -207,7 +241,15 @@ export function inspectCmakeProject(contents: string, libraryName: string) { /find_package\s*\(\s*weak-node-api\b/.test(code); const linksWeakNodeApi = /target_link_libraries\s*\([^)]*\bweak-node-api\b/.test(code); - if (usesCmakeJs && !importsWeakNodeApi) { + 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`, ); @@ -244,6 +286,7 @@ function getPackageJsonUpdates({ packageName, libraryName, workspace, + weakNodeApi, }: InitContext) { const { hostSpecifier, cliSpecifier } = determineDependencySpecifiers({ cliPackagePath: PACKAGE_ROOT, @@ -265,7 +308,9 @@ function getPackageJsonUpdates({ ], packageJson === undefined, ), - withMissingScripts({ build: "cmake-rn" }), + withMissingScripts({ + build: weakNodeApi === "cmake-js" ? "cmake-rn --cmake-js" : "cmake-rn", + }), withDependency({ name: "react-native-node-api", specifier: hostSpecifier, @@ -300,6 +345,13 @@ const dryRunOption = new Option( "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", @@ -310,23 +362,27 @@ export const initCommand = new Command("init") .argument("[path]", "Path of the package directory", ".") .addOption(nameOption) .addOption(dryRunOption) + .addOption(weakNodeApiOption) .addOption(forceOption) .action( - wrapAction(async (packagePath: string, { name, dryRun, force }) => { - const plan = await planInit({ - packagePath, - libraryName: name, - force, - }); - if (!dryRun) { - await applyChanges(plan.changes); - } - printPlan({ - plan, - dryRun, - command: "cmake-rn init", - packagePath: plan.packagePath, - targetName: plan.libraryName, - }); - }), + 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/templates/CMakeLists.txt b/packages/cmake-rn/templates/CMakeLists.txt index fccb3908..c2bdc319 100644 --- a/packages/cmake-rn/templates/CMakeLists.txt +++ b/packages/cmake-rn/templates/CMakeLists.txt @@ -1,9 +1,5 @@ cmake_minimum_required(VERSION 3.15...3.31) project({{name}}) -# 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) +{{nodeApi}} set_target_properties({{name}} PROPERTIES PREFIX "" SUFFIX .node) 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) From b87524ca438f6b9144e0a5c0d81a30f81db68538 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:10:02 +0000 Subject: [PATCH 4/8] Import applyChanges from cli-utils in the ferric build test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne --- packages/ferric/src/build.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) => { From d5edc4cccd1dcc4e5150354c7d0e04bcd63a1dad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:10:03 +0000 Subject: [PATCH 5/8] Load cmake-rn-example in the test app Mirrors ferric-example: a cmakeRnExample suite calling sum, run as part of allTests, with its own test scripts for iOS, Android and macOS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne --- apps/test-app/App.tsx | 14 ++++++++++++++ apps/test-app/package.json | 5 ++++- pnpm-lock.yaml | 3 +++ scripts/init-macos-test-app.ts | 6 ++++++ 4 files changed, 27 insertions(+), 1 deletion(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index 21cd6cee..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 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"), From 698534fd4abd7ca058ec1ac8291f34bffb5ac192 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:10:41 +0000 Subject: [PATCH 6/8] Trigger CI with the PR labels Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne From 4c8d7210d4d72e75f7108d7928f83e4e14a97db9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:37:13 +0000 Subject: [PATCH 7/8] Fix cmake-rn init on CRLF checkouts and a slow indentation regex - Match the {{nodeApi}} placeholder with an optional CR, as Windows checks the templates out with CRLF line endings. - Detect JSON indentation without letting the captured whitespace span newlines, which made the match quadratic on many blank lines (CodeQL). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne --- packages/cli-utils/src/json.test.ts | 25 +++++++++++++++++++++++++ packages/cli-utils/src/json.ts | 2 +- packages/cmake-rn/src/init.test.ts | 22 +++++++++------------- packages/cmake-rn/src/init.ts | 8 ++++---- 4 files changed, 39 insertions(+), 18 deletions(-) create mode 100644 packages/cli-utils/src/json.test.ts 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/cli-utils/src/json.ts b/packages/cli-utils/src/json.ts index f4bf9e02..9f32a6bf 100644 --- a/packages/cli-utils/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/cmake-rn/src/init.test.ts b/packages/cmake-rn/src/init.test.ts index 5532788e..4ad28915 100644 --- a/packages/cmake-rn/src/init.test.ts +++ b/packages/cmake-rn/src/init.test.ts @@ -39,6 +39,11 @@ function setupTempDirectory(context: TestContext, files: FileMap = {}) { 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"), @@ -98,10 +103,7 @@ describe("planInit", () => { "build/Release/my-addon.android.node", ]); - const cmakeLists = fs.readFileSync( - path.join(packagePath, "CMakeLists.txt"), - "utf8", - ); + 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( @@ -110,7 +112,7 @@ describe("planInit", () => { ); assert.deepEqual(inspectCmakeProject(cmakeLists, "my-addon"), []); assert.equal( - fs.readFileSync(path.join(packagePath, "my-addon.js"), "utf8"), + readText(path.join(packagePath, "my-addon.js")), 'module.exports = require("./build/Release/my-addon.node");\n', ); }); @@ -258,10 +260,7 @@ describe("planInit", () => { assert.deepEqual(readPackageJson(packagePath)["scripts"], { build: "cmake-rn --cmake-js", }); - const cmakeLists = fs.readFileSync( - path.join(packagePath, "CMakeLists.txt"), - "utf8", - ); + 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/); @@ -287,10 +286,7 @@ describe("planInit", () => { assert.deepEqual(readPackageJson(packagePath)["scripts"], { build: "cmake-rn", }); - const cmakeLists = fs.readFileSync( - path.join(packagePath, "CMakeLists.txt"), - "utf8", - ); + 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" }); diff --git a/packages/cmake-rn/src/init.ts b/packages/cmake-rn/src/init.ts index 534bcce4..c3263997 100644 --- a/packages/cmake-rn/src/init.ts +++ b/packages/cmake-rn/src/init.ts @@ -174,12 +174,12 @@ const planCmakeProject: InitStep = async ({ 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}}\n", - await readTemplate(`node-api-${weakNodeApi}.cmake`, { libraryName }), - ); + ).replace(/\{\{nodeApi\}\}\r?\n/, () => nodeApi); const previous = await readFileIfExists(cmakeListsPath); if (previous !== undefined && previous !== contents && !force) { return { From f3810921f57b0b5a561e83253b2bb6852db7f032 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:09:02 +0000 Subject: [PATCH 8/8] Rerun CI without the MacOS label Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2xSE1YMYWQRnGpnbKVbne