diff --git a/package-lock.json b/package-lock.json index 16d8c5b41..8a6b2aa63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1282,13 +1282,8 @@ ] }, "node_modules/cargo-cp-artifact": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/cargo-cp-artifact/-/cargo-cp-artifact-0.1.6.tgz", - "integrity": "sha512-CQw0doK/aaF7j041666XzuilHxqMxaKkn+I5vmBsd8SAwS0cO5CqVEVp0xJwOKstyqWZ6WK4Ww3O6p26x/Goyg==", - "dev": true, - "bin": { - "cargo-cp-artifact": "bin/cargo-cp-artifact.js" - } + "resolved": "pkgs/cargo-cp-artifact", + "link": true }, "node_modules/chai": { "version": "4.3.6", @@ -4246,6 +4241,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "pkgs/cargo-cp-artifact": { + "version": "0.1.6", + "license": "MIT", + "bin": { + "cargo-cp-artifact": "bin/cargo-cp-artifact.js" + }, + "devDependencies": { + "mocha": "^9.1.0" + } + }, "pkgs/create-neon": { "version": "0.2.0", "license": "MIT", @@ -5245,10 +5250,10 @@ "dev": true }, "cargo-cp-artifact": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/cargo-cp-artifact/-/cargo-cp-artifact-0.1.6.tgz", - "integrity": "sha512-CQw0doK/aaF7j041666XzuilHxqMxaKkn+I5vmBsd8SAwS0cO5CqVEVp0xJwOKstyqWZ6WK4Ww3O6p26x/Goyg==", - "dev": true + "version": "file:pkgs/cargo-cp-artifact", + "requires": { + "mocha": "^9.1.0" + } }, "chai": { "version": "4.3.6", diff --git a/pkgs/cargo-cp-artifact/LICENSE b/pkgs/cargo-cp-artifact/LICENSE new file mode 100644 index 000000000..dad206127 --- /dev/null +++ b/pkgs/cargo-cp-artifact/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 The Neon Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pkgs/cargo-cp-artifact/README.md b/pkgs/cargo-cp-artifact/README.md new file mode 100644 index 000000000..2625a755c --- /dev/null +++ b/pkgs/cargo-cp-artifact/README.md @@ -0,0 +1,93 @@ +# cargo-cp-artifact + +`cargo-cp-artifact` is a small command line utility for parsing cargo metadata output and copying a compiler artifact to a desired location. + +## Installation + +```sh +npm install -g cargo-cp-artifact +``` + +## Usage + +``` +cargo-cp-artifact --artifact artifact-kind crate-name output-file -- wrapped-command +``` + +`cargo-cp-artifact` accepts a list of crate name and artifact kind to output file mappings and a command to wrap.`cargo-cp-artifact` will read `stdout` of the wrapped command and parse it as [cargo metadata](https://doc.rust-lang.org/cargo/reference/external-tools.html#json-messages). Compiler artifacts that match arguments provided will be copied to the target destination. + +When wrapping a `cargo` command, it is necessary to include a `json` format to `--message-format`. + +### Arguments + +Multiple arguments are allowed to copy multiple build artifacts. + +#### `--artifact` + +_Alias: `-a`_ + +Followed by *three* arguments: `artifact-kind crate-name output-file` + +#### `--npm` + +_Alias: `-n`_ + +Followed by *two* arguments: `artifact-kind output-file` + +The crate name will be read from the `npm_package_name` environment variable. If the package name includes a namespace (`@namespace/package`), the namespace will be removed when matching the crate name (`package`). + +### Artifact Kind + +Valid artifact kinds are `bin`, `cdylib`, and `dylib`. They may be abbreviated as `b`, `c`, and `d` respectively. + +For example, `-ac` is the equivalent of `--artifact cdylib`. + +## Examples + +### Wrapping cargo + +```sh +cargo-cp-artifact -a cdylib my-crate lib/index.node -- cargo build --message-format=json-render-diagnostics +``` + +### Parsing a file + +```sh +cargo-cp-artifact -a cdylib my-crate lib/index.node -- cat build-output.txt +``` + +### `npm` script + +`package.json` +```json +{ + "name": "my-crate", + "scripts": { + "build": "cargo-cp-artifact -nc lib/index.node -- cargo build --message-format=json-render-diagnostics" + } +} +``` + +```sh +npm run build + +# Additional arguments may be passed +npm run build -- --feature=serde +``` + +## Why does this exist? + +At the time of writing, `cargo` does not include a configuration for outputting a library or binary to a specified location. An `--out-dir` option [exists on nightly](https://github.com/rust-lang/cargo/issues/6790), but does not allow specifying the name of the file. + +It surprisingly difficult to reliably find the location of a cargo compiler artifact. It is impacted by many parameters, including: + +* Build profile +* Target, default or specified +* Crate name and name transforms + +However, `cargo` can emit metadata on `stdout` while continuing to provide human readable diagnostics on `stderr`. The metadata may be parsed to more easily and reliably find the location of compiler artifacts. + +`cargo-cp-artifact` chooses to wrap a command as a child process instead of reading `stdin` for two reasons: + +1. Removes the need for `-o pipefile` when integrating with build tooling which may need to be platform agnostic. +2. Allows additional arguments to be provided when used in an [`npm` script](https://docs.npmjs.com/cli/v6/using-npm/scripts). diff --git a/pkgs/cargo-cp-artifact/bin/cargo-cp-artifact.js b/pkgs/cargo-cp-artifact/bin/cargo-cp-artifact.js new file mode 100755 index 000000000..49bb916b2 --- /dev/null +++ b/pkgs/cargo-cp-artifact/bin/cargo-cp-artifact.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +"use strict"; + +const run = require(".."); + +run(process.argv.slice(2), process.env); diff --git a/pkgs/cargo-cp-artifact/package.json b/pkgs/cargo-cp-artifact/package.json new file mode 100644 index 000000000..e4b37e94e --- /dev/null +++ b/pkgs/cargo-cp-artifact/package.json @@ -0,0 +1,34 @@ +{ + "name": "cargo-cp-artifact", + "version": "0.1.6", + "description": "Copies compiler artifacts emitted by rustc by parsing Cargo metadata", + "main": "src/index.js", + "files": [ + "bin", + "src" + ], + "bin": { + "cargo-cp-artifact": "bin/cargo-cp-artifact.js" + }, + "scripts": { + "test": "mocha test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/neon-bindings/neon.git" + }, + "keywords": [ + "cargo", + "rust", + "neon" + ], + "author": "K.J. Valencik", + "license": "MIT", + "bugs": { + "url": "https://github.com/neon-bindings/neon/issues" + }, + "homepage": "https://github.com/neon-bindings/neon/tree/main/pkgs/cargo-cp-artifact", + "devDependencies": { + "mocha": "^9.1.0" + } +} diff --git a/pkgs/cargo-cp-artifact/src/args.js b/pkgs/cargo-cp-artifact/src/args.js new file mode 100644 index 000000000..36fbd3e2a --- /dev/null +++ b/pkgs/cargo-cp-artifact/src/args.js @@ -0,0 +1,131 @@ +"use strict"; + +class ParseError extends Error {} + +const NPM_ENV = "npm_package_name"; +const EXPECTED_COMMAND = [ + "Missing command to execute.", + [ + "cargo-cp-artifct -a cdylib my-crate index.node", + "--", + "cargo build --message-format=json-render-diagnostics", + ].join(" "), +].join("\n"); + +function validateArtifactType(artifactType) { + switch (artifactType) { + case "b": + case "bin": + return "bin"; + case "c": + case "cdylib": + return "cdylib"; + case "d": + case "dylib": + return "dylib"; + default: + } + + throw new ParseError(`Unexpected artifact type: ${artifactType}`); +} + +function getArtifactName({ artifactType, crateName }) { + return `${artifactType}:${crateName}`; +} + +function getCrateNameFromEnv(env) { + if (!env.hasOwnProperty(NPM_ENV)) { + throw new ParseError( + [ + `Could not find the \`${NPM_ENV}\` environment variable.`, + "Expected to be executed from an `npm` command.", + ].join(" ") + ); + } + + const name = env[NPM_ENV]; + const firstSlash = name.indexOf("/"); + + // This is a namespaced package; assume the crate is the un-namespaced version + if (name[0] === "@" && firstSlash > 0) { + return name.slice(firstSlash + 1); + } + + return name; +} + +function parse(argv, env) { + const artifacts = {}; + let tokens = argv; + + function getNext() { + if (!tokens.length) { + throw new ParseError(EXPECTED_COMMAND); + } + + const next = tokens[0]; + tokens = tokens.slice(1); + return next; + } + + function getArtifactType(token) { + if (token[1] !== "-" && token.length === 3) { + return validateArtifactType(token[2]); + } + + return validateArtifactType(getNext()); + } + + function pushArtifact(artifact) { + const name = getArtifactName(artifact); + + artifacts[name] = artifacts[name] || []; + artifacts[name].push(artifact.outputFile); + } + + while (tokens.length) { + const token = getNext(); + + // End of CLI arguments + if (token === "--") { + break; + } + + if ( + token === "--artifact" || + (token.length <= 3 && token.startsWith("-a")) + ) { + const artifactType = getArtifactType(token); + const crateName = getNext(); + const outputFile = getNext(); + + pushArtifact({ artifactType, crateName, outputFile }); + continue; + } + + if (token === "--npm" || (token.length <= 3 && token.startsWith("-n"))) { + const artifactType = getArtifactType(token); + const crateName = getCrateNameFromEnv(env); + const outputFile = getNext(); + + pushArtifact({ artifactType, crateName, outputFile }); + continue; + } + + throw new ParseError(`Unexpected option: ${token}`); + } + + if (!tokens.length) { + throw new ParseError(EXPECTED_COMMAND); + } + + const cmd = getNext(); + + return { + artifacts, + cmd, + args: tokens, + }; +} + +module.exports = { ParseError, getArtifactName, parse }; diff --git a/pkgs/cargo-cp-artifact/src/index.js b/pkgs/cargo-cp-artifact/src/index.js new file mode 100644 index 000000000..250c4ea67 --- /dev/null +++ b/pkgs/cargo-cp-artifact/src/index.js @@ -0,0 +1,145 @@ +"use strict"; + +const { spawn } = require("child_process"); +const { + promises: { copyFile, mkdir, stat }, +} = require("fs"); +const { dirname } = require("path"); +const readline = require("readline"); + +const { ParseError, getArtifactName, parse } = require("./args"); + +function run(argv, env) { + const options = parseArgs(argv, env); + const copied = {}; + + const cp = spawn(options.cmd, options.args, { + stdio: ["inherit", "pipe", "inherit"], + }); + + const rl = readline.createInterface({ input: cp.stdout }); + + cp.on("error", (err) => { + if (options.cmd === "cargo" && err.code === "ENOENT") { + console.error(`Error: could not find the \`cargo\` executable. + +You can find instructions for installing Rust and Cargo at: + + https://www.rust-lang.org/tools/install + +`); + } else { + console.error(err); + } + process.exitCode = 1; + }); + + cp.on("exit", (code) => { + if (!process.exitCode) { + process.exitCode = code; + } + }); + + rl.on("line", (line) => { + try { + processCargoBuildLine(options, copied, line); + } catch (err) { + console.error(err); + process.exitCode = 1; + } + }); + + process.on("exit", () => { + Object.keys(options.artifacts).forEach((name) => { + if (!copied[name]) { + console.error(`Did not copy "${name}"`); + + if (!process.exitCode) { + process.exitCode = 1; + } + } + }); + }); +} + +function processCargoBuildLine(options, copied, line) { + const data = JSON.parse(line); + const { filenames, reason, target } = data; + + if (!data || reason !== "compiler-artifact" || !target) { + return; + } + + const { kind: kinds, name } = data.target; + + if (!Array.isArray(kinds) || !Array.isArray(filenames)) { + return; + } + + // `kind` and `filenames` zip up as key/value pairs + kinds.forEach((kind, i) => { + const filename = filenames[i]; + const key = getArtifactName({ artifactType: kind, crateName: name }); + const outputFiles = options.artifacts[key]; + + if (!outputFiles || !filename) { + return; + } + + Promise.all( + outputFiles.map((outputFile) => copyArtifact(filename, outputFile)) + ) + .then(() => { + copied[key] = true; + }) + .catch((err) => { + process.exitCode = 1; + console.error(err); + }); + }); +} + +async function isNewer(filename, outputFile) { + try { + const prevStats = await stat(outputFile); + const nextStats = await stat(filename); + + return nextStats.mtime > prevStats.mtime; + } catch (_err) {} + + return true; +} + +async function copyArtifact(filename, outputFile) { + if (!(await isNewer(filename, outputFile))) { + return; + } + + const outputDir = dirname(outputFile); + + // Don't try to create the current directory + if (outputDir && outputDir !== ".") { + await mkdir(outputDir, { recursive: true }); + } + + await copyFile(filename, outputFile); +} + +function parseArgs(argv, env) { + try { + return parse(argv, env); + } catch (err) { + if (err instanceof ParseError) { + quitError(err.message); + } else { + throw err; + } + } +} + +function quitError(msg) { + console.error(msg); + process.exit(1); +} + +module.exports = run; diff --git a/pkgs/cargo-cp-artifact/test/args.js b/pkgs/cargo-cp-artifact/test/args.js new file mode 100644 index 000000000..abb953d90 --- /dev/null +++ b/pkgs/cargo-cp-artifact/test/args.js @@ -0,0 +1,132 @@ +"use strict"; + +const assert = require("assert"); + +const { parse } = require("../src/args"); + +describe("Argument Parsing", () => { + it("throws on invalid artifact type", () => { + assert.throws(() => parse(["-an", "a", "b", "--"]), /artifact type/); + }); + + it("npm must have an environment variable", () => { + assert.throws(() => parse(["-nc", "a", "b", "--"], {}), /environment/); + }); + + it("must provide a command", () => { + assert.throws(() => parse(["-ac", "a", "b"]), /Missing command/); + assert.throws(() => parse(["-ac", "a", "b", "--"]), /Missing command/); + }); + + it("cannot provide invalid option", () => { + assert.throws(() => parse(["-q"], {}), /Unexpected option/); + }); + + it("should be able to use --artifact", () => { + const args = "bin my-crate my-bin -- a b c".split(" "); + const expected = { + artifacts: { + "bin:my-crate": ["my-bin"], + }, + cmd: "a", + args: ["b", "c"], + }; + + assert.deepStrictEqual(parse(["--artifact", ...args]), expected); + assert.deepStrictEqual(parse(["-a", ...args]), expected); + }); + + it("should be able to use --npm", () => { + const args = "bin my-bin -- a b c".split(" "); + const env = { + npm_package_name: "my-crate", + }; + + const expected = { + artifacts: { + "bin:my-crate": ["my-bin"], + }, + cmd: "a", + args: ["b", "c"], + }; + + assert.deepStrictEqual(parse(["--npm", ...args], env), expected); + assert.deepStrictEqual(parse(["-n", ...args], env), expected); + }); + + it("should be able to use short-hand for crate type with -a", () => { + const args = "-ab my-crate my-bin -- a b c".split(" "); + const expected = { + artifacts: { + "bin:my-crate": ["my-bin"], + }, + cmd: "a", + args: ["b", "c"], + }; + + assert.deepStrictEqual(parse(args), expected); + }); + + it("should be able to use short-hand for crate type with -n", () => { + const args = "-nb my-bin -- a b c".split(" "); + const env = { + npm_package_name: "my-crate", + }; + + const expected = { + artifacts: { + "bin:my-crate": ["my-bin"], + }, + cmd: "a", + args: ["b", "c"], + }; + + assert.deepStrictEqual(parse(args, env), expected); + }); + + it("should remove namespace from package name", () => { + const args = "-nc index.node -- a b c".split(" "); + const env = { + npm_package_name: "@my-namespace/my-crate", + }; + + const expected = { + artifacts: { + "cdylib:my-crate": ["index.node"], + }, + cmd: "a", + args: ["b", "c"], + }; + + assert.deepStrictEqual(parse(args, env), expected); + }); + + it("should be able to provide multiple artifacts", () => { + const args = ` + -nb my-bin + --artifact d a b + -ac my-crate index.node + --npm bin other-copy + -- a b c + ` + .trim() + .split("\n") + .map((line) => line.trim()) + .join(" ") + .split(" "); + + const env = { + npm_package_name: "my-crate", + }; + + assert.deepStrictEqual(parse(args, env), { + artifacts: { + "bin:my-crate": ["my-bin", "other-copy"], + "dylib:a": ["b"], + "cdylib:my-crate": ["index.node"], + }, + cmd: "a", + args: ["b", "c"], + }); + }); +});