From c7ba0a86a6ee4676fe4a2fa89341aa80737a6134 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 20:36:53 +0000 Subject: [PATCH 1/8] Add a `ferric init` command Scaffolds a package building a Rust Node-API module: the package.json, Cargo.toml, build.rs, src/lib.rs and .gitignore it needs, plus an entry in the workspace of the mono-repo it is initialized into. Run against a package that already exists, it updates rather than overwrites: every value already declared is kept, so a package only gains what it lacks, and an existing Cargo.toml is left untouched with notices about anything missing from it. `--dry-run` prints the plan without applying it. Ferric now runs its tests as part of the workspace test script, one of which asserts that initializing the checked-in ferric-example changes nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019peL1QhG54ysW6r3GKqkbo --- .changeset/ferric-init-command.md | 7 + packages/ferric/README.md | 34 ++ packages/ferric/package.json | 5 +- packages/ferric/src/init.test.ts | 285 +++++++++++++++ packages/ferric/src/init.ts | 469 +++++++++++++++++++++++++ packages/ferric/src/json.ts | 57 +++ packages/ferric/src/program.ts | 4 +- packages/ferric/src/toml.ts | 49 +++ packages/ferric/src/workspaces.test.ts | 175 +++++++++ packages/ferric/src/workspaces.ts | 232 ++++++++++++ packages/ferric/templates/Cargo.toml | 29 ++ packages/ferric/templates/build.rs | 3 + packages/ferric/templates/gitignore | 5 + packages/ferric/templates/lib.rs | 6 + 14 files changed, 1358 insertions(+), 2 deletions(-) create mode 100644 .changeset/ferric-init-command.md create mode 100644 packages/ferric/src/init.test.ts create mode 100644 packages/ferric/src/init.ts create mode 100644 packages/ferric/src/json.ts create mode 100644 packages/ferric/src/toml.ts create mode 100644 packages/ferric/src/workspaces.test.ts create mode 100644 packages/ferric/src/workspaces.ts create mode 100644 packages/ferric/templates/Cargo.toml create mode 100644 packages/ferric/templates/build.rs create mode 100644 packages/ferric/templates/gitignore create mode 100644 packages/ferric/templates/lib.rs diff --git a/.changeset/ferric-init-command.md b/.changeset/ferric-init-command.md new file mode 100644 index 00000000..11b7ef02 --- /dev/null +++ b/.changeset/ferric-init-command.md @@ -0,0 +1,7 @@ +--- +"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 updates instead of overwriting: values already declared are kept, an existing `Cargo.toml` is left alone (with notices about anything it lacks) and `--dry-run` prints the changes without applying them. diff --git a/packages/ferric/README.md b/packages/ferric/README.md index 5e9e0ad1..6e0698de 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -19,3 +19,37 @@ 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: +values already declared are left alone, so an existing `package.json` only gains +the scripts, dependencies and prebuilt artifacts it lacks. An existing +`Cargo.toml` is never rewritten — pinned versions, features and comments are +worth more than a mechanical update — but the command points out anything it +needs to build as a Node-API module. + +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. + +Pass `--dry-run` to print the changes without applying them: + +``` +npx ferric init packages/my-addon --dry-run +``` + +The [`ferric-example`](../ferric-example) package is what an initialized package +grows into: running `ferric init` in it is expected to be a no-op, which the +tests assert. diff --git a/packages/ferric/package.json b/packages/ferric/package.json index ec83168a..e38478f3 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" diff --git a/packages/ferric/src/init.test.ts b/packages/ferric/src/init.test.ts new file mode 100644 index 00000000..db818775 --- /dev/null +++ b/packages/ferric/src/init.test.ts @@ -0,0 +1,285 @@ +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, + crateNameFromPackageName, + determineDependencySpecifiers, + planInit, + type InitPlan, +} from "./init.js"; +import { findWorkspace } from "./workspaces.js"; + +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) { + const contents = fs.readFileSync( + path.join(packagePath, "package.json"), + "utf8", + ); + return JSON.parse(contents) as Record; +} + +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("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": "0.1.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": "0.1.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("leaves an existing Cargo manifest alone, noticing what it lacks", async (context) => { + const packagePath = setupTempDirectory(context, { + "Cargo.toml": [ + "[package]", + 'name = "renamed-by-hand"', + "", + "[dependencies]", + 'napi = "=3.4.0"', + "", + ].join("\n"), + }); + const plan = await planInit({ packagePath }); + assert.equal( + plan.changes.some(({ path: filePath }) => + filePath.endsWith("Cargo.toml"), + ), + false, + ); + assert.equal(plan.notices.length, 3); + assert.match(plan.notices.join("\n"), /cdylib/); + assert.match(plan.notices.join("\n"), /napi-derive/); + assert.match(plan.notices.join("\n"), /napi-build/); + }); + + 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 = path.resolve( + import.meta.dirname, + "../../ferric-example", + ); + const plan = await planInit({ packagePath }); + assert.deepEqual(changedPaths(plan, packagePath), []); + assert.deepEqual(plan.notices, []); + }); +}); diff --git a/packages/ferric/src/init.ts b/packages/ferric/src/init.ts new file mode 100644 index 00000000..909b181b --- /dev/null +++ b/packages/ferric/src/init.ts @@ -0,0 +1,469 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + chalk, + Command, + Option, + assertFixable, + prettyPath, + wrapAction, +} from "@react-native-node-api/cli-utils"; + +import { + detectJsonIndentation, + getObjectProperty, + getStringArrayProperty, + parseJsonObject, + stringifyJson, +} from "./json.js"; +import { + addWorkspacePattern, + findWorkspace, + isPathCovered, + relativeWorkspacePath, + type Workspace, +} from "./workspaces.js"; +import { parseTomlTables, hasTomlKey } from "./toml.js"; + +const PACKAGE_ROOT = path.join(import.meta.dirname, ".."); +const TEMPLATES_PATH = path.join(PACKAGE_ROOT, "templates"); + +export type Change = { + kind: "create" | "update"; + path: string; + contents: string; +}; + +export type InitPlan = { + packagePath: string; + packageName: string; + crateName: string; + libraryName: string; + changes: Change[]; + 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; +}; + +/** + * 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, +}: InitOptions): Promise { + const resolvedPath = path.resolve(packagePath); + const changes: Change[] = []; + const notices: string[] = []; + + const packageJsonPath = path.join(resolvedPath, "package.json"); + const existingPackageJson = await readFileIfExists(packageJsonPath); + 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 workspace = findWorkspace(resolvedPath); + const updatedPackageJson = updatePackageJson({ + packageJson, + packageName, + libraryName, + workspace, + }); + if (updatedPackageJson) { + changes.push({ + kind: existingPackageJson ? "update" : "create", + path: packageJsonPath, + contents: stringifyJson( + updatedPackageJson, + existingPackageJson + ? detectJsonIndentation(existingPackageJson) + : undefined, + ), + }); + } + + const cargoTomlPath = path.join(resolvedPath, "Cargo.toml"); + const existingCargoToml = await readFileIfExists(cargoTomlPath); + if (existingCargoToml) { + notices.push(...inspectCargoToml(existingCargoToml)); + } else { + changes.push({ + kind: "create", + path: cargoTomlPath, + contents: (await readTemplate("Cargo.toml")).replaceAll( + "{{name}}", + crateName, + ), + }); + } + + for (const [template, relativePath] of [ + ["build.rs", "build.rs"], + ["lib.rs", path.join("src", "lib.rs")], + ]) { + const outputPath = path.join(resolvedPath, relativePath); + if (!(await readFileIfExists(outputPath))) { + changes.push({ + kind: "create", + path: outputPath, + contents: await readTemplate(template), + }); + } + } + + const gitignorePath = path.join(resolvedPath, ".gitignore"); + // The template is named without a leading dot, as npm won't publish a + // .gitignore file inside a package + const gitignoreTemplate = await readTemplate("gitignore"); + const existingGitignore = await readFileIfExists(gitignorePath); + if (existingGitignore) { + const updatedGitignore = appendMissingLines( + existingGitignore, + gitignoreTemplate, + ); + if (updatedGitignore) { + changes.push({ + kind: "update", + path: gitignorePath, + contents: updatedGitignore, + }); + } + } else { + changes.push({ + kind: "create", + path: gitignorePath, + contents: gitignoreTemplate, + }); + } + + const workspaceChange = await planWorkspaceChange( + workspace, + resolvedPath, + notices, + ); + if (workspaceChange) { + changes.push(workspaceChange); + } + + return { + packagePath: resolvedPath, + packageName, + crateName, + libraryName, + changes, + notices, + }; +} + +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(/^@[^/]+\//, ""); +} + +type PackageJsonOptions = { + packageJson: Record | undefined; + packageName: string; + libraryName: string; + workspace: Workspace | undefined; +}; + +/** + * Add whatever a package needs to build a Rust Node-API module to its + * package.json, leaving any value it already declares alone. Returns undefined + * if it already declares everything. + */ +function updatePackageJson({ + packageJson, + packageName, + libraryName, + workspace, +}: PackageJsonOptions) { + const { hostSpecifier, ferricSpecifier } = + determineDependencySpecifiers(workspace); + const result: Record = { ...packageJson }; + let changed = false; + + for (const [key, value] of Object.entries({ + name: packageName, + version: "0.1.0", + type: "commonjs", + main: `${libraryName}.js`, + types: `${libraryName}.d.ts`, + })) { + if (result[key] === undefined) { + result[key] = value; + changed = true; + } + } + + // Only extend an existing "files" array: adding one to a package without it + // would suddenly narrow down what that package publishes. + const artifactFiles = [ + `${libraryName}.js`, + `${libraryName}.d.ts`, + `${libraryName}.apple.node`, + `${libraryName}.android.node`, + ]; + const files = getStringArrayProperty(result, "files"); + if (packageJson === undefined) { + result["files"] = artifactFiles; + changed = true; + } else if (files) { + const missing = artifactFiles.filter((file) => !files.includes(file)); + if (missing.length > 0) { + result["files"] = [...files, ...missing]; + changed = true; + } + } + + changed = + addMissingEntries(result, "scripts", { + build: "ferric build", + "build:types": "ferric build --dts-only", + }) || changed; + changed = + addMissingEntries( + result, + "dependencies", + { "react-native-node-api": hostSpecifier }, + ["devDependencies", "peerDependencies"], + ) || changed; + changed = + addMissingEntries( + result, + "devDependencies", + { "ferric-cli": ferricSpecifier }, + ["dependencies", "peerDependencies"], + ) || changed; + + return changed ? result : undefined; +} + +/** + * Add entries to an object valued property, keeping any entry already declared + * by it (or by one of the other properties it can be declared in). + */ +function addMissingEntries( + packageJson: Record, + key: string, + entries: Record, + alternativeKeys: string[] = [], +) { + const existing = getObjectProperty(packageJson, key) ?? {}; + const declaredElsewhere = alternativeKeys.flatMap((alternativeKey) => + Object.keys(getObjectProperty(packageJson, alternativeKey) ?? {}), + ); + const missing = Object.entries(entries).filter( + ([name]) => + existing[name] === undefined && !declaredElsewhere.includes(name), + ); + if (missing.length === 0) { + return false; + } + packageJson[key] = { ...existing, ...Object.fromEntries(missing) }; + return true; +} + +/** + * 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", + }; +} + +/** + * Notices about anything an existing Cargo manifest needs to build into a + * Node-API module. It is left untouched: it is likely to carry pinned versions, + * comments and features which are more valuable than a mechanical update. + */ +function inspectCargoToml(contents: string) { + const tables = parseTomlTables(contents); + const result: string[] = []; + if (!/crate-type\s*=\s*\[[^\]]*"cdylib"/.test(contents)) { + result.push( + `Cargo.toml doesn't declare ${chalk.blue('crate-type = ["cdylib"]')} in its ${chalk.blue("[lib]")} section, which Node-API modules are built as`, + ); + } + for (const [table, dependency] of [ + ["dependencies", "napi"], + ["dependencies", "napi-derive"], + ["build-dependencies", "napi-build"], + ]) { + if (!hasTomlKey(tables, table, dependency)) { + result.push( + `Cargo.toml is missing ${chalk.blue(dependency)} from its ${chalk.blue(`[${table}]`)}`, + ); + } + } + return result; +} + +/** + * 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"; +} + +async function planWorkspaceChange( + workspace: Workspace | undefined, + packagePath: string, + notices: string[], +): Promise { + if (!workspace) { + return undefined; + } + const relativePath = relativeWorkspacePath(workspace, packagePath); + const { patterns, configPath } = workspace; + if (patterns && isPathCovered(patterns, relativePath)) { + return undefined; + } + const contents = await readFileIfExists(configPath); + const updated = + patterns && contents + ? addWorkspacePattern(workspace, contents, relativePath) + : undefined; + if (!updated) { + notices.push( + `Add ${chalk.blue(relativePath)} to the workspace packages declared by ${prettyPath(configPath)}`, + ); + return undefined; + } + return { kind: "update", path: configPath, contents: updated }; +} + +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); + +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) + .action( + wrapAction(async (packagePath: string, { name, dryRun }) => { + const plan = await planInit({ packagePath, crateName: name }); + if (!dryRun) { + await applyChanges(plan.changes); + } + printPlan(plan, dryRun); + }), + ); + +function printPlan(plan: InitPlan, dryRun: boolean) { + for (const { kind, path: filePath } of plan.changes) { + const label = + kind === "create" ? chalk.green("create") : chalk.yellow("update"); + console.log( + `${dryRun ? chalk.dim("would ") : ""}${label} ${prettyPath(filePath)}`, + ); + } + for (const notice of plan.notices) { + console.log(`${chalk.yellow("ℹ︎")} ${notice}`); + } + if (plan.changes.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..316556ad --- /dev/null +++ b/packages/ferric/src/json.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; + +/** + * 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): Record { + const value: unknown = JSON.parse(contents); + assert( + typeof value === "object" && value !== null && !Array.isArray(value), + "Expected a JSON object", + ); + return Object.fromEntries(Object.entries(value)); +} + +export function getObjectProperty( + value: Record, + key: string, +): Record | undefined { + const property = value[key]; + if (property === undefined) { + return undefined; + } + assert( + typeof property === "object" && + property !== null && + !Array.isArray(property), + `Expected "${key}" to be an object`, + ); + return Object.fromEntries(Object.entries(property)); +} + +export function getStringArrayProperty( + value: Record, + key: string, +): string[] | undefined { + 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/toml.ts b/packages/ferric/src/toml.ts new file mode 100644 index 00000000..36bb2131 --- /dev/null +++ b/packages/ferric/src/toml.ts @@ -0,0 +1,49 @@ +/** + * The tables of a TOML document, mapped to the keys they declare. This is only + * precise enough to answer "is this declared?" for the manifests Cargo writes — + * values (and the tables nested inside inline tables or arrays) are ignored. + */ +export function parseTomlTables(contents: string) { + const result = new Map>(); + let currentTable = ""; + result.set(currentTable, new Set()); + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + const table = trimmed.match(/^\[\[?\s*([^\]]+?)\s*\]\]?/); + if (table) { + currentTable = table[1]; + if (!result.has(currentTable)) { + result.set(currentTable, new Set()); + } + continue; + } + const key = trimmed.match(/^("[^"]*"|[A-Za-z0-9_.-]+)\s*=/); + if (key) { + result.get(currentTable)?.add(unquote(key[1])); + } + } + return result; +} + +/** + * Determine if a table declares a key, either directly (`napi = "3"` under + * `[dependencies]`) or as a sub-table (`[dependencies.napi]`). + */ +export function hasTomlKey( + tables: Map>, + table: string, + key: string, +) { + return ( + tables.get(table)?.has(key) === true || + tables.has(`${table}.${key}`) || + tables.has(`${table}."${key}"`) + ); +} + +function unquote(value: string) { + return value.startsWith('"') ? value.slice(1, -1) : value; +} diff --git a/packages/ferric/src/workspaces.test.ts b/packages/ferric/src/workspaces.test.ts new file mode 100644 index 00000000..c8e36733 --- /dev/null +++ b/packages/ferric/src/workspaces.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + addWorkspacePattern, + isPathCovered, + parseNpmWorkspacePatterns, + parsePnpmWorkspacePatterns, + patternToRegExp, + 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("patternToRegExp", () => { + it("matches a single path component per star", () => { + assert.match("packages/my-addon", patternToRegExp("packages/*")); + assert.doesNotMatch( + "packages/nested/my-addon", + patternToRegExp("packages/*"), + ); + }); + + it("matches any depth per double star", () => { + assert.match("packages/nested/my-addon", patternToRegExp("packages/**")); + }); + + it("matches an exact path", () => { + assert.match("apps/test-app", patternToRegExp("apps/test-app")); + assert.doesNotMatch("apps/test-apple", patternToRegExp("apps/test-app")); + }); + + it("treats dots literally", () => { + assert.doesNotMatch("packagesX", patternToRegExp("packages.")); + }); +}); + +describe("isPathCovered", () => { + it("applies negations after matches", () => { + assert.equal(isPathCovered(["packages/*"], "packages/my-addon"), true); + 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..bbde2d9b --- /dev/null +++ b/packages/ferric/src/workspaces.ts @@ -0,0 +1,232 @@ +import fs from "node:fs"; +import path from "node:path"; + +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. + */ +export function findWorkspace(packagePath: string): Workspace | undefined { + let currentPath = path.dirname(path.resolve(packagePath)); + for (;;) { + const pnpmConfigPath = path.join(currentPath, "pnpm-workspace.yaml"); + if (fs.existsSync(pnpmConfigPath)) { + return { + kind: "pnpm", + rootPath: currentPath, + configPath: pnpmConfigPath, + patterns: parsePnpmWorkspacePatterns( + fs.readFileSync(pnpmConfigPath, "utf8"), + ), + }; + } + const packageJsonPath = path.join(currentPath, "package.json"); + if (fs.existsSync(packageJsonPath)) { + const patterns = parseNpmWorkspacePatterns( + fs.readFileSync(packageJsonPath, "utf8"), + ); + 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(); +} + +/** + * Turn a workspace package pattern into an anchored regular expression. + * Only the subset of globbing used by workspace patterns is supported: "*" + * matching within a path component and "**" matching across components. + */ +export function patternToRegExp(pattern: string) { + const trimmed = pattern.replace(/^\.\//, "").replace(/\/$/, ""); + const source = trimmed.replace(/\*\*|\*|\?|[.+^${}()|[\]\\]/g, (match) => { + if (match === "**") { + return ".*"; + } else if (match === "*") { + return "[^/]*"; + } else if (match === "?") { + return "[^/]"; + } else { + return `\\${match}`; + } + }); + return new RegExp(`^${source}$`); +} + +/** + * Determine if a path (relative to the workspace root, using forward slashes) + * is matched by the workspace patterns. + */ +export function isPathCovered(patterns: string[], relativePath: string) { + let result = false; + for (const pattern of patterns) { + const negated = pattern.startsWith("!"); + const matches = patternToRegExp(negated ? pattern.slice(1) : pattern).test( + relativePath, + ); + if (matches) { + 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, + ); +} diff --git a/packages/ferric/templates/Cargo.toml b/packages/ferric/templates/Cargo.toml new file mode 100644 index 00000000..954eeade --- /dev/null +++ b/packages/ferric/templates/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "{{name}}" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[lib] +crate-type = ["cdylib"] + +[dependencies.napi] +version = "=3.4.0" +# see https://nodejs.org/api/n-api.html#node-api-version-matrix +default-features = false +features = ["napi3"] + +[dependencies.napi-derive] +version = "3.3.0" +features = ["type-def"] + +# See https://github.com/callstackincubator/react-native-node-api/issues/331 +[dependencies.napi-sys] +version = "=3.0.1" + +[build-dependencies] +napi-build = "2.2.4" + +[profile.release] +lto = true +codegen-units = 1 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 +} From ab184a638de9208da5d5a1375bf514062875ab58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 07:31:10 +0000 Subject: [PATCH 2/8] Address review of the `ferric init` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse Cargo manifests with smol-toml and match workspace patterns with picomatch (what pnpm's own tooling matches with) instead of hand-rolled scanners, print a diff of every file being updated, and skip ancestors which can't be read or parsed while looking for the workspace. A test now asserts the init template declares the same dependency versions as the ferric-example, which CI builds and runs on devices — bumping the pins there fails until the template follows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019peL1QhG54ysW6r3GKqkbo --- .changeset/ferric-init-command.md | 2 +- packages/ferric/README.md | 3 +- packages/ferric/package.json | 6 ++ packages/ferric/src/init.test.ts | 82 ++++++++++++++++++- packages/ferric/src/init.ts | 107 +++++++++++++++++++------ packages/ferric/src/json.ts | 29 +++---- packages/ferric/src/toml.ts | 49 ----------- packages/ferric/src/workspaces.test.ts | 27 +++---- packages/ferric/src/workspaces.ts | 63 +++++++-------- packages/ferric/templates/gitignore | 1 - pnpm-lock.yaml | 48 +++++++++-- 11 files changed, 263 insertions(+), 154 deletions(-) delete mode 100644 packages/ferric/src/toml.ts diff --git a/.changeset/ferric-init-command.md b/.changeset/ferric-init-command.md index 11b7ef02..21c95ae8 100644 --- a/.changeset/ferric-init-command.md +++ b/.changeset/ferric-init-command.md @@ -4,4 +4,4 @@ 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 updates instead of overwriting: values already declared are kept, an existing `Cargo.toml` is left alone (with notices about anything it lacks) and `--dry-run` prints the changes without applying them. +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 updates instead of overwriting: values already declared are kept, an existing `Cargo.toml` is left alone (with notices about anything it lacks) and every updated file is printed as a diff, which `--dry-run` prints without writing anything. diff --git a/packages/ferric/README.md b/packages/ferric/README.md index 6e0698de..1c302325 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -44,7 +44,8 @@ The name of the crate defaults to the package name without its npm scope normalizes it to `my_addon` for the artifacts it produces, which is the basename `ferric build` writes its outputs and entrypoint with. -Pass `--dry-run` to print the changes without applying them: +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 diff --git a/packages/ferric/package.json b/packages/ferric/package.json index e38478f3..779ebd28 100644 --- a/packages/ferric/package.json +++ b/packages/ferric/package.json @@ -26,8 +26,14 @@ "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:*", + "smol-toml": "^1.8.0", "weak-node-api": "workspace:*", "zod": "^4.1.11" + }, + "devDependencies": { + "@types/picomatch": "^4.0.3" } } diff --git a/packages/ferric/src/init.test.ts b/packages/ferric/src/init.test.ts index db818775..b3c9e82f 100644 --- a/packages/ferric/src/init.test.ts +++ b/packages/ferric/src/init.test.ts @@ -4,15 +4,51 @@ 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, crateNameFromPackageName, determineDependencySpecifiers, + formatDiff, planInit, type InitPlan, } from "./init.js"; +import { isRecord } from "./json.js"; import { findWorkspace } from "./workspaces.js"; +const TEMPLATES_PATH = path.resolve(import.meta.dirname, "..", "templates"); +const FERRIC_EXAMPLE_PATH = path.resolve( + import.meta.dirname, + "..", + "..", + "ferric-example", +); + +/** + * The version each dependency of a Cargo manifest is declared with, whether as + * a string or a table with a "version" key. + */ +function dependencyVersions(contents: string) { + const manifest: unknown = parseToml(contents); + assert.ok(isRecord(manifest)); + const result: Record = {}; + for (const table of ["dependencies", "build-dependencies"]) { + const dependencies = manifest[table]; + if (!isRecord(dependencies)) { + continue; + } + for (const [name, value] of Object.entries(dependencies)) { + if (typeof value === "string") { + result[name] = value; + } else if (isRecord(value) && typeof value["version"] === "string") { + result[name] = value["version"]; + } + } + } + return result; +} + interface FileMap { [filePath: string]: string; } @@ -232,6 +268,15 @@ describe("planInit", () => { assert.match(plan.notices.join("\n"), /napi-build/); }); + 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"), @@ -274,12 +319,41 @@ describe("planInit", () => { // 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 = path.resolve( - import.meta.dirname, - "../../ferric-example", - ); + const packagePath = FERRIC_EXAMPLE_PATH; const plan = await planInit({ packagePath }); assert.deepEqual(changedPaths(plan, packagePath), []); assert.deepEqual(plan.notices, []); }); }); + +describe("the templates", () => { + it("declare the versions the ferric-example is built with", () => { + // The example is the crate CI actually builds and runs on devices, so it is + // where the pins (see #331) are proven: keeping the template in sync with it + // is what stops the versions it scaffolds from going stale unnoticed. + const template = dependencyVersions( + fs.readFileSync(path.join(TEMPLATES_PATH, "Cargo.toml"), "utf8"), + ); + const example = dependencyVersions( + fs.readFileSync(path.join(FERRIC_EXAMPLE_PATH, "Cargo.toml"), "utf8"), + ); + assert.ok(Object.keys(template).length > 0); + for (const [name, version] of Object.entries(template)) { + assert.equal( + example[name], + version, + `Update the ${name} version in the init template to the ${example[name]} of the ferric-example`, + ); + } + }); +}); + +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 909b181b..c0317d79 100644 --- a/packages/ferric/src/init.ts +++ b/packages/ferric/src/init.ts @@ -9,11 +9,14 @@ import { prettyPath, wrapAction, } from "@react-native-node-api/cli-utils"; +import { structuredPatch } from "diff"; +import { parse as parseToml } from "smol-toml"; import { detectJsonIndentation, getObjectProperty, getStringArrayProperty, + isRecord, parseJsonObject, stringifyJson, } from "./json.js"; @@ -24,16 +27,13 @@ import { relativeWorkspacePath, type Workspace, } from "./workspaces.js"; -import { parseTomlTables, hasTomlKey } from "./toml.js"; const PACKAGE_ROOT = path.join(import.meta.dirname, ".."); const TEMPLATES_PATH = path.join(PACKAGE_ROOT, "templates"); -export type Change = { - kind: "create" | "update"; - path: string; - contents: string; -}; +export type Change = + | { kind: "create"; path: string; contents: string } + | { kind: "update"; path: string; contents: string; previous: string }; export type InitPlan = { packagePath: string; @@ -94,16 +94,22 @@ export async function planInit({ workspace, }); if (updatedPackageJson) { - changes.push({ - kind: existingPackageJson ? "update" : "create", - path: packageJsonPath, - contents: stringifyJson( - updatedPackageJson, - existingPackageJson - ? detectJsonIndentation(existingPackageJson) - : undefined, - ), - }); + const contents = stringifyJson( + updatedPackageJson, + existingPackageJson + ? detectJsonIndentation(existingPackageJson) + : undefined, + ); + changes.push( + existingPackageJson + ? { + kind: "update", + path: packageJsonPath, + contents, + previous: existingPackageJson, + } + : { kind: "create", path: packageJsonPath, contents }, + ); } const cargoTomlPath = path.join(resolvedPath, "Cargo.toml"); @@ -150,6 +156,7 @@ export async function planInit({ kind: "update", path: gitignorePath, contents: updatedGitignore, + previous: existingGitignore, }); } } else { @@ -338,9 +345,21 @@ export function determineDependencySpecifiers( * comments and features which are more valuable than a mechanical update. */ function inspectCargoToml(contents: string) { - const tables = parseTomlTables(contents); + let manifest: unknown; + try { + manifest = parseToml(contents); + } catch (error) { + return [ + `Cargo.toml could not be parsed: ${error instanceof Error ? error.message : String(error)}`, + ]; + } + if (!isRecord(manifest)) { + return ["Cargo.toml doesn't declare a package"]; + } const result: string[] = []; - if (!/crate-type\s*=\s*\[[^\]]*"cdylib"/.test(contents)) { + const lib = manifest["lib"]; + const crateType = isRecord(lib) ? lib["crate-type"] : undefined; + if (!Array.isArray(crateType) || !crateType.includes("cdylib")) { result.push( `Cargo.toml doesn't declare ${chalk.blue('crate-type = ["cdylib"]')} in its ${chalk.blue("[lib]")} section, which Node-API modules are built as`, ); @@ -350,7 +369,8 @@ function inspectCargoToml(contents: string) { ["dependencies", "napi-derive"], ["build-dependencies", "napi-build"], ]) { - if (!hasTomlKey(tables, table, dependency)) { + const dependencies = manifest[table]; + if (!isRecord(dependencies) || dependencies[dependency] === undefined) { result.push( `Cargo.toml is missing ${chalk.blue(dependency)} from its ${chalk.blue(`[${table}]`)}`, ); @@ -393,13 +413,18 @@ async function planWorkspaceChange( patterns && contents ? addWorkspacePattern(workspace, contents, relativePath) : undefined; - if (!updated) { + if (!updated || contents === undefined) { notices.push( `Add ${chalk.blue(relativePath)} to the workspace packages declared by ${prettyPath(configPath)}`, ); return undefined; } - return { kind: "update", path: configPath, contents: updated }; + return { + kind: "update", + path: configPath, + contents: updated, + previous: contents, + }; } function readTemplate(filename: string) { @@ -442,13 +467,47 @@ export const initCommand = new Command("init") }), ); +/** + * 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 printPlan(plan: InitPlan, dryRun: boolean) { - for (const { kind, path: filePath } of plan.changes) { + for (const change of plan.changes) { const label = - kind === "create" ? chalk.green("create") : chalk.yellow("update"); + change.kind === "create" ? chalk.green("create") : chalk.yellow("update"); console.log( - `${dryRun ? chalk.dim("would ") : ""}${label} ${prettyPath(filePath)}`, + `${dryRun ? chalk.dim("would ") : ""}${label} ${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") { + for (const line of formatDiff(change.previous, change.contents)) { + console.log(chalk.dim("│ ") + line); + } + } } for (const notice of plan.notices) { console.log(`${chalk.yellow("ℹ︎")} ${notice}`); diff --git a/packages/ferric/src/json.ts b/packages/ferric/src/json.ts index 316556ad..f4bf9e02 100644 --- a/packages/ferric/src/json.ts +++ b/packages/ferric/src/json.ts @@ -1,5 +1,9 @@ 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. @@ -13,36 +17,25 @@ export function stringifyJson(value: unknown, indentation = " ") { return JSON.stringify(value, null, indentation) + "\n"; } -export function parseJsonObject(contents: string): Record { +export function parseJsonObject(contents: string) { const value: unknown = JSON.parse(contents); - assert( - typeof value === "object" && value !== null && !Array.isArray(value), - "Expected a JSON object", - ); - return Object.fromEntries(Object.entries(value)); + assert(isRecord(value), "Expected a JSON object"); + return value; } -export function getObjectProperty( - value: Record, - key: string, -): Record | undefined { +export function getObjectProperty(value: Record, key: string) { const property = value[key]; if (property === undefined) { return undefined; } - assert( - typeof property === "object" && - property !== null && - !Array.isArray(property), - `Expected "${key}" to be an object`, - ); - return Object.fromEntries(Object.entries(property)); + assert(isRecord(property), `Expected "${key}" to be an object`); + return property; } export function getStringArrayProperty( value: Record, key: string, -): string[] | undefined { +) { const property = value[key]; if (property === undefined) { return undefined; diff --git a/packages/ferric/src/toml.ts b/packages/ferric/src/toml.ts deleted file mode 100644 index 36bb2131..00000000 --- a/packages/ferric/src/toml.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * The tables of a TOML document, mapped to the keys they declare. This is only - * precise enough to answer "is this declared?" for the manifests Cargo writes — - * values (and the tables nested inside inline tables or arrays) are ignored. - */ -export function parseTomlTables(contents: string) { - const result = new Map>(); - let currentTable = ""; - result.set(currentTable, new Set()); - for (const line of contents.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith("#")) { - continue; - } - const table = trimmed.match(/^\[\[?\s*([^\]]+?)\s*\]\]?/); - if (table) { - currentTable = table[1]; - if (!result.has(currentTable)) { - result.set(currentTable, new Set()); - } - continue; - } - const key = trimmed.match(/^("[^"]*"|[A-Za-z0-9_.-]+)\s*=/); - if (key) { - result.get(currentTable)?.add(unquote(key[1])); - } - } - return result; -} - -/** - * Determine if a table declares a key, either directly (`napi = "3"` under - * `[dependencies]`) or as a sub-table (`[dependencies.napi]`). - */ -export function hasTomlKey( - tables: Map>, - table: string, - key: string, -) { - return ( - tables.get(table)?.has(key) === true || - tables.has(`${table}.${key}`) || - tables.has(`${table}."${key}"`) - ); -} - -function unquote(value: string) { - return value.startsWith('"') ? value.slice(1, -1) : value; -} diff --git a/packages/ferric/src/workspaces.test.ts b/packages/ferric/src/workspaces.test.ts index c8e36733..df6c9a76 100644 --- a/packages/ferric/src/workspaces.test.ts +++ b/packages/ferric/src/workspaces.test.ts @@ -6,7 +6,6 @@ import { isPathCovered, parseNpmWorkspacePatterns, parsePnpmWorkspacePatterns, - patternToRegExp, type Workspace, } from "./workspaces.js"; @@ -79,32 +78,28 @@ describe("parseNpmWorkspacePatterns", () => { }); }); -describe("patternToRegExp", () => { +describe("isPathCovered", () => { it("matches a single path component per star", () => { - assert.match("packages/my-addon", patternToRegExp("packages/*")); - assert.doesNotMatch( - "packages/nested/my-addon", - patternToRegExp("packages/*"), + 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.match("packages/nested/my-addon", patternToRegExp("packages/**")); + assert.equal( + isPathCovered(["packages/**"], "packages/nested/my-addon"), + true, + ); }); it("matches an exact path", () => { - assert.match("apps/test-app", patternToRegExp("apps/test-app")); - assert.doesNotMatch("apps/test-apple", patternToRegExp("apps/test-app")); + assert.equal(isPathCovered(["apps/test-app"], "apps/test-app"), true); + assert.equal(isPathCovered(["apps/test-app"], "apps/test-apple"), false); }); - it("treats dots literally", () => { - assert.doesNotMatch("packagesX", patternToRegExp("packages.")); - }); -}); - -describe("isPathCovered", () => { it("applies negations after matches", () => { - assert.equal(isPathCovered(["packages/*"], "packages/my-addon"), true); assert.equal( isPathCovered(["packages/*", "!packages/my-addon"], "packages/my-addon"), false, diff --git a/packages/ferric/src/workspaces.ts b/packages/ferric/src/workspaces.ts index bbde2d9b..07eca0f3 100644 --- a/packages/ferric/src/workspaces.ts +++ b/packages/ferric/src/workspaces.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import picomatch from "picomatch"; + import { detectJsonIndentation, getStringArrayProperty, @@ -25,25 +27,29 @@ export type Workspace = { /** * 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"); - if (fs.existsSync(pnpmConfigPath)) { + const pnpmContents = readFileIfAccessible(pnpmConfigPath); + if (pnpmContents !== undefined) { return { kind: "pnpm", rootPath: currentPath, configPath: pnpmConfigPath, - patterns: parsePnpmWorkspacePatterns( - fs.readFileSync(pnpmConfigPath, "utf8"), + patterns: ignoringErrors(() => + parsePnpmWorkspacePatterns(pnpmContents), ), }; } const packageJsonPath = path.join(currentPath, "package.json"); - if (fs.existsSync(packageJsonPath)) { - const patterns = parseNpmWorkspacePatterns( - fs.readFileSync(packageJsonPath, "utf8"), + const packageJsonContents = readFileIfAccessible(packageJsonPath); + if (packageJsonContents !== undefined) { + const patterns = ignoringErrors(() => + parseNpmWorkspacePatterns(packageJsonContents), ); if (patterns) { return { @@ -114,39 +120,16 @@ function parseYamlScalar(value: string) { return quoted ? quoted[2] : stripComment(value).trim(); } -/** - * Turn a workspace package pattern into an anchored regular expression. - * Only the subset of globbing used by workspace patterns is supported: "*" - * matching within a path component and "**" matching across components. - */ -export function patternToRegExp(pattern: string) { - const trimmed = pattern.replace(/^\.\//, "").replace(/\/$/, ""); - const source = trimmed.replace(/\*\*|\*|\?|[.+^${}()|[\]\\]/g, (match) => { - if (match === "**") { - return ".*"; - } else if (match === "*") { - return "[^/]*"; - } else if (match === "?") { - return "[^/]"; - } else { - return `\\${match}`; - } - }); - return new RegExp(`^${source}$`); -} - /** * Determine if a path (relative to the workspace root, using forward slashes) - * is matched by the workspace patterns. + * 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("!"); - const matches = patternToRegExp(negated ? pattern.slice(1) : pattern).test( - relativePath, - ); - if (matches) { + if (picomatch.isMatch(relativePath, negated ? pattern.slice(1) : pattern)) { result = !negated; } } @@ -230,3 +213,19 @@ function addNpmWorkspacePattern(contents: string, pattern: string) { 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/gitignore b/packages/ferric/templates/gitignore index a225cf72..374f3d6d 100644 --- a/packages/ferric/templates/gitignore +++ b/packages/ferric/templates/gitignore @@ -1,5 +1,4 @@ /target -/*.xcframework/ /*.apple.node/ /*.android.node/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca51f945..6bf3e1e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,19 +193,32 @@ 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 + 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 packages/ferric-example: dependencies: @@ -298,7 +311,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,6 +2350,9 @@ 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==} @@ -2933,6 +2949,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 +3942,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 +4441,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 +6166,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 +6181,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 +6903,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,6 +7123,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/picomatch@4.0.3': {} + '@types/react@19.2.17': dependencies: csstype: 3.2.3 @@ -7767,6 +7795,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 +8912,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 +9549,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: From dc210150f5e57fcc9f0d92a5e9e0d23ad35f46d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sat, 31 Oct 2026 11:42:44 +0100 Subject: [PATCH 3/8] Refine `ferric init` for existing packages and tie it to the example Bump outdated dependencies and napi crates in place, skip differing entrypoints and source files with a diff unless `--force` is passed, and make the Node-API version, crate versions and release profile configurable. Regenerate ferric-example with the new `init-ferric-example` script, which CI runs to fail on any difference from what `ferric init` generates. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/ferric-init-command.md | 4 +- .github/workflows/check.yml | 6 + package.json | 3 +- packages/ferric-example/Cargo.lock | 2 +- packages/ferric-example/Cargo.toml | 2 +- packages/ferric-example/package.json | 6 +- packages/ferric/README.md | 20 +- packages/ferric/package.json | 4 +- packages/ferric/src/cargo-manifest.ts | 211 ++++++++++++ packages/ferric/src/init.test.ts | 293 ++++++++++++---- packages/ferric/src/init.ts | 465 +++++++++++++++++++------- packages/ferric/templates/Cargo.toml | 17 +- packages/ferric/templates/gitignore | 1 + pnpm-lock.yaml | 11 + scripts/init-ferric-example.ts | 67 ++++ 15 files changed, 908 insertions(+), 204 deletions(-) create mode 100644 packages/ferric/src/cargo-manifest.ts create mode 100644 scripts/init-ferric-example.ts diff --git a/.changeset/ferric-init-command.md b/.changeset/ferric-init-command.md index 21c95ae8..f76d70bf 100644 --- a/.changeset/ferric-init-command.md +++ b/.changeset/ferric-init-command.md @@ -4,4 +4,6 @@ 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 updates instead of overwriting: values already declared are kept, an existing `Cargo.toml` is left alone (with notices about anything it lacks) and every updated file is printed as a diff, which `--dry-run` prints without writing anything. +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 1c302325..7a8879fa 100644 --- a/packages/ferric/README.md +++ b/packages/ferric/README.md @@ -19,6 +19,7 @@ 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 @@ -33,11 +34,16 @@ The command creates whatever is missing — `package.json`, `Cargo.toml`, `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: -values already declared are left alone, so an existing `package.json` only gains -the scripts, dependencies and prebuilt artifacts it lacks. An existing -`Cargo.toml` is never rewritten — pinned versions, features and comments are -worth more than a mechanical update — but the command points out anything it -needs to build as a Node-API module. + +- `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 @@ -51,6 +57,4 @@ plan without writing anything: npx ferric init packages/my-addon --dry-run ``` -The [`ferric-example`](../ferric-example) package is what an initialized package -grows into: running `ferric init` in it is expected to be a no-op, which the -tests assert. +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 779ebd28..e49a0922 100644 --- a/packages/ferric/package.json +++ b/packages/ferric/package.json @@ -29,11 +29,13 @@ "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/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 index b3c9e82f..a5db9ae7 100644 --- a/packages/ferric/src/init.test.ts +++ b/packages/ferric/src/init.test.ts @@ -9,15 +9,16 @@ import { parse as parseToml } from "smol-toml"; import { applyChanges, crateNameFromPackageName, + DEFAULT_CRATE_VERSIONS, + DEFAULT_NODE_API_VERSION, determineDependencySpecifiers, formatDiff, planInit, type InitPlan, } from "./init.js"; -import { isRecord } from "./json.js"; +import { parseJsonObject } from "./json.js"; import { findWorkspace } from "./workspaces.js"; -const TEMPLATES_PATH = path.resolve(import.meta.dirname, "..", "templates"); const FERRIC_EXAMPLE_PATH = path.resolve( import.meta.dirname, "..", @@ -25,30 +26,6 @@ const FERRIC_EXAMPLE_PATH = path.resolve( "ferric-example", ); -/** - * The version each dependency of a Cargo manifest is declared with, whether as - * a string or a table with a "version" key. - */ -function dependencyVersions(contents: string) { - const manifest: unknown = parseToml(contents); - assert.ok(isRecord(manifest)); - const result: Record = {}; - for (const table of ["dependencies", "build-dependencies"]) { - const dependencies = manifest[table]; - if (!isRecord(dependencies)) { - continue; - } - for (const [name, value] of Object.entries(dependencies)) { - if (typeof value === "string") { - result[name] = value; - } else if (isRecord(value) && typeof value["version"] === "string") { - result[name] = value["version"]; - } - } - } - return result; -} - interface FileMap { [filePath: string]: string; } @@ -71,11 +48,9 @@ function setupTempDirectory(context: TestContext, files: FileMap = {}) { } function readPackageJson(packagePath: string) { - const contents = fs.readFileSync( - path.join(packagePath, "package.json"), - "utf8", + return parseJsonObject( + fs.readFileSync(path.join(packagePath, "package.json"), "utf8"), ); - return JSON.parse(contents) as Record; } function keysOf(value: unknown) { @@ -215,7 +190,7 @@ describe("planInit", () => { main: "index.js", files: ["index.js"], scripts: { build: "make" }, - devDependencies: { "ferric-cli": "0.1.0" }, + devDependencies: { "ferric-cli": "999.0.0" }, }, null, 2, @@ -231,7 +206,7 @@ describe("planInit", () => { "build:types": "ferric build --dts-only", }); assert.deepEqual(packageJson["devDependencies"], { - "ferric-cli": "0.1.0", + "ferric-cli": "999.0.0", }); // The artifacts are added to an existing "files", which would otherwise // keep them out of the published package @@ -244,14 +219,14 @@ describe("planInit", () => { ]); }); - it("leaves an existing Cargo manifest alone, noticing what it lacks", async (context) => { + it("notices what an existing Cargo manifest lacks", async (context) => { const packagePath = setupTempDirectory(context, { "Cargo.toml": [ "[package]", 'name = "renamed-by-hand"', "", "[dependencies]", - 'napi = "=3.4.0"', + 'napi = "3.14.2"', "", ].join("\n"), }); @@ -262,10 +237,229 @@ describe("planInit", () => { ), false, ); - assert.equal(plan.notices.length, 3); - assert.match(plan.notices.join("\n"), /cdylib/); - assert.match(plan.notices.join("\n"), /napi-derive/); - assert.match(plan.notices.join("\n"), /napi-build/); + 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.ok(change.contents.endsWith('"\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) => { @@ -322,32 +516,11 @@ describe("planInit", () => { const packagePath = FERRIC_EXAMPLE_PATH; const plan = await planInit({ packagePath }); assert.deepEqual(changedPaths(plan, packagePath), []); + assert.deepEqual(plan.skips, []); assert.deepEqual(plan.notices, []); }); }); -describe("the templates", () => { - it("declare the versions the ferric-example is built with", () => { - // The example is the crate CI actually builds and runs on devices, so it is - // where the pins (see #331) are proven: keeping the template in sync with it - // is what stops the versions it scaffolds from going stale unnoticed. - const template = dependencyVersions( - fs.readFileSync(path.join(TEMPLATES_PATH, "Cargo.toml"), "utf8"), - ); - const example = dependencyVersions( - fs.readFileSync(path.join(FERRIC_EXAMPLE_PATH, "Cargo.toml"), "utf8"), - ); - assert.ok(Object.keys(template).length > 0); - for (const [name, version] of Object.entries(template)) { - assert.equal( - example[name], - version, - `Update the ${name} version in the init template to the ${example[name]} of the ferric-example`, - ); - } - }); -}); - describe("formatDiff", () => { it("renders the changed lines with context", () => { const lines = formatDiff("a\nb\nc\n", "a\nb\nB\nc\n").map((line) => diff --git a/packages/ferric/src/init.ts b/packages/ferric/src/init.ts index c0317d79..5c28e373 100644 --- a/packages/ferric/src/init.ts +++ b/packages/ferric/src/init.ts @@ -10,13 +10,18 @@ import { wrapAction, } from "@react-native-node-api/cli-utils"; import { structuredPatch } from "diff"; -import { parse as parseToml } from "smol-toml"; +import { + isCrateVersion, + isOutdated, + updateCargoManifest, + type CrateVersions, +} from "./cargo-manifest.js"; +import { MAX_NODE_API_VERSION } from "./features.js"; import { detectJsonIndentation, getObjectProperty, getStringArrayProperty, - isRecord, parseJsonObject, stringifyJson, } from "./json.js"; @@ -31,9 +36,41 @@ import { 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 }; + | { + 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; @@ -41,6 +78,7 @@ export type InitPlan = { crateName: string; libraryName: string; changes: Change[]; + skips: Skip[]; notices: string[]; }; @@ -49,6 +87,14 @@ export type InitOptions = { 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; }; /** @@ -58,10 +104,33 @@ export type InitOptions = { export async function planInit({ packagePath, crateName: crateNameOption, + nodeApiVersion, + crateVersions: crateVersionsOption, + releaseProfile, + force = false, }: InitOptions): Promise { const resolvedPath = path.resolve(packagePath); const changes: Change[] = []; + const skips: Skip[] = []; const notices: string[] = []; + 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 packageJsonPath = path.join(resolvedPath, "package.json"); const existingPackageJson = await readFileIfExists(packageJsonPath); @@ -87,43 +156,87 @@ export async function planInit({ const libraryName = crateName.replaceAll("-", "_"); const workspace = findWorkspace(resolvedPath); - const updatedPackageJson = updatePackageJson({ + const { updated: updatedPackageJson, conflicts } = updatePackageJson({ packageJson, packageName, libraryName, workspace, }); - if (updatedPackageJson) { - const contents = stringifyJson( - updatedPackageJson, - existingPackageJson - ? detectJsonIndentation(existingPackageJson) + const indentation = existingPackageJson + ? detectJsonIndentation(existingPackageJson) + : undefined; + const hasConflicts = Object.keys(conflicts).length > 0; + const keptPackageJson = updatedPackageJson ?? packageJson; + const overwritten = + hasConflicts && keptPackageJson + ? stringifyJson({ ...keptPackageJson, ...conflicts }, indentation) + : undefined; + if (overwritten && keptPackageJson && !force) { + skips.push({ + path: packageJsonPath, + previous: stringifyJson(keptPackageJson, indentation), + contents: overwritten, + hint: conflicts["main"] + ? `To keep ${chalk.blue(String(keptPackageJson["main"]))} as the entrypoint, re-export the addon from it: ${chalk.blue(reExportStatement(keptPackageJson, conflicts["main"]))}` : undefined, - ); + }); + } + const packageJsonContents = + force && overwritten + ? overwritten + : updatedPackageJson && stringifyJson(updatedPackageJson, indentation); + if (packageJsonContents) { changes.push( existingPackageJson ? { kind: "update", path: packageJsonPath, - contents, + contents: packageJsonContents, previous: existingPackageJson, + forced: force && hasConflicts, } - : { kind: "create", path: packageJsonPath, contents }, + : { + kind: "create", + path: packageJsonPath, + contents: packageJsonContents, + }, ); } const cargoTomlPath = path.join(resolvedPath, "Cargo.toml"); const existingCargoToml = await readFileIfExists(cargoTomlPath); - if (existingCargoToml) { - notices.push(...inspectCargoToml(existingCargoToml)); + if (existingCargoToml !== undefined) { + const manifest = updateCargoManifest(existingCargoToml, crateVersions); + notices.push(...manifest.notices); + if (manifest.contents !== existingCargoToml) { + changes.push({ + kind: "update", + path: cargoTomlPath, + contents: manifest.contents, + previous: existingCargoToml, + }); + } + for (const [option, value] of Object.entries({ + "--node-api-version": nodeApiVersion, + "--release-profile": releaseProfile, + })) { + if (value !== undefined) { + notices.push( + `Ignored ${chalk.blue(option)}, which only applies when creating a Cargo.toml`, + ); + } + } } else { + const template = await readTemplate("Cargo.toml"); changes.push({ kind: "create", path: cargoTomlPath, - contents: (await readTemplate("Cargo.toml")).replaceAll( - "{{name}}", + contents: renderCargoManifest(template, { crateName, - ), + nodeApiVersion: nodeApiVersion ?? DEFAULT_NODE_API_VERSION, + crateVersions, + releaseProfile: releaseProfile ?? "speed", + }), }); } @@ -132,12 +245,22 @@ export async function planInit({ ["lib.rs", path.join("src", "lib.rs")], ]) { const outputPath = path.join(resolvedPath, relativePath); - if (!(await readFileIfExists(outputPath))) { - changes.push({ - kind: "create", - path: outputPath, - contents: await readTemplate(template), - }); + const contents = await readTemplate(template); + const previous = await readFileIfExists(outputPath); + if (previous === undefined) { + changes.push({ kind: "create", path: outputPath, contents }); + } else if (previous !== contents) { + if (force) { + changes.push({ + kind: "update", + path: outputPath, + contents, + previous, + forced: true, + }); + } else { + skips.push({ path: outputPath, contents, previous }); + } } } @@ -146,7 +269,7 @@ export async function planInit({ // .gitignore file inside a package const gitignoreTemplate = await readTemplate("gitignore"); const existingGitignore = await readFileIfExists(gitignorePath); - if (existingGitignore) { + if (existingGitignore !== undefined) { const updatedGitignore = appendMissingLines( existingGitignore, gitignoreTemplate, @@ -182,10 +305,49 @@ export async function planInit({ crateName, libraryName, changes, + skips, notices, }; } +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"]) + .replaceAll( + "{{releaseProfile}}\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 }); @@ -209,8 +371,9 @@ type PackageJsonOptions = { /** * Add whatever a package needs to build a Rust Node-API module to its - * package.json, leaving any value it already declares alone. Returns undefined - * if it already declares everything. + * package.json and bump outdated dependencies, leaving any other value it already + * declares alone. `updated` is undefined if there is nothing to change, and + * `conflicts` holds the entrypoints it declares differently. */ function updatePackageJson({ packageJson, @@ -227,9 +390,23 @@ function updatePackageJson({ name: packageName, version: "0.1.0", type: "commonjs", + })) { + if (result[key] === undefined) { + result[key] = value; + changed = true; + } + } + + const entrypoints = { main: `${libraryName}.js`, types: `${libraryName}.d.ts`, - })) { + }; + const conflicts = Object.fromEntries( + Object.entries(entrypoints).filter( + ([key, value]) => result[key] !== undefined && result[key] !== value, + ), + ); + for (const [key, value] of Object.entries(entrypoints)) { if (result[key] === undefined) { result[key] = value; changed = true; @@ -256,52 +433,58 @@ function updatePackageJson({ } } + const scripts = getObjectProperty(result, "scripts") ?? {}; + const missingScripts = Object.entries({ + build: "ferric build", + "build:types": "ferric build --dts-only", + }).filter(([name]) => scripts[name] === undefined); + if (missingScripts.length > 0) { + result["scripts"] = { ...scripts, ...Object.fromEntries(missingScripts) }; + changed = true; + } + changed = - addMissingEntries(result, "scripts", { - build: "ferric build", - "build:types": "ferric build --dts-only", + updateDependency(result, { + name: "react-native-node-api", + specifier: hostSpecifier, + keys: ["dependencies", "devDependencies", "peerDependencies"], }) || changed; changed = - addMissingEntries( - result, - "dependencies", - { "react-native-node-api": hostSpecifier }, - ["devDependencies", "peerDependencies"], - ) || changed; - changed = - addMissingEntries( - result, - "devDependencies", - { "ferric-cli": ferricSpecifier }, - ["dependencies", "peerDependencies"], - ) || changed; - - return changed ? result : undefined; + updateDependency(result, { + name: "ferric-cli", + specifier: ferricSpecifier, + keys: ["devDependencies", "dependencies", "peerDependencies"], + }) || changed; + + return { updated: changed ? result : undefined, conflicts }; } /** - * Add entries to an object valued property, keeping any entry already declared - * by it (or by one of the other properties it can be declared in). + * 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 addMissingEntries( +function updateDependency( packageJson: Record, - key: string, - entries: Record, - alternativeKeys: string[] = [], + { + name, + specifier, + keys, + }: { name: string; specifier: string; keys: string[] }, ) { - const existing = getObjectProperty(packageJson, key) ?? {}; - const declaredElsewhere = alternativeKeys.flatMap((alternativeKey) => - Object.keys(getObjectProperty(packageJson, alternativeKey) ?? {}), - ); - const missing = Object.entries(entries).filter( - ([name]) => - existing[name] === undefined && !declaredElsewhere.includes(name), + const declaredIn = keys.find( + (key) => getObjectProperty(packageJson, key)?.[name] !== undefined, ); - if (missing.length === 0) { - return false; + const key = declaredIn ?? keys[0]; + const dependencies = getObjectProperty(packageJson, key) ?? {}; + const declared = dependencies[name]; + if ( + declaredIn === undefined || + (typeof declared === "string" && isOutdated(declared, specifier)) + ) { + packageJson[key] = { ...dependencies, [name]: specifier }; + return true; } - packageJson[key] = { ...existing, ...Object.fromEntries(missing) }; - return true; + return false; } /** @@ -339,46 +522,6 @@ export function determineDependencySpecifiers( }; } -/** - * Notices about anything an existing Cargo manifest needs to build into a - * Node-API module. It is left untouched: it is likely to carry pinned versions, - * comments and features which are more valuable than a mechanical update. - */ -function inspectCargoToml(contents: string) { - let manifest: unknown; - try { - manifest = parseToml(contents); - } catch (error) { - return [ - `Cargo.toml could not be parsed: ${error instanceof Error ? error.message : String(error)}`, - ]; - } - if (!isRecord(manifest)) { - return ["Cargo.toml doesn't declare a package"]; - } - const result: string[] = []; - const lib = manifest["lib"]; - const crateType = isRecord(lib) ? lib["crate-type"] : undefined; - if (!Array.isArray(crateType) || !crateType.includes("cdylib")) { - result.push( - `Cargo.toml doesn't declare ${chalk.blue('crate-type = ["cdylib"]')} in its ${chalk.blue("[lib]")} section, which Node-API modules are built as`, - ); - } - for (const [table, dependency] of [ - ["dependencies", "napi"], - ["dependencies", "napi-derive"], - ["build-dependencies", "napi-build"], - ]) { - const dependencies = manifest[table]; - if (!isRecord(dependencies) || dependencies[dependency] === undefined) { - result.push( - `Cargo.toml is missing ${chalk.blue(dependency)} from its ${chalk.blue(`[${table}]`)}`, - ); - } - } - return result; -} - /** * Append the lines of a template missing from an existing file, or undefined if * it already has them all. @@ -452,19 +595,86 @@ const dryRunOption = new Option( "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 }) => { - const plan = await planInit({ packagePath, crateName: name }); - if (!dryRun) { - await applyChanges(plan.changes); - } - printPlan(plan, dryRun); - }), + 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); + }, + ), ); /** @@ -494,25 +704,48 @@ export function formatDiff(previous: string, contents: string, context = 2) { ]); } +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) { - const label = - change.kind === "create" ? chalk.green("create") : chalk.yellow("update"); console.log( - `${dryRun ? chalk.dim("would ") : ""}${label} ${prettyPath(change.path)}`, + `${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") { - for (const line of formatDiff(change.previous, change.contents)) { - console.log(chalk.dim("│ ") + line); - } + 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.changes.length === 0) { + 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)}`, ); diff --git a/packages/ferric/templates/Cargo.toml b/packages/ferric/templates/Cargo.toml index 954eeade..168e182e 100644 --- a/packages/ferric/templates/Cargo.toml +++ b/packages/ferric/templates/Cargo.toml @@ -8,22 +8,15 @@ license = "MIT" crate-type = ["cdylib"] [dependencies.napi] -version = "=3.4.0" +version = "{{napiCrateVersion}}" # see https://nodejs.org/api/n-api.html#node-api-version-matrix default-features = false -features = ["napi3"] +features = ["napi{{nodeApiVersion}}"] [dependencies.napi-derive] -version = "3.3.0" +version = "{{napiDeriveCrateVersion}}" features = ["type-def"] -# See https://github.com/callstackincubator/react-native-node-api/issues/331 -[dependencies.napi-sys] -version = "=3.0.1" - [build-dependencies] -napi-build = "2.2.4" - -[profile.release] -lto = true -codegen-units = 1 +napi-build = "{{napiBuildCrateVersion}}" +{{releaseProfile}} diff --git a/packages/ferric/templates/gitignore b/packages/ferric/templates/gitignore index 374f3d6d..a225cf72 100644 --- a/packages/ferric/templates/gitignore +++ b/packages/ferric/templates/gitignore @@ -1,4 +1,5 @@ /target +/*.xcframework/ /*.apple.node/ /*.android.node/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bf3e1e4..32790f22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,9 @@ importers: 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 @@ -219,6 +222,9 @@ importers: '@types/picomatch': specifier: ^4.0.3 version: 4.0.3 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 packages/ferric-example: dependencies: @@ -2356,6 +2362,9 @@ packages: '@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==} @@ -7129,6 +7138,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/semver@7.8.0': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': 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", +); From 52c739695ef6a884313a982367ea26aba622d7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sat, 31 Oct 2026 11:43:40 +0100 Subject: [PATCH 4/8] ci: trigger label-gated jobs Co-Authored-By: Claude Opus 5.5 (1M context) From c8f16c2edda1e13e83eadede0bdf06cc778de52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sat, 31 Oct 2026 11:53:41 +0100 Subject: [PATCH 5/8] Fix the release profile placeholder on CRLF checkouts Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ferric/src/init.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ferric/src/init.ts b/packages/ferric/src/init.ts index 5c28e373..4de63cf9 100644 --- a/packages/ferric/src/init.ts +++ b/packages/ferric/src/init.ts @@ -331,8 +331,8 @@ function renderCargoManifest( .replaceAll("{{napiCrateVersion}}", crateVersions.napi) .replaceAll("{{napiDeriveCrateVersion}}", crateVersions["napi-derive"]) .replaceAll("{{napiBuildCrateVersion}}", crateVersions["napi-build"]) - .replaceAll( - "{{releaseProfile}}\n", + .replace( + /\{\{releaseProfile\}\}\r?\n/, profile.length > 0 ? ["", "[profile.release]", ...profile, ""].join("\n") : "", From fd987855fd27c036a5f7199d2faebe669dfdc06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sat, 31 Oct 2026 11:54:09 +0100 Subject: [PATCH 6/8] Allow CRLF in the release profile test Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ferric/src/init.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ferric/src/init.test.ts b/packages/ferric/src/init.test.ts index a5db9ae7..235716a7 100644 --- a/packages/ferric/src/init.test.ts +++ b/packages/ferric/src/init.test.ts @@ -368,7 +368,7 @@ describe("planInit", () => { ); assert(change); assert.equal(parseToml(change.contents)["profile"], undefined); - assert.ok(change.contents.endsWith('"\n')); + assert.match(change.contents, /"\r?\n$/); }); it("defaults to Node-API version 3", async (context) => { From 21f68d1fd27fc777123b464bcad41e17cf223185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sat, 31 Oct 2026 12:03:06 +0100 Subject: [PATCH 7/8] Split `ferric init` planning into steps Each step returns its changes, skips and notices without touching shared state, and package.json edits are pure functions compared against the original rather than tracked with a flag. Planning fails if two steps change the same file. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ferric/src/init.test.ts | 10 + packages/ferric/src/init.ts | 615 +++++++++++++++++-------------- 2 files changed, 349 insertions(+), 276 deletions(-) diff --git a/packages/ferric/src/init.test.ts b/packages/ferric/src/init.test.ts index 235716a7..f7929a17 100644 --- a/packages/ferric/src/init.test.ts +++ b/packages/ferric/src/init.test.ts @@ -8,6 +8,7 @@ import { parse as parseToml } from "smol-toml"; import { applyChanges, + assertDistinctChanges, crateNameFromPackageName, DEFAULT_CRATE_VERSIONS, DEFAULT_NODE_API_VERSION, @@ -65,6 +66,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("crateNameFromPackageName", () => { it("strips the scope", () => { assert.equal(crateNameFromPackageName("@my-org/my-addon"), "my-addon"); diff --git a/packages/ferric/src/init.ts b/packages/ferric/src/init.ts index 4de63cf9..801f74c7 100644 --- a/packages/ferric/src/init.ts +++ b/packages/ferric/src/init.ts @@ -1,5 +1,7 @@ +import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { chalk, @@ -97,6 +99,48 @@ export type InitOptions = { 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. @@ -110,9 +154,6 @@ export async function planInit({ force = false, }: InitOptions): Promise { const resolvedPath = path.resolve(packagePath); - const changes: Change[] = []; - const skips: Skip[] = []; - const notices: string[] = []; const crateVersions = { ...DEFAULT_CRATE_VERSIONS, ...crateVersionsOption }; for (const [crate, version] of Object.entries(crateVersions)) { assertFixable( @@ -132,8 +173,9 @@ export async function planInit({ }, ); - const packageJsonPath = path.join(resolvedPath, "package.json"); - const existingPackageJson = await readFileIfExists(packageJsonPath); + const existingPackageJson = await readFileIfExists( + path.join(resolvedPath, "package.json"), + ); const packageJson = existingPackageJson ? parseJsonObject(existingPackageJson) : undefined; @@ -155,160 +197,219 @@ export async function planInit({ // is the basename `ferric build` writes its outputs and entrypoint with. const libraryName = crateName.replaceAll("-", "_"); - const workspace = findWorkspace(resolvedPath); - const { updated: updatedPackageJson, conflicts } = updatePackageJson({ + 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((step) => step(context))), + ); + assertDistinctChanges(changes); + + return { + packagePath: resolvedPath, packageName, + crateName, libraryName, - workspace, - }); - const indentation = existingPackageJson - ? detectJsonIndentation(existingPackageJson) - : undefined; - const hasConflicts = Object.keys(conflicts).length > 0; - const keptPackageJson = updatedPackageJson ?? packageJson; - const overwritten = - hasConflicts && keptPackageJson - ? stringifyJson({ ...keptPackageJson, ...conflicts }, indentation) - : undefined; - if (overwritten && keptPackageJson && !force) { - skips.push({ - path: packageJsonPath, - previous: stringifyJson(keptPackageJson, indentation), - contents: overwritten, - hint: conflicts["main"] - ? `To keep ${chalk.blue(String(keptPackageJson["main"]))} as the entrypoint, re-export the addon from it: ${chalk.blue(reExportStatement(keptPackageJson, conflicts["main"]))}` + 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 packageJsonContents = - force && overwritten - ? overwritten - : updatedPackageJson && stringifyJson(updatedPackageJson, indentation); - if (packageJsonContents) { - changes.push( - existingPackageJson - ? { + + 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: packageJsonContents, + contents: stringify(result), previous: existingPackageJson, forced: force && hasConflicts, - } - : { - kind: "create", - path: packageJsonPath, - contents: packageJsonContents, }, - ); - } + ], + 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 cargoTomlPath = path.join(resolvedPath, "Cargo.toml"); - const existingCargoToml = await readFileIfExists(cargoTomlPath); - if (existingCargoToml !== undefined) { - const manifest = updateCargoManifest(existingCargoToml, crateVersions); - notices.push(...manifest.notices); - if (manifest.contents !== existingCargoToml) { - changes.push({ - kind: "update", - path: cargoTomlPath, - contents: manifest.contents, - previous: existingCargoToml, - }); - } - for (const [option, value] of Object.entries({ - "--node-api-version": nodeApiVersion, - "--release-profile": releaseProfile, - })) { - if (value !== undefined) { - notices.push( - `Ignored ${chalk.blue(option)}, which only applies when creating a Cargo.toml`, - ); - } - } - } else { - const template = await readTemplate("Cargo.toml"); - changes.push({ - kind: "create", - path: cargoTomlPath, - contents: renderCargoManifest(template, { - crateName, - nodeApiVersion: nodeApiVersion ?? DEFAULT_NODE_API_VERSION, - crateVersions, - releaseProfile: releaseProfile ?? "speed", - }), - }); +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`, + ), + ], + }; +}; - for (const [template, relativePath] of [ - ["build.rs", "build.rs"], - ["lib.rs", path.join("src", "lib.rs")], - ]) { - const outputPath = path.join(resolvedPath, relativePath); - const contents = await readTemplate(template); - const previous = await readFileIfExists(outputPath); - if (previous === undefined) { - changes.push({ kind: "create", path: outputPath, contents }); - } else if (previous !== contents) { - if (force) { - changes.push({ - kind: "update", - path: outputPath, - contents, - previous, - forced: true, - }); - } else { - skips.push({ path: outputPath, contents, previous }); - } - } +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 gitignorePath = path.join(resolvedPath, ".gitignore"); +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 gitignoreTemplate = await readTemplate("gitignore"); - const existingGitignore = await readFileIfExists(gitignorePath); - if (existingGitignore !== undefined) { - const updatedGitignore = appendMissingLines( - existingGitignore, - gitignoreTemplate, - ); - if (updatedGitignore) { - changes.push({ - kind: "update", - path: gitignorePath, - contents: updatedGitignore, - previous: existingGitignore, - }); - } - } else { - changes.push({ - kind: "create", - path: gitignorePath, - contents: gitignoreTemplate, - }); + 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 workspaceChange = await planWorkspaceChange( - workspace, - resolvedPath, - notices, - ); - if (workspaceChange) { - changes.push(workspaceChange); +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 { - packagePath: resolvedPath, - packageName, - crateName, - libraryName, - changes, - skips, - notices, + changes: [{ kind: "update", path: configPath, contents, previous }], }; -} +}; + +const INIT_STEPS: InitStep[] = [ + planPackageJson, + planCargoManifest, + planSourceFiles, + planGitignore, + planWorkspace, +]; function renderCargoManifest( template: string, @@ -362,129 +463,123 @@ export function crateNameFromPackageName(packageName: string) { return packageName.replace(/^@[^/]+\//, ""); } -type PackageJsonOptions = { - packageJson: Record | undefined; - packageName: string; - libraryName: string; - workspace: Workspace | undefined; -}; +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; + }; +} /** - * Add whatever a package needs to build a Rust Node-API module to its - * package.json and bump outdated dependencies, leaving any other value it already - * declares alone. `updated` is undefined if there is nothing to change, and - * `conflicts` holds the entrypoints it declares differently. + * What a package needs to build a Rust Node-API module, leaving any value it + * already declares alone besides outdated dependencies. */ -function updatePackageJson({ +function getPackageJsonUpdates({ packageJson, packageName, libraryName, workspace, -}: PackageJsonOptions) { +}: InitContext): PackageJsonUpdate[] { const { hostSpecifier, ferricSpecifier } = determineDependencySpecifiers(workspace); - const result: Record = { ...packageJson }; - let changed = false; - - for (const [key, value] of Object.entries({ - name: packageName, - version: "0.1.0", - type: "commonjs", - })) { - if (result[key] === undefined) { - result[key] = value; - changed = true; - } - } - - const entrypoints = { - main: `${libraryName}.js`, - types: `${libraryName}.d.ts`, - }; - const conflicts = Object.fromEntries( - Object.entries(entrypoints).filter( - ([key, value]) => result[key] !== undefined && result[key] !== value, + 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, ), - ); - for (const [key, value] of Object.entries(entrypoints)) { - if (result[key] === undefined) { - result[key] = value; - changed = true; - } - } - - // Only extend an existing "files" array: adding one to a package without it - // would suddenly narrow down what that package publishes. - const artifactFiles = [ - `${libraryName}.js`, - `${libraryName}.d.ts`, - `${libraryName}.apple.node`, - `${libraryName}.android.node`, - ]; - const files = getStringArrayProperty(result, "files"); - if (packageJson === undefined) { - result["files"] = artifactFiles; - changed = true; - } else if (files) { - const missing = artifactFiles.filter((file) => !files.includes(file)); - if (missing.length > 0) { - result["files"] = [...files, ...missing]; - changed = true; - } - } - - const scripts = getObjectProperty(result, "scripts") ?? {}; - const missingScripts = Object.entries({ - build: "ferric build", - "build:types": "ferric build --dts-only", - }).filter(([name]) => scripts[name] === undefined); - if (missingScripts.length > 0) { - result["scripts"] = { ...scripts, ...Object.fromEntries(missingScripts) }; - changed = true; - } - - changed = - updateDependency(result, { + withMissingScripts({ + build: "ferric build", + "build:types": "ferric build --dts-only", + }), + withDependency({ name: "react-native-node-api", specifier: hostSpecifier, keys: ["dependencies", "devDependencies", "peerDependencies"], - }) || changed; - changed = - updateDependency(result, { + }), + withDependency({ name: "ferric-cli", specifier: ferricSpecifier, keys: ["devDependencies", "dependencies", "peerDependencies"], - }) || changed; - - return { updated: changed ? result : undefined, conflicts }; -} - -/** - * 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 updateDependency( - packageJson: Record, - { - name, - specifier, - keys, - }: { name: string; specifier: string; keys: string[] }, -) { - const declaredIn = keys.find( - (key) => getObjectProperty(packageJson, key)?.[name] !== undefined, - ); - const key = declaredIn ?? keys[0]; - const dependencies = getObjectProperty(packageJson, key) ?? {}; - const declared = dependencies[name]; - if ( - declaredIn === undefined || - (typeof declared === "string" && isOutdated(declared, specifier)) - ) { - packageJson[key] = { ...dependencies, [name]: specifier }; - return true; - } - return false; + }), + ]; } /** @@ -538,38 +633,6 @@ function appendMissingLines(contents: string, template: string) { return contents + separator + missing.join("\n") + "\n"; } -async function planWorkspaceChange( - workspace: Workspace | undefined, - packagePath: string, - notices: string[], -): Promise { - if (!workspace) { - return undefined; - } - const relativePath = relativeWorkspacePath(workspace, packagePath); - const { patterns, configPath } = workspace; - if (patterns && isPathCovered(patterns, relativePath)) { - return undefined; - } - const contents = await readFileIfExists(configPath); - const updated = - patterns && contents - ? addWorkspacePattern(workspace, contents, relativePath) - : undefined; - if (!updated || contents === undefined) { - notices.push( - `Add ${chalk.blue(relativePath)} to the workspace packages declared by ${prettyPath(configPath)}`, - ); - return undefined; - } - return { - kind: "update", - path: configPath, - contents: updated, - previous: contents, - }; -} - function readTemplate(filename: string) { return fs.promises.readFile(path.join(TEMPLATES_PATH, filename), "utf8"); } From 1a36542680a51264885bcafa9ff1e0ba9967c3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Sat, 31 Oct 2026 12:03:40 +0100 Subject: [PATCH 8/8] Run init steps through an async wrapper Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ferric/src/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ferric/src/init.ts b/packages/ferric/src/init.ts index 801f74c7..13a2f619 100644 --- a/packages/ferric/src/init.ts +++ b/packages/ferric/src/init.ts @@ -211,7 +211,7 @@ export async function planInit({ force, }; const { changes, skips, notices } = mergeStepResults( - await Promise.all(INIT_STEPS.map((step) => step(context))), + await Promise.all(INIT_STEPS.map(async (step) => step(context))), ); assertDistinctChanges(changes);