diff --git a/.changeset/ferric-init-command.md b/.changeset/ferric-init-command.md new file mode 100644 index 00000000..f76d70bf --- /dev/null +++ b/.changeset/ferric-init-command.md @@ -0,0 +1,9 @@ +--- +"ferric-cli": minor +--- + +Add a `ferric init [path]` command, scaffolding a package building a Rust Node-API module. + +It writes the `package.json`, `Cargo.toml`, `build.rs`, `src/lib.rs` 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 crates in place, and prints a diff for every update. 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. + +A new `Cargo.toml` can be configured with `--node-api-version`, `--napi-crate-version`, `--napi-derive-crate-version`, `--napi-build-crate-version` and `--release-profile`. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2edc3f71..142733a5 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -75,6 +75,12 @@ jobs: # weak-node-api's codegen it can't be reproduced here without reintroducing a # native build into the fastest-feedback job. See #414. - run: pnpm --filter weak-node-api run prebuild:prepare + - run: pnpm run init-ferric-example + - name: Verify ferric-example matches what ferric init generates + run: | + git status --porcelain packages/ferric-example + git diff --exit-code packages/ferric-example + test -z "$(git status --porcelain packages/ferric-example)" - run: pnpm run lint env: DEBUG: eslint:eslint diff --git a/package.json b/package.json index 92b1136d..56a80946 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "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-macos-test-app": "node scripts/init-macos-test-app.ts", + "init-ferric-example": "node scripts/init-ferric-example.ts" }, "author": { "name": "Callstack", diff --git a/packages/ferric-example/Cargo.lock b/packages/ferric-example/Cargo.lock index 6e736ac0..eb892d7b 100644 --- a/packages/ferric-example/Cargo.lock +++ b/packages/ferric-example/Cargo.lock @@ -31,7 +31,7 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "ferric-example" -version = "1.0.0" +version = "0.1.0" dependencies = [ "napi", "napi-build", diff --git a/packages/ferric-example/Cargo.toml b/packages/ferric-example/Cargo.toml index 0fa3cd42..9f5e89f9 100644 --- a/packages/ferric-example/Cargo.toml +++ b/packages/ferric-example/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferric-example" -version = "1.0.0" +version = "0.1.0" edition = "2021" license = "MIT" diff --git a/packages/ferric-example/package.json b/packages/ferric-example/package.json index 3e9fd232..b5a35a2f 100644 --- a/packages/ferric-example/package.json +++ b/packages/ferric-example/package.json @@ -2,13 +2,13 @@ "name": "@react-native-node-api/ferric-example", "version": "0.1.3", "private": true, - "type": "commonjs", "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/ferric-example" }, + "type": "commonjs", "main": "ferric_example.js", "types": "ferric_example.d.ts", "files": [ @@ -19,8 +19,8 @@ ], "scripts": { "build": "ferric build", - "bootstrap": "node --run build", - "build:types": "ferric build --dts-only" + "build:types": "ferric build --dts-only", + "bootstrap": "node --run build" }, "dependencies": { "react-native-node-api": "workspace:*" diff --git a/packages/ferric/README.md b/packages/ferric/README.md index 5e9e0ad1..7a8879fa 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -19,3 +19,42 @@ features = ["napi3"] ``` Pass `--skip-feature-check` to bypass the check. + +## `ferric init [path]` + +Scaffold a package building a Rust Node-API module, or bring an existing package +up to what `ferric build` expects of it: + +``` +npx ferric init packages/my-addon +``` + +The command creates whatever is missing — `package.json`, `Cargo.toml`, +`build.rs`, `src/lib.rs` 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 `Cargo.toml` keeps its comments and formatting. Outdated `napi`, `napi-derive` and `napi-build` versions are bumped in place, and the command points out anything else `ferric build` needs. +- A `main`, `types`, `src/lib.rs` or `build.rs` that differs is skipped, printing a diff to merge by hand. Pass `--force` to overwrite them instead. + +A newly created `Cargo.toml` can be configured with: + +- `--node-api-version `: the `napiN` feature to enable, defaulting to 3. +- `--napi-crate-version`, `--napi-derive-crate-version` and `--napi-build-crate-version`: the crate versions, defaulting to those tested with `ferric-example`. These also set what an existing `Cargo.toml` is bumped to. +- `--release-profile `: `speed` (the default) enables `lto` and a single codegen unit, `size` also optimizes for size and strips symbols, and `none` leaves `[profile.release]` out. + +The name of the crate defaults to the package name without its npm scope +(`@my-org/my-addon` becomes `my-addon`) and can be set with `--name`. Cargo +normalizes it to `my_addon` for the artifacts it produces, which is the basename +`ferric build` writes its outputs and entrypoint with. + +Every file it updates is printed as a diff, and `--dry-run` prints the whole +plan without writing anything: + +``` +npx ferric init packages/my-addon --dry-run +``` + +The [`ferric-example`](../ferric-example) package is generated by this command: run `pnpm run init-ferric-example` from the root of the repository after changing the templates, which CI asserts leaves no diff. diff --git a/packages/ferric/package.json b/packages/ferric/package.json index ec83168a..e49a0922 100644 --- a/packages/ferric/package.json +++ b/packages/ferric/package.json @@ -11,7 +11,10 @@ "type": "module", "files": [ "bin", - "dist" + "dist", + "!dist/**/*.test.d.ts", + "!dist/**/*.test.d.ts.map", + "templates" ], "bin": { "ferric": "./bin/ferric.js" @@ -23,8 +26,16 @@ "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", "weak-node-api": "workspace:*", "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 new file mode 100644 index 00000000..9b4c916e --- /dev/null +++ b/packages/ferric/src/cargo-manifest.ts @@ -0,0 +1,211 @@ +import { chalk } from "@react-native-node-api/cli-utils"; +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"; + +export type CrateVersions = { + napi: string; + "napi-derive": string; + "napi-build": string; +}; + +const CRATES = [ + { crate: "napi", table: "dependencies" }, + { crate: "napi-derive", table: "dependencies" }, + { crate: "napi-build", table: "build-dependencies" }, +] satisfies { crate: keyof CrateVersions; table: string }[]; + +/** + * Cargo separates comparators with commas, which semver expects as spaces. + */ +function toSemverRange(requirement: string) { + return semver.validRange(requirement.replaceAll(",", " ")); +} + +export function isCrateVersion(requirement: string) { + return toSemverRange(requirement) !== null; +} + +/** + * Whether a declared version requirement allows only versions below the wanted + * one. Requirements which aren't versions at all are never considered outdated. + */ +export function isOutdated(declared: string, wanted: string) { + const declaredRange = toSemverRange(declared); + const wantedRange = toSemverRange(wanted); + if (!declaredRange || !wantedRange || declaredRange === "*") { + return false; + } + return semver.gt( + semver.minVersion(wantedRange) ?? "0.0.0", + semver.minVersion(declaredRange) ?? "0.0.0", + ); +} + +/** + * Replace a version in the text of a manifest, as serializing a parsed manifest + * would lose its comments and formatting. + */ +function replaceCrateVersion({ + contents, + table, + crate, + version, +}: { + contents: string; + table: string; + crate: string; + version: string; +}) { + let section = ""; + const lines = contents.split("\n").map((line) => { + const header = line.match(/^\s*\[([^[\]]+)\]/); + if (header) { + section = header[1].trim(); + return line; + } + const inTable = section === table && line.match(`^\\s*"?${crate}"?\\s*=`); + const inCrateTable = + section === `${table}.${crate}` && line.match(/^\s*version\s*=/); + if (!inTable && !inCrateTable) { + return line; + } + const pattern = inTable + ? /(=\s*(?:\{[^}]*?\bversion\s*=\s*)?)(["'])[^"']*\2/ + : /(=\s*)(["'])[^"']*\2/; + return line.replace(pattern, `$1$2${version}$2`); + }); + return lines.join("\n"); +} + +function getCrateDeclaration( + manifest: Record, + table: string, + crate: string, +) { + const dependencies = manifest[table]; + return isRecord(dependencies) ? dependencies[crate] : undefined; +} + +function getStringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item) => typeof item === "string") + : []; +} + +function inspectFeatures(dependencies: Record) { + const result: string[] = []; + const napi = dependencies["napi"]; + if (isRecord(napi) || typeof napi === "string") { + if (!isRecord(napi) || napi["default-features"] !== false) { + result.push( + `Cargo.toml doesn't declare ${chalk.blue("default-features = false")} on ${chalk.blue("napi")}, which ferric build rejects`, + ); + } + const features = isRecord(napi) ? getStringArray(napi["features"]) : []; + if (getNodeApiVersion(features) > MAX_NODE_API_VERSION) { + result.push( + `Cargo.toml enables a ${chalk.blue("napi")} feature above the Node-API version ${MAX_NODE_API_VERSION} supported by the host`, + ); + } + } + const napiSys = dependencies["napi-sys"]; + if (isRecord(napiSys) || typeof napiSys === "string") { + const version = isRecord(napiSys) ? napiSys["version"] : napiSys; + if (typeof version === "string" && version.startsWith("=")) { + result.push( + `Cargo.toml pins ${chalk.blue(`napi-sys ${version}`)}, which is likely to conflict with newer ${chalk.blue("napi")} versions: remove the pin`, + ); + } + const features = isRecord(napiSys) + ? getStringArray(napiSys["features"]) + : []; + if (features.includes("dyn-symbols")) { + result.push( + `Cargo.toml enables the ${chalk.blue("dyn-symbols")} feature of ${chalk.blue("napi-sys")}, which ferric build rejects`, + ); + } + } + return result; +} + +/** + * Bump outdated crates of an existing manifest in place and report anything else + * it needs to build into a Node-API module. Everything but those versions is + * left to the developer. + */ +export function updateCargoManifest( + contents: string, + crateVersions: CrateVersions, +) { + let manifest: unknown; + try { + manifest = parseToml(contents); + } catch (error) { + return { + contents, + notices: [ + `Cargo.toml could not be parsed: ${error instanceof Error ? error.message : String(error)}`, + ], + }; + } + if (!isRecord(manifest)) { + return { contents, notices: ["Cargo.toml doesn't declare a package"] }; + } + const parsed = manifest; + const notices: string[] = []; + const lib = parsed["lib"]; + const crateType = isRecord(lib) ? lib["crate-type"] : undefined; + if (!Array.isArray(crateType) || !crateType.includes("cdylib")) { + notices.push( + `Cargo.toml doesn't declare ${chalk.blue('crate-type = ["cdylib"]')} in its ${chalk.blue("[lib]")} section, which Node-API modules are built as`, + ); + } + + let updated = contents; + for (const { crate, table } of CRATES) { + const wanted = crateVersions[crate]; + const declaration = getCrateDeclaration(parsed, table, crate); + if (declaration === undefined) { + notices.push( + `Cargo.toml is missing ${chalk.blue(crate)} from its ${chalk.blue(`[${table}]`)}`, + ); + continue; + } + const declared = isRecord(declaration) + ? declaration["version"] + : declaration; + if (typeof declared !== "string") { + notices.push( + `Cargo.toml doesn't declare ${chalk.blue(crate)} with a version: make sure it resolves to ${chalk.blue(wanted)} or newer`, + ); + } else if (isOutdated(declared, wanted)) { + const replaced = replaceCrateVersion({ + contents: updated, + table, + crate, + version: wanted, + }); + const result: unknown = parseToml(replaced); + const resolved = isRecord(result) + ? getCrateDeclaration(result, table, crate) + : undefined; + const version = isRecord(resolved) ? resolved["version"] : resolved; + if (version === wanted) { + updated = replaced; + } else { + notices.push( + `Couldn't update ${chalk.blue(crate)} in Cargo.toml: change ${chalk.blue(declared)} to ${chalk.blue(wanted)}`, + ); + } + } + } + + const dependencies = parsed["dependencies"]; + if (isRecord(dependencies)) { + notices.push(...inspectFeatures(dependencies)); + } + return { contents: updated, notices }; +} diff --git a/packages/ferric/src/init.test.ts b/packages/ferric/src/init.test.ts new file mode 100644 index 00000000..f7929a17 --- /dev/null +++ b/packages/ferric/src/init.test.ts @@ -0,0 +1,542 @@ +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 { parse as parseToml } from "smol-toml"; + +import { + applyChanges, + assertDistinctChanges, + 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, + "..", + "..", + "ferric-example", +); + +interface FileMap { + [filePath: string]: string; +} + +function setupTempDirectory(context: TestContext, files: FileMap = {}) { + const tempPath = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "ferric-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 keysOf(value: unknown) { + assert.ok(typeof value === "object" && value !== null); + return Object.keys(value); +} + +function changedPaths(plan: InitPlan, packagePath: string) { + return plan.changes + .map(({ path: filePath }) => path.relative(packagePath, filePath)) + .map((filePath) => filePath.split(path.sep).join("/")) + .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("crateNameFromPackageName", () => { + it("strips the scope", () => { + assert.equal(crateNameFromPackageName("@my-org/my-addon"), "my-addon"); + assert.equal(crateNameFromPackageName("my-addon"), "my-addon"); + }); +}); + +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); + const packagePath = path.join(tempPath, "my-addon"); + const plan = await planInit({ packagePath }); + + assert.equal(plan.crateName, "my-addon"); + assert.equal(plan.libraryName, "my_addon"); + assert.deepEqual(changedPaths(plan, packagePath), [ + ".gitignore", + "Cargo.toml", + "build.rs", + "package.json", + "src/lib.rs", + ]); + + await applyChanges(plan.changes); + + const packageJson = readPackageJson(packagePath); + assert.equal(packageJson["name"], "my-addon"); + assert.equal(packageJson["main"], "my_addon.js"); + assert.equal(packageJson["types"], "my_addon.d.ts"); + assert.deepEqual(packageJson["files"], [ + "my_addon.js", + "my_addon.d.ts", + "my_addon.apple.node", + "my_addon.android.node", + ]); + assert.deepEqual(packageJson["scripts"], { + build: "ferric build", + "build:types": "ferric build --dts-only", + }); + assert.deepEqual(keysOf(packageJson["dependencies"]), [ + "react-native-node-api", + ]); + assert.deepEqual(keysOf(packageJson["devDependencies"]), ["ferric-cli"]); + + const cargoToml = fs.readFileSync( + path.join(packagePath, "Cargo.toml"), + "utf8", + ); + assert.match(cargoToml, /^name = "my-addon"$/m); + assert.doesNotMatch(cargoToml, /{{name}}/); + assert.match(cargoToml, /crate-type = \["cdylib"\]/); + + assert.ok(fs.existsSync(path.join(packagePath, "src", "lib.rs"))); + assert.ok(fs.existsSync(path.join(packagePath, "build.rs"))); + }); + + it("is a no-op when run again", async (context) => { + const tempPath = setupTempDirectory(context); + const packagePath = path.join(tempPath, "my-addon"); + await applyChanges((await planInit({ packagePath })).changes); + + const plan = await planInit({ packagePath }); + assert.deepEqual(plan.changes, []); + assert.deepEqual(plan.notices, []); + }); + + it("writes nothing while planning", async (context) => { + const tempPath = setupTempDirectory(context); + const packagePath = path.join(tempPath, "my-addon"); + const plan = await planInit({ packagePath }); + assert.ok(plan.changes.length > 0); + assert.equal(fs.existsSync(packagePath), false); + }); + + it("derives the crate name from a scoped package name", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify({ name: "@my-org/my-addon" }, null, 2), + }); + const plan = await planInit({ packagePath }); + assert.equal(plan.crateName, "my-addon"); + assert.equal(plan.libraryName, "my_addon"); + await applyChanges(plan.changes); + assert.equal(readPackageJson(packagePath)["name"], "@my-org/my-addon"); + assert.equal(readPackageJson(packagePath)["main"], "my_addon.js"); + }); + + it("uses the crate name passed by the caller", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify({ name: "my-addon" }, null, 2), + }); + const plan = await planInit({ packagePath, crateName: "other_name" }); + assert.equal(plan.libraryName, "other_name"); + }); + + it("rejects a crate name Cargo won't accept", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify({ name: "@my-org/123" }, null, 2), + }); + await assert.rejects(() => planInit({ packagePath }), /crate name/); + }); + + it("keeps the values an existing package declares", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify( + { + name: "my-addon", + version: "2.1.0", + main: "index.js", + files: ["index.js"], + scripts: { build: "make" }, + devDependencies: { "ferric-cli": "999.0.0" }, + }, + null, + 2, + ), + }); + await applyChanges((await planInit({ packagePath })).changes); + + const packageJson = readPackageJson(packagePath); + assert.equal(packageJson["version"], "2.1.0"); + assert.equal(packageJson["main"], "index.js"); + assert.deepEqual(packageJson["scripts"], { + build: "make", + "build:types": "ferric build --dts-only", + }); + assert.deepEqual(packageJson["devDependencies"], { + "ferric-cli": "999.0.0", + }); + // The artifacts are added to an existing "files", which would otherwise + // keep them out of the published package + assert.deepEqual(packageJson["files"], [ + "index.js", + "my_addon.js", + "my_addon.d.ts", + "my_addon.apple.node", + "my_addon.android.node", + ]); + }); + + it("notices what an existing Cargo manifest lacks", async (context) => { + const packagePath = setupTempDirectory(context, { + "Cargo.toml": [ + "[package]", + 'name = "renamed-by-hand"', + "", + "[dependencies]", + 'napi = "3.14.2"', + "", + ].join("\n"), + }); + const plan = await planInit({ packagePath }); + assert.equal( + plan.changes.some(({ path: filePath }) => + filePath.endsWith("Cargo.toml"), + ), + false, + ); + const notices = plan.notices.join("\n"); + assert.match(notices, /cdylib/); + assert.match(notices, /napi-derive/); + assert.match(notices, /napi-build/); + assert.match(notices, /default-features/); + }); + + it("bumps outdated crates of an existing Cargo manifest in place", async (context) => { + const packagePath = setupTempDirectory(context, { + "Cargo.toml": [ + "# Keep this comment", + "[lib]", + 'crate-type = ["cdylib"]', + "", + "[dependencies.napi]", + 'version = "=3.4.0" # and this one', + "default-features = false", + "", + "[dependencies]", + 'napi-derive = { version = "3.3.0", features = ["type-def"] }', + "", + "[build-dependencies]", + 'napi-build = "9.0.0"', + "", + ].join("\n"), + }); + const plan = await planInit({ packagePath }); + const change = plan.changes.find(({ path: filePath }) => + filePath.endsWith("Cargo.toml"), + ); + assert(change); + assert.equal( + change.contents, + [ + "# Keep this comment", + "[lib]", + 'crate-type = ["cdylib"]', + "", + "[dependencies.napi]", + `version = "${DEFAULT_CRATE_VERSIONS.napi}" # and this one`, + "default-features = false", + "", + "[dependencies]", + `napi-derive = { version = "${DEFAULT_CRATE_VERSIONS["napi-derive"]}", features = ["type-def"] }`, + "", + "[build-dependencies]", + 'napi-build = "9.0.0"', + "", + ].join("\n"), + ); + assert.deepEqual(plan.notices, []); + }); + + it("notices crates it cannot bump and a stale napi-sys pin", async (context) => { + const packagePath = setupTempDirectory(context, { + "Cargo.toml": [ + "[lib]", + 'crate-type = ["cdylib"]', + "", + "[dependencies]", + "napi = { workspace = true, default-features = false }", + 'napi-derive = "3.6.12"', + 'napi-sys = { version = "=3.0.1", features = ["dyn-symbols"] }', + "", + "[build-dependencies]", + 'napi-build = "2.6.0"', + "", + ].join("\n"), + }); + const notices = (await planInit({ packagePath })).notices.join("\n"); + assert.match(notices, /doesn't declare .*napi.* with a version/); + assert.match(notices, /pins .*napi-sys/); + assert.match(notices, /dyn-symbols/); + }); + + it("notices options which only apply to a new Cargo manifest", async (context) => { + const packagePath = setupTempDirectory(context, { "Cargo.toml": "" }); + const { notices } = await planInit({ + packagePath, + nodeApiVersion: 10, + releaseProfile: "size", + }); + assert.match(notices.join("\n"), /--node-api-version/); + assert.match(notices.join("\n"), /--release-profile/); + }); + + it("writes the options into a new Cargo manifest", async (context) => { + const packagePath = setupTempDirectory(context); + const plan = await planInit({ + packagePath, + nodeApiVersion: 10, + crateVersions: { napi: "3.15.0", "napi-build": "2.7.0" }, + releaseProfile: "size", + }); + const change = plan.changes.find(({ path: filePath }) => + filePath.endsWith("Cargo.toml"), + ); + assert(change); + const manifest = parseJsonObject( + JSON.stringify(parseToml(change.contents)), + ); + assert.deepEqual(manifest["dependencies"], { + napi: { + version: "3.15.0", + "default-features": false, + features: ["napi10"], + }, + "napi-derive": { + version: DEFAULT_CRATE_VERSIONS["napi-derive"], + features: ["type-def"], + }, + }); + assert.deepEqual(manifest["build-dependencies"], { "napi-build": "2.7.0" }); + assert.deepEqual(manifest["profile"], { + release: { + lto: true, + "codegen-units": 1, + "opt-level": "z", + strip: true, + }, + }); + }); + + it("omits the release profile when asked to", async (context) => { + const packagePath = setupTempDirectory(context); + const plan = await planInit({ packagePath, releaseProfile: "none" }); + const change = plan.changes.find(({ path: filePath }) => + filePath.endsWith("Cargo.toml"), + ); + assert(change); + assert.equal(parseToml(change.contents)["profile"], undefined); + assert.match(change.contents, /"\r?\n$/); + }); + + it("defaults to Node-API version 3", async (context) => { + const packagePath = setupTempDirectory(context); + const plan = await planInit({ packagePath }); + assert.equal(DEFAULT_NODE_API_VERSION, 3); + const change = plan.changes.find(({ path: filePath }) => + filePath.endsWith("Cargo.toml"), + ); + assert(change); + assert.match(change.contents, /features = \["napi3"\]/); + }); + + it("rejects versions it cannot use", async (context) => { + const packagePath = setupTempDirectory(context); + await assert.rejects(planInit({ packagePath, nodeApiVersion: 11 })); + await assert.rejects(planInit({ packagePath, nodeApiVersion: Number.NaN })); + await assert.rejects( + planInit({ packagePath, crateVersions: { napi: "not-a-version" } }), + ); + }); + + it("bumps outdated dependencies but keeps newer and non-version ones", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify({ + name: "my-addon", + peerDependencies: { "react-native-node-api": "^999.0.0" }, + devDependencies: { "ferric-cli": "^0.0.1" }, + }), + }); + const plan = await planInit({ packagePath }); + await applyChanges(plan.changes); + const packageJson = readPackageJson(packagePath); + const { ferricSpecifier } = determineDependencySpecifiers(undefined); + assert.deepEqual(packageJson["peerDependencies"], { + "react-native-node-api": "^999.0.0", + }); + assert.deepEqual(packageJson["devDependencies"], { + "ferric-cli": ferricSpecifier, + }); + }); + + it("skips differing entrypoints and source files", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify({ + name: "my-addon", + main: "index.js", + type: "module", + }), + "src/lib.rs": "// mine\n", + }); + const plan = await planInit({ packagePath }); + await applyChanges(plan.changes); + + assert.equal(readPackageJson(packagePath)["main"], "index.js"); + assert.equal( + fs.readFileSync(path.join(packagePath, "src/lib.rs"), "utf8"), + "// mine\n", + ); + const skipped = plan.skips.map(({ path: filePath }) => + path.relative(packagePath, filePath), + ); + assert.deepEqual(skipped.sort(), [ + "package.json", + path.join("src", "lib.rs"), + ]); + const packageJsonSkip = plan.skips.find(({ path: filePath }) => + filePath.endsWith("package.json"), + ); + assert(packageJsonSkip); + assert.match( + packageJsonSkip.hint ?? "", + /export \{ default \} from "\.\/my_addon\.js"/, + ); + assert.match(packageJsonSkip.contents, /"main": "my_addon\.js"/); + }); + + it("overwrites them when forced", async (context) => { + const packagePath = setupTempDirectory(context, { + "package.json": JSON.stringify({ name: "my-addon", main: "index.js" }), + "src/lib.rs": "// mine\n", + }); + const plan = await planInit({ packagePath, force: true }); + await applyChanges(plan.changes); + + assert.deepEqual(plan.skips, []); + assert.equal(readPackageJson(packagePath)["main"], "my_addon.js"); + assert.match( + fs.readFileSync(path.join(packagePath, "src/lib.rs"), "utf8"), + /#\[napi\]/, + ); + }); + + it("notices a Cargo manifest it cannot parse", async (context) => { + const packagePath = setupTempDirectory(context, { + "Cargo.toml": "[package\nname =\n", + }); + const plan = await planInit({ packagePath }); + assert.equal(plan.notices.length, 1); + assert.match(plan.notices[0], /could not be parsed/); + }); + + it("adds the package to a pnpm workspace", async (context) => { + const tempPath = setupTempDirectory(context, { + "pnpm-workspace.yaml": ["packages:", " - apps/*", ""].join("\n"), + }); + const packagePath = path.join(tempPath, "packages", "my-addon"); + const plan = await planInit({ packagePath }); + await applyChanges(plan.changes); + + assert.equal( + fs.readFileSync(path.join(tempPath, "pnpm-workspace.yaml"), "utf8"), + ["packages:", " - apps/*", " - packages/my-addon", ""].join("\n"), + ); + }); + + it("leaves a workspace already covering the package alone", async (context) => { + const tempPath = setupTempDirectory(context, { + "pnpm-workspace.yaml": ["packages:", " - packages/*", ""].join("\n"), + }); + const packagePath = path.join(tempPath, "packages", "my-addon"); + const plan = await planInit({ packagePath }); + assert.deepEqual( + plan.changes.filter(({ path: filePath }) => + filePath.startsWith(path.join(tempPath, "pnpm-workspace.yaml")), + ), + [], + ); + }); + + it("notices a workspace it cannot edit", async (context) => { + const tempPath = setupTempDirectory(context, { + "pnpm-workspace.yaml": ['packages: ["apps/*"]', ""].join("\n"), + }); + const packagePath = path.join(tempPath, "packages", "my-addon"); + const plan = await planInit({ packagePath }); + assert.equal(plan.changes.length, 5); + assert.match(plan.notices.join("\n"), /packages\/my-addon/); + }); + + it("has nothing to change about the ferric-example package", async () => { + // The example is what a package initialized by this command grows into, so + // it doubles as the fixture asserting an already initialized package is + // left untouched (see #299). + const packagePath = FERRIC_EXAMPLE_PATH; + const plan = await planInit({ packagePath }); + assert.deepEqual(changedPaths(plan, packagePath), []); + assert.deepEqual(plan.skips, []); + 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 new file mode 100644 index 00000000..13a2f619 --- /dev/null +++ b/packages/ferric/src/init.ts @@ -0,0 +1,824 @@ +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, + assertFixable, + prettyPath, + wrapAction, +} 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"); + +export const DEFAULT_NODE_API_VERSION = 3; + +export const DEFAULT_CRATE_VERSIONS: CrateVersions = { + napi: "3.14.2", + "napi-derive": "3.6.12", + "napi-build": "2.6.0", +}; + +const SPEED_PROFILE = ["lto = true", "codegen-units = 1"]; + +const RELEASE_PROFILES = { + speed: SPEED_PROFILE, + size: [...SPEED_PROFILE, 'opt-level = "z"', "strip = true"], + none: [], +} satisfies Record; + +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; + crateName: string; + libraryName: string; + changes: Change[]; + skips: Skip[]; + notices: string[]; +}; + +export type InitOptions = { + /** Directory of the package to initialize. */ + packagePath: string; + /** Name of the Cargo crate, derived from the package name when omitted. */ + crateName?: string; + /** Node-API version enabled when creating a Cargo manifest. */ + nodeApiVersion?: number; + /** Versions of the napi crates, overriding the defaults. */ + crateVersions?: Partial; + /** Release profile written when creating a Cargo manifest. */ + releaseProfile?: ReleaseProfile; + /** Overwrite entrypoints and source files instead of skipping them. */ + force?: boolean; +}; + +type PackageJson = Record; + +type InitContext = { + packagePath: string; + packageName: string; + crateName: string; + libraryName: string; + /** The package.json of an existing package, as read from disk. */ + existingPackageJson: string | undefined; + packageJson: PackageJson | undefined; + workspace: Workspace | undefined; + crateVersions: CrateVersions; + nodeApiVersion: number | undefined; + releaseProfile: ReleaseProfile | undefined; + 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. + */ +export async function planInit({ + packagePath, + crateName: crateNameOption, + nodeApiVersion, + crateVersions: crateVersionsOption, + releaseProfile, + force = false, +}: InitOptions): Promise { + const resolvedPath = path.resolve(packagePath); + const crateVersions = { ...DEFAULT_CRATE_VERSIONS, ...crateVersionsOption }; + for (const [crate, version] of Object.entries(crateVersions)) { + assertFixable( + isCrateVersion(version), + `Cannot use ${chalk.blue(version)} as the version of ${chalk.blue(crate)}`, + { instructions: "Pass a Cargo version requirement, such as 3.14.2" }, + ); + } + assertFixable( + nodeApiVersion === undefined || + (Number.isInteger(nodeApiVersion) && + nodeApiVersion >= 1 && + nodeApiVersion <= MAX_NODE_API_VERSION), + `Cannot use ${chalk.blue(String(nodeApiVersion))} as the Node-API version`, + { + instructions: `Pass a version between 1 and ${MAX_NODE_API_VERSION}, the highest one implemented by the host`, + }, + ); + + 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 crateName = crateNameOption ?? crateNameFromPackageName(packageName); + assertFixable( + /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(crateName), + `Cannot use ${chalk.blue(crateName)} as a crate name`, + { + instructions: + "Pass --name with a crate name starting with a letter, followed by letters, digits, dashes or underscores", + }, + ); + // Cargo normalizes dashes to underscores for the artifact it produces, which + // is the basename `ferric build` writes its outputs and entrypoint with. + const libraryName = crateName.replaceAll("-", "_"); + + const context: InitContext = { + packagePath: resolvedPath, + packageName, + crateName, + libraryName, + existingPackageJson, + packageJson, + workspace: findWorkspace(resolvedPath), + crateVersions, + nodeApiVersion, + releaseProfile, + force, + }; + const { changes, skips, notices } = mergeStepResults( + await Promise.all(INIT_STEPS.map(async (step) => step(context))), + ); + assertDistinctChanges(changes); + + return { + packagePath: resolvedPath, + packageName, + crateName, + libraryName, + changes, + skips, + notices, + }; +} + +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 planCargoManifest: InitStep = async ({ + packagePath, + crateName, + crateVersions, + nodeApiVersion, + releaseProfile, +}) => { + const cargoTomlPath = path.join(packagePath, "Cargo.toml"); + const previous = await readFileIfExists(cargoTomlPath); + if (previous === undefined) { + return { + changes: [ + { + kind: "create", + path: cargoTomlPath, + contents: renderCargoManifest(await readTemplate("Cargo.toml"), { + crateName, + nodeApiVersion: nodeApiVersion ?? DEFAULT_NODE_API_VERSION, + crateVersions, + releaseProfile: releaseProfile ?? "speed", + }), + }, + ], + }; + } + const { contents, notices } = updateCargoManifest(previous, crateVersions); + const ignoredOptions = Object.entries({ + "--node-api-version": nodeApiVersion, + "--release-profile": releaseProfile, + }).filter(([, value]) => value !== undefined); + return { + changes: + contents === previous + ? [] + : [{ kind: "update", path: cargoTomlPath, contents, previous }], + notices: [ + ...notices, + ...ignoredOptions.map( + ([option]) => + `Ignored ${chalk.blue(option)}, which only applies when creating a Cargo.toml`, + ), + ], + }; +}; + +async function planSourceFile( + { packagePath, force }: InitContext, + 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 }] }; +} + +const planSourceFiles: InitStep = async (context) => + mergeStepResults( + await Promise.all([ + planSourceFile(context, "build.rs", "build.rs"), + planSourceFile(context, "lib.rs", path.join("src", "lib.rs")), + ]), + ); + +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 INIT_STEPS: InitStep[] = [ + planPackageJson, + planCargoManifest, + planSourceFiles, + planGitignore, + planWorkspace, +]; + +function renderCargoManifest( + template: string, + { + crateName, + nodeApiVersion, + crateVersions, + releaseProfile, + }: { + crateName: string; + nodeApiVersion: number; + crateVersions: CrateVersions; + releaseProfile: ReleaseProfile; + }, +) { + const profile = RELEASE_PROFILES[releaseProfile]; + return template + .replaceAll("{{name}}", crateName) + .replaceAll("{{nodeApiVersion}}", String(nodeApiVersion)) + .replaceAll("{{napiCrateVersion}}", crateVersions.napi) + .replaceAll("{{napiDeriveCrateVersion}}", crateVersions["napi-derive"]) + .replaceAll("{{napiBuildCrateVersion}}", crateVersions["napi-build"]) + .replace( + /\{\{releaseProfile\}\}\r?\n/, + profile.length > 0 + ? ["", "[profile.release]", ...profile, ""].join("\n") + : "", + ); +} + +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(/^@[^/]+\//, ""); +} + +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. + */ +function getPackageJsonUpdates({ + packageJson, + packageName, + libraryName, + workspace, +}: InitContext): PackageJsonUpdate[] { + const { hostSpecifier, ferricSpecifier } = + determineDependencySpecifiers(workspace); + return [ + withMissing({ + name: packageName, + version: "0.1.0", + type: "commonjs", + ...getEntrypoints(libraryName), + }), + withFiles( + [ + `${libraryName}.js`, + `${libraryName}.d.ts`, + `${libraryName}.apple.node`, + `${libraryName}.android.node`, + ], + packageJson === undefined, + ), + withMissingScripts({ + build: "ferric build", + "build:types": "ferric build --dts-only", + }), + withDependency({ + name: "react-native-node-api", + specifier: hostSpecifier, + keys: ["dependencies", "devDependencies", "peerDependencies"], + }), + withDependency({ + name: "ferric-cli", + specifier: ferricSpecifier, + keys: ["devDependencies", "dependencies", "peerDependencies"], + }), + ]; +} + +/** + * 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", +).default(undefined, "derived from the package name"); + +const dryRunOption = new Option( + "--dry-run", + "Print the changes instead of applying them", +).default(false); + +const nodeApiVersionOption = new Option( + "--node-api-version ", + "Node-API version enabled when creating a Cargo.toml", +) + .argParser((value) => parseInt(value, 10)) + .default(undefined, String(DEFAULT_NODE_API_VERSION)); + +const napiCrateVersionOption = new Option( + "--napi-crate-version ", + "Version of the napi crate", +).default(undefined, DEFAULT_CRATE_VERSIONS.napi); + +const napiDeriveCrateVersionOption = new Option( + "--napi-derive-crate-version ", + "Version of the napi-derive crate", +).default(undefined, DEFAULT_CRATE_VERSIONS["napi-derive"]); + +const napiBuildCrateVersionOption = new Option( + "--napi-build-crate-version ", + "Version of the napi-build crate", +).default(undefined, DEFAULT_CRATE_VERSIONS["napi-build"]); + +const releaseProfileOption = new Option( + "--release-profile ", + "Release profile written when creating a Cargo.toml", +) + .choices(["speed", "size", "none"] satisfies ReleaseProfile[]) + .default(undefined, "speed"); + +const forceOption = new Option( + "--force", + "Overwrite entrypoints and source files which differ, instead of skipping them", +).default(false); + +export const initCommand = new Command("init") + .description("Scaffold a package building a Rust Node-API module") + .argument("[path]", "Path of the package directory", ".") + .addOption(nameOption) + .addOption(dryRunOption) + .addOption(nodeApiVersionOption) + .addOption(napiCrateVersionOption) + .addOption(napiDeriveCrateVersionOption) + .addOption(napiBuildCrateVersionOption) + .addOption(releaseProfileOption) + .addOption(forceOption) + .action( + wrapAction( + async ( + packagePath: string, + { + name, + dryRun, + nodeApiVersion, + napiCrateVersion, + napiDeriveCrateVersion, + napiBuildCrateVersion, + releaseProfile, + force, + }, + ) => { + const plan = await planInit({ + packagePath, + crateName: name, + nodeApiVersion, + crateVersions: Object.fromEntries( + Object.entries({ + napi: napiCrateVersion, + "napi-derive": napiDeriveCrateVersion, + "napi-build": napiBuildCrateVersion, + }).filter(([, version]) => version !== undefined), + ), + releaseProfile, + force, + }); + if (!dryRun) { + await applyChanges(plan.changes); + } + printPlan(plan, dryRun); + }, + ), + ); + +/** + * 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/packages/ferric/src/json.ts b/packages/ferric/src/json.ts new file mode 100644 index 00000000..f4bf9e02 --- /dev/null +++ b/packages/ferric/src/json.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Indentation used by the first indented line, so rewrites of a hand-maintained + * JSON file keep the style it was checked in with. + */ +export function detectJsonIndentation(contents: string) { + const match = contents.match(/\n(\s+)\S/); + return match ? match[1] : " "; +} + +export function stringifyJson(value: unknown, indentation = " ") { + return JSON.stringify(value, null, indentation) + "\n"; +} + +export function parseJsonObject(contents: string) { + const value: unknown = JSON.parse(contents); + assert(isRecord(value), "Expected a JSON object"); + return value; +} + +export function getObjectProperty(value: Record, key: string) { + const property = value[key]; + if (property === undefined) { + return undefined; + } + assert(isRecord(property), `Expected "${key}" to be an object`); + return property; +} + +export function getStringArrayProperty( + value: Record, + key: string, +) { + const property = value[key]; + if (property === undefined) { + return undefined; + } + assert(Array.isArray(property), `Expected "${key}" to be an array`); + const result: string[] = []; + for (const item of property) { + assert(typeof item === "string", `Expected "${key}" to contain strings`); + result.push(item); + } + return result; +} diff --git a/packages/ferric/src/program.ts b/packages/ferric/src/program.ts index d47d0479..507b19e7 100644 --- a/packages/ferric/src/program.ts +++ b/packages/ferric/src/program.ts @@ -2,8 +2,10 @@ import { Command } from "@react-native-node-api/cli-utils"; import { printBanner } from "./banner.js"; import { buildCommand } from "./build.js"; +import { initCommand } from "./init.js"; export const program = new Command("ferric") .hook("preAction", () => printBanner()) .description("Rust Node-API Modules for React Native") - .addCommand(buildCommand, { isDefault: true }); + .addCommand(buildCommand, { isDefault: true }) + .addCommand(initCommand); diff --git a/packages/ferric/src/workspaces.test.ts b/packages/ferric/src/workspaces.test.ts new file mode 100644 index 00000000..df6c9a76 --- /dev/null +++ b/packages/ferric/src/workspaces.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + addWorkspacePattern, + isPathCovered, + parseNpmWorkspacePatterns, + parsePnpmWorkspacePatterns, + type Workspace, +} from "./workspaces.js"; + +const PNPM_WORKSPACE: Workspace = { + kind: "pnpm", + rootPath: "/repo", + configPath: "/repo/pnpm-workspace.yaml", + patterns: [], +}; + +const NPM_WORKSPACE: Workspace = { + kind: "npm", + rootPath: "/repo", + configPath: "/repo/package.json", + patterns: [], +}; + +describe("parsePnpmWorkspacePatterns", () => { + it("reads a block sequence", () => { + assert.deepEqual( + parsePnpmWorkspacePatterns( + [ + "packages:", + " - packages/*", + ' - "apps/test-app" # quoted', + " - '!packages/excluded'", + "", + "onlyBuiltDependencies:", + " - esbuild", + ].join("\n"), + ), + ["packages/*", "apps/test-app", "!packages/excluded"], + ); + }); + + it("returns undefined for a flow sequence", () => { + assert.equal( + parsePnpmWorkspacePatterns('packages: ["packages/*"]'), + undefined, + ); + }); + + it("returns undefined without a packages key", () => { + assert.equal( + parsePnpmWorkspacePatterns("onlyBuiltDependencies:\n"), + undefined, + ); + }); +}); + +describe("parseNpmWorkspacePatterns", () => { + it("reads an array", () => { + assert.deepEqual( + parseNpmWorkspacePatterns('{ "workspaces": ["packages/*"] }'), + ["packages/*"], + ); + }); + + it("reads the yarn object form", () => { + assert.deepEqual( + parseNpmWorkspacePatterns( + '{ "workspaces": { "packages": ["packages/*"], "nohoist": [] } }', + ), + ["packages/*"], + ); + }); + + it("returns undefined without workspaces", () => { + assert.equal(parseNpmWorkspacePatterns('{ "name": "my-app" }'), undefined); + }); +}); + +describe("isPathCovered", () => { + it("matches a single path component per star", () => { + assert.equal(isPathCovered(["packages/*"], "packages/my-addon"), true); + assert.equal( + isPathCovered(["packages/*"], "packages/nested/my-addon"), + false, + ); + }); + + it("matches any depth per double star", () => { + assert.equal( + isPathCovered(["packages/**"], "packages/nested/my-addon"), + true, + ); + }); + + it("matches an exact path", () => { + assert.equal(isPathCovered(["apps/test-app"], "apps/test-app"), true); + assert.equal(isPathCovered(["apps/test-app"], "apps/test-apple"), false); + }); + + it("applies negations after matches", () => { + assert.equal( + isPathCovered(["packages/*", "!packages/my-addon"], "packages/my-addon"), + false, + ); + assert.equal(isPathCovered([], "packages/my-addon"), false); + }); +}); + +describe("addWorkspacePattern", () => { + it("appends to a pnpm block sequence", () => { + assert.equal( + addWorkspacePattern( + PNPM_WORKSPACE, + [ + "packages:", + " - apps/*", + "", + "onlyBuiltDependencies:", + " - esbuild", + "", + ].join("\n"), + "packages/my-addon", + ), + [ + "packages:", + " - apps/*", + " - packages/my-addon", + "", + "onlyBuiltDependencies:", + " - esbuild", + "", + ].join("\n"), + ); + }); + + it("gives up on a pnpm flow sequence", () => { + assert.equal( + addWorkspacePattern( + PNPM_WORKSPACE, + 'packages: ["apps/*"]\n', + "packages/my-addon", + ), + undefined, + ); + }); + + it("appends to a package.json array", () => { + assert.equal( + addWorkspacePattern( + NPM_WORKSPACE, + '{\n "workspaces": [\n "apps/*"\n ]\n}\n', + "packages/my-addon", + ), + '{\n "workspaces": [\n "apps/*",\n "packages/my-addon"\n ]\n}\n', + ); + }); + + it("appends to the yarn object form", () => { + assert.equal( + addWorkspacePattern( + NPM_WORKSPACE, + '{\n "workspaces": {\n "packages": [\n "apps/*"\n ]\n }\n}\n', + "packages/my-addon", + ), + '{\n "workspaces": {\n "packages": [\n "apps/*",\n "packages/my-addon"\n ]\n }\n}\n', + ); + }); +}); diff --git a/packages/ferric/src/workspaces.ts b/packages/ferric/src/workspaces.ts new file mode 100644 index 00000000..07eca0f3 --- /dev/null +++ b/packages/ferric/src/workspaces.ts @@ -0,0 +1,231 @@ +import fs from "node:fs"; +import path from "node:path"; + +import picomatch from "picomatch"; + +import { + detectJsonIndentation, + getStringArrayProperty, + getObjectProperty, + parseJsonObject, + stringifyJson, +} from "./json.js"; + +export type Workspace = { + kind: "pnpm" | "npm"; + /** Directory declaring the workspace. */ + rootPath: string; + /** File declaring the package patterns. */ + configPath: string; + /** + * Patterns of the workspace, or undefined if the file declares them in a form + * this can't read (such as a YAML flow sequence). + */ + patterns: string[] | undefined; +}; + +/** + * Find the workspace containing a package, by looking for a pnpm workspace file + * or a package.json declaring "workspaces" in the ancestor directories. + * Directories and files which can't be read or parsed are skipped: the walk + * passes through parts of the filesystem the user has no business in. + */ +export function findWorkspace(packagePath: string): Workspace | undefined { + let currentPath = path.dirname(path.resolve(packagePath)); + for (;;) { + const pnpmConfigPath = path.join(currentPath, "pnpm-workspace.yaml"); + const pnpmContents = readFileIfAccessible(pnpmConfigPath); + if (pnpmContents !== undefined) { + return { + kind: "pnpm", + rootPath: currentPath, + configPath: pnpmConfigPath, + patterns: ignoringErrors(() => + parsePnpmWorkspacePatterns(pnpmContents), + ), + }; + } + const packageJsonPath = path.join(currentPath, "package.json"); + const packageJsonContents = readFileIfAccessible(packageJsonPath); + if (packageJsonContents !== undefined) { + const patterns = ignoringErrors(() => + parseNpmWorkspacePatterns(packageJsonContents), + ); + if (patterns) { + return { + kind: "npm", + rootPath: currentPath, + configPath: packageJsonPath, + patterns, + }; + } + } + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return undefined; + } + currentPath = parentPath; + } +} + +const PNPM_PACKAGES_KEY = /^packages:(.*)$/; +const YAML_SEQUENCE_ITEM = /^(\s+)-\s*(.*)$/; + +export function parsePnpmWorkspacePatterns(contents: string) { + const lines = contents.split(/\r?\n/); + const keyIndex = lines.findIndex((line) => PNPM_PACKAGES_KEY.test(line)); + if (keyIndex < 0) { + return undefined; + } + const [, rest] = lines[keyIndex].match(PNPM_PACKAGES_KEY) ?? []; + if (stripComment(rest ?? "").trim().length > 0) { + // A flow sequence (or an anchor / alias) rather than a block sequence + return undefined; + } + const result: string[] = []; + for (const line of lines.slice(keyIndex + 1)) { + if (line.trim().length === 0 || line.trimStart().startsWith("#")) { + continue; + } + const match = line.match(YAML_SEQUENCE_ITEM); + if (!match) { + break; + } + result.push(parseYamlScalar(match[2])); + } + return result; +} + +export function parseNpmWorkspacePatterns(contents: string) { + const packageJson = parseJsonObject(contents); + const workspaces = packageJson["workspaces"]; + if (Array.isArray(workspaces)) { + return getStringArrayProperty(packageJson, "workspaces"); + } else if (typeof workspaces === "object" && workspaces !== null) { + // Yarn allows an object with "packages" (and "nohoist") + const object = getObjectProperty(packageJson, "workspaces"); + return object && getStringArrayProperty(object, "packages"); + } else { + return undefined; + } +} + +function stripComment(value: string) { + const match = value.match(/(^|\s)#/); + return match ? value.slice(0, match.index) : value; +} + +function parseYamlScalar(value: string) { + const quoted = value.match(/^(["'])(.*)\1/); + return quoted ? quoted[2] : stripComment(value).trim(); +} + +/** + * Determine if a path (relative to the workspace root, using forward slashes) + * is matched by the workspace patterns. A pattern prefixed with "!" excludes + * what it matches, which is why the last pattern matching wins. + */ +export function isPathCovered(patterns: string[], relativePath: string) { + let result = false; + for (const pattern of patterns) { + const negated = pattern.startsWith("!"); + if (picomatch.isMatch(relativePath, negated ? pattern.slice(1) : pattern)) { + result = !negated; + } + } + return result; +} + +export function relativeWorkspacePath( + workspace: Workspace, + packagePath: string, +) { + return path + .relative(workspace.rootPath, path.resolve(packagePath)) + .split(path.sep) + .join("/"); +} + +/** + * Produce the contents of the workspace configuration file with a package + * pattern added, or undefined if the file declares its packages in a form this + * can't safely edit. + */ +export function addWorkspacePattern( + workspace: Workspace, + contents: string, + pattern: string, +) { + return workspace.kind === "pnpm" + ? addPnpmWorkspacePattern(contents, pattern) + : addNpmWorkspacePattern(contents, pattern); +} + +function addPnpmWorkspacePattern(contents: string, pattern: string) { + const newline = contents.includes("\r\n") ? "\r\n" : "\n"; + const lines = contents.split(/\r?\n/); + const keyIndex = lines.findIndex((line) => PNPM_PACKAGES_KEY.test(line)); + if (keyIndex < 0) { + return undefined; + } + let insertIndex = -1; + let indentation = " "; + for (const [offset, line] of lines.slice(keyIndex + 1).entries()) { + if (line.trim().length === 0 || line.trimStart().startsWith("#")) { + continue; + } + const match = line.match(YAML_SEQUENCE_ITEM); + if (!match) { + break; + } + insertIndex = keyIndex + 1 + offset; + indentation = match[1]; + } + if (insertIndex < 0) { + // An empty (or flow) sequence: the block sequence we'd append to is missing + return undefined; + } + lines.splice(insertIndex + 1, 0, `${indentation}- ${pattern}`); + return lines.join(newline); +} + +function addNpmWorkspacePattern(contents: string, pattern: string) { + const packageJson = parseJsonObject(contents); + const indentation = detectJsonIndentation(contents); + const workspaces = packageJson["workspaces"]; + if (Array.isArray(workspaces)) { + const patterns = getStringArrayProperty(packageJson, "workspaces") ?? []; + return stringifyJson( + { ...packageJson, workspaces: [...patterns, pattern] }, + indentation, + ); + } + const object = getObjectProperty(packageJson, "workspaces"); + if (!object || !Array.isArray(object["packages"])) { + return undefined; + } + const patterns = getStringArrayProperty(object, "packages") ?? []; + return stringifyJson( + { + ...packageJson, + workspaces: { ...object, packages: [...patterns, pattern] }, + }, + indentation, + ); +} + +function readFileIfAccessible(filePath: string) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return undefined; + } +} + +function ignoringErrors(fn: () => T) { + try { + return fn(); + } catch { + return undefined; + } +} diff --git a/packages/ferric/templates/Cargo.toml b/packages/ferric/templates/Cargo.toml new file mode 100644 index 00000000..168e182e --- /dev/null +++ b/packages/ferric/templates/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "{{name}}" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[lib] +crate-type = ["cdylib"] + +[dependencies.napi] +version = "{{napiCrateVersion}}" +# see https://nodejs.org/api/n-api.html#node-api-version-matrix +default-features = false +features = ["napi{{nodeApiVersion}}"] + +[dependencies.napi-derive] +version = "{{napiDeriveCrateVersion}}" +features = ["type-def"] + +[build-dependencies] +napi-build = "{{napiBuildCrateVersion}}" +{{releaseProfile}} diff --git a/packages/ferric/templates/build.rs b/packages/ferric/templates/build.rs new file mode 100644 index 00000000..bbfc9e4b --- /dev/null +++ b/packages/ferric/templates/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/packages/ferric/templates/gitignore b/packages/ferric/templates/gitignore new file mode 100644 index 00000000..a225cf72 --- /dev/null +++ b/packages/ferric/templates/gitignore @@ -0,0 +1,5 @@ +/target + +/*.xcframework/ +/*.apple.node/ +/*.android.node/ diff --git a/packages/ferric/templates/lib.rs b/packages/ferric/templates/lib.rs new file mode 100644 index 00000000..ecfd7ab1 --- /dev/null +++ b/packages/ferric/templates/lib.rs @@ -0,0 +1,6 @@ +use napi_derive::napi; + +#[napi] +pub fn sum(a: i32, b: i32) -> i32 { + a + b +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca51f945..32790f22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,19 +193,38 @@ importers: dependencies: '@napi-rs/cli': specifier: ~3.10.8 - version: 3.10.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@22.20.1) + version: 3.10.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.20.1) '@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 + semver: + specifier: ^7.8.5 + version: 7.8.5 + smol-toml: + specifier: ^1.8.0 + version: 1.9.1 weak-node-api: specifier: workspace:* version: link:../weak-node-api zod: 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 packages/ferric-example: dependencies: @@ -298,7 +317,7 @@ importers: version: link:../gyp-to-cmake node-addon-examples: specifier: github:nodejs/node-addon-examples#4b7dd86a85644610e6de80154df9acac9329b509 - version: git+https://git@github.com:nodejs/node-addon-examples.git#4b7dd86a85644610e6de80154df9acac9329b509 + version: https://codeload.github.com/nodejs/node-addon-examples/tar.gz/4b7dd86a85644610e6de80154df9acac9329b509 read-pkg: specifier: ^9.0.1 version: 9.0.1 @@ -2337,9 +2356,15 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2933,6 +2958,10 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3922,8 +3951,8 @@ packages: resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} engines: {node: ^18 || ^20 || >= 21} - node-addon-examples@git+https://git@github.com:nodejs/node-addon-examples.git#4b7dd86a85644610e6de80154df9acac9329b509: - resolution: {commit: 4b7dd86a85644610e6de80154df9acac9329b509, repo: git@github.com:nodejs/node-addon-examples.git, type: git} + node-addon-examples@https://codeload.github.com/nodejs/node-addon-examples/tar.gz/4b7dd86a85644610e6de80154df9acac9329b509: + resolution: {tarball: https://codeload.github.com/nodejs/node-addon-examples/tar.gz/4b7dd86a85644610e6de80154df9acac9329b509} version: 1.0.0 node-api-headers@1.9.0: @@ -4421,6 +4450,10 @@ packages: resolution: {integrity: sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==} engines: {node: '>=6'} + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6142,7 +6175,7 @@ snapshots: tinyglobby: 0.2.17 yaml: 2.9.0 - '@napi-rs/cli@3.10.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@22.20.1)': + '@napi-rs/cli@3.10.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.20.1)': dependencies: '@inquirer/prompts': 8.7.3(@types/node@22.20.1) '@napi-rs/cross-toolchain': 1.0.3 @@ -6157,8 +6190,8 @@ snapshots: typanion: 3.14.0 typescript: 6.0.3 optionalDependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 transitivePeerDependencies: - '@napi-rs/cross-toolchain-arm64-target-aarch64' - '@napi-rs/cross-toolchain-arm64-target-armv7' @@ -6879,7 +6912,9 @@ snapshots: metro-runtime: 0.87.0 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.88.0-nightly-20260809-db662caea': {} @@ -7097,10 +7132,14 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/picomatch@4.0.3': {} + '@types/react@19.2.17': dependencies: csstype: 3.2.3 + '@types/semver@7.8.0': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -7767,6 +7806,8 @@ snapshots: diff@7.0.0: {} + diff@9.0.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8882,7 +8923,7 @@ snapshots: node-addon-api@8.9.0: {} - node-addon-examples@git+https://git@github.com:nodejs/node-addon-examples.git#4b7dd86a85644610e6de80154df9acac9329b509: + node-addon-examples@https://codeload.github.com/nodejs/node-addon-examples/tar.gz/4b7dd86a85644610e6de80154df9acac9329b509: dependencies: chalk: 5.6.2 cmake-js: 7.4.0 @@ -9519,6 +9560,8 @@ snapshots: astral-regex: 1.0.0 is-fullwidth-code-point: 2.0.0 + smol-toml@1.9.1: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/scripts/init-ferric-example.ts b/scripts/init-ferric-example.ts new file mode 100644 index 00000000..58402cb0 --- /dev/null +++ b/scripts/init-ferric-example.ts @@ -0,0 +1,67 @@ +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"; + +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", +);