diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a6a36a..110fff7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -242,3 +242,27 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload %GITHUB_REF:~10% dotslash-windows-arm64.%GITHUB_REF:~10%.tar.gz shell: cmd + + npm-publish: + # This job depends on release assets uploaded by the previous jobs. + # Keep this job's dependencies in sync with node/platforms.js. + needs: [macos, windows, windows-arm64, linux-musl-x86_64, linux-musl-arm64] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: node + steps: + - uses: actions/checkout@v4 + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run build -- --tag ${GITHUB_REF#refs/tags/} + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test-node.yml b/.github/workflows/test-node.yml new file mode 100644 index 0000000..3c6b433 --- /dev/null +++ b/.github/workflows/test-node.yml @@ -0,0 +1,21 @@ +name: JavaScript chores + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: node + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run lint diff --git a/.gitignore b/.gitignore index d8f63cd..49b8886 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store /target +**/node_modules +node/bin/*/ diff --git a/node/.prettierrc b/node/.prettierrc new file mode 100644 index 0000000..cf4ee91 --- /dev/null +++ b/node/.prettierrc @@ -0,0 +1,9 @@ +{ + "singleQuote": true, + "overrides": [ + { + "files": "bin/dotslash", + "options": { "parser": "babel" } + } + ] +} diff --git a/node/README.md b/node/README.md new file mode 100644 index 0000000..b53e6fd --- /dev/null +++ b/node/README.md @@ -0,0 +1,42 @@ +# DotSlash: simplified executable deployment + +[DotSlash](https://dotslash-cli.com/docs/) (`dotslash`) is a command-line tool that lets you represent a set of +platform-specific, heavyweight executables with an equivalent small, +easy-to-read text file. In turn, this makes it efficient to store executables in +source control without hurting repository size. This paves the way for checking +build toolchains and other tools directly into the repo, reducing dependencies +on the host environment and thereby facilitating reproducible builds. + +The `fb-dotslash` npm package allows you to use DotSlash in your Node.js projects without having to install DotSlash globally. This is particularly useful for package authors, who have traditionally needed to either include binaries for _all_ platforms or manage their own download and caching in a postinstall script. + +## Using DotSlash in an npm package + +First, you'll need to write a [DotSlash file](https://dotslash-cli.com/docs/dotslash-file/) that describes the binary you want to distribute. + +If your npm package declares `fb-dotslash` as a dependency, any commands executed as part of `npm run` and `npm exec` will have `dotslash` available on the `PATH`. This means you can, for example, directly reference DotSlash files in your `package.json` scripts with no further setup: + +```json +{ + "name": "my-package", + "scripts": { + "foo": "path/to/dotslash/file" + }, + "dependencies": { + "fb-dotslash": "^0.5.7" + } +} +``` + +If you need to use `dotslash` in some other context, you can use `require('fb-dotslash')` to get the path to the DotSlash executable appropriate for the current platform: + +```js +const dotslash = require('fb-dotslash'); +const {spawnSync} = require('child_process'); +spawnSync(dotslash, ['path/to/dotslash/file'], {stdio: 'inherit']); +``` + +## License + +DotSlash is licensed under both the MIT license and Apache-2.0 license; the +exact terms can be found in the [LICENSE-MIT](https://github.com/facebook/dotslash/blob/main/LICENSE-MIT) and +[LICENSE-APACHE](https://github.com/facebook/dotslash/blob/main/LICENSE-APACHE) files, respectively. diff --git a/node/bin/dotslash b/node/bin/dotslash new file mode 100755 index 0000000..6aa0970 --- /dev/null +++ b/node/bin/dotslash @@ -0,0 +1,22 @@ +#!/usr/bin/env node +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under both the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree and the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. + */ + +'use strict'; + +const spawn = require('child_process').spawn; + +const input = process.argv.slice(2); +const bin = require('../'); + +if (bin !== null) { + spawn(bin, input, { stdio: 'inherit' }).on('exit', process.exit); +} else { + throw new Error('Platform not supported.'); +} diff --git a/node/index.d.ts b/node/index.d.ts new file mode 100644 index 0000000..ca857b0 --- /dev/null +++ b/node/index.d.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + * + * @format + */ + +declare const binaryPath: string; +export = binaryPath; diff --git a/node/index.js b/node/index.js new file mode 100644 index 0000000..019e385 --- /dev/null +++ b/node/index.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +'use strict'; + +const os = require('os'); +const path = require('path'); +const { artifactsByPlatformAndArch } = require('./platforms'); + +const artifacts = artifactsByPlatformAndArch[os.platform()]; +const { slug, binary } = artifacts[os.arch()] ?? artifacts['*']; + +module.exports = path.join(__dirname, 'bin', slug, binary); diff --git a/node/index.js.flow b/node/index.js.flow new file mode 100644 index 0000000..0b12727 --- /dev/null +++ b/node/index.js.flow @@ -0,0 +1,15 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + * + * @format + * @flow strict-local + */ + +declare var binaryPath: string; +module.exports = binaryPath; diff --git a/node/package-lock.json b/node/package-lock.json new file mode 100644 index 0000000..5c09294 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "fb-dotslash", + "version": "0.0.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fb-dotslash", + "version": "0.0.0-dev", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "devDependencies": { + "prettier": "3.6.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..c419d8e --- /dev/null +++ b/node/package.json @@ -0,0 +1,40 @@ +{ + "name": "fb-dotslash", + "version": "0.0.0-dev", + "bin": { + "dotslash": "bin/dotslash" + }, + "description": "Command-line tool to facilitate fetching an executable, caching it, and then running it.", + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/dotslash.git" + }, + "homepage": "https://dotslash-cli.com/", + "bugs": "https://github.com/facebook/dotslash/issues", + "contributors": [ + "Michael Bolin ", + "Andres Suarez ", + "Moti Zilberman " + ], + "main": "index.js", + "files": [ + "bin", + "platforms.js", + "index.js", + "index.js.flow", + "index.d.ts" + ], + "scripts": { + "clean": "node scripts/clean-package", + "build": "npm run fix && node scripts/build-package", + "fix": "prettier --write .", + "lint": "prettier --check ." + }, + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "prettier": "3.6.2" + } +} diff --git a/node/platforms.js b/node/platforms.js new file mode 100644 index 0000000..320893e --- /dev/null +++ b/node/platforms.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +module.exports = { + // Keep in sync with .github/workflows/release.yml - the 'npm-publish' job's dependencies + // MUST include the build job for each artifact listed below. + artifactsByPlatformAndArch: { + linux: { + arm64: { + // Build job: 'linux-musl-arm64' + slug: 'linux-musl.aarch64', + binary: 'dotslash', + }, + x64: { + // Build job: 'linux-musl-x86_64' + slug: 'linux-musl.x86_64', + binary: 'dotslash', + }, + }, + darwin: { + '*': { + // Build job: 'macos' + slug: 'macos', + binary: 'dotslash', + }, + }, + win32: { + arm64: { + // Build job: 'windows-arm64' + slug: 'windows-arm64', + binary: 'dotslash.exe', + }, + x64: { + // Build job: 'windows' + slug: 'windows', + binary: 'dotslash.exe', + }, + }, + }, +}; diff --git a/node/scripts/build-package.js b/node/scripts/build-package.js new file mode 100755 index 0000000..278daa1 --- /dev/null +++ b/node/scripts/build-package.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +'use strict'; + +const { parseArgs } = require('util'); +const { promises: fs } = require('fs'); +const path = require('path'); +const os = require('os'); +const { artifactsByPlatformAndArch } = require('../platforms'); +const { spawnSync } = require('child_process'); + +const PACKAGE_JSON_PATH = path.join(__dirname, '..', 'package.json'); +const BIN_PATH = path.join(__dirname, '..', 'bin'); +const GITHUB_REPO = 'facebook/dotslash'; + +async function main() { + const { + values: { tag, prerelease }, + } = parseArgs({ + options: { + tag: { + short: 't', + type: 'string', + }, + prerelease: { + type: 'boolean', + }, + }, + }); + + if (tag == null) { + throw new Error('Missing required argument: --tag'); + } + + await deleteOldBinaries(); + const versionInfo = getVersionInfoFromArgs(tag, prerelease); + if (versionInfo.prerelease && !prerelease) { + console.warn( + `Building a prerelease version because the tag ${tag} does not seem to denote a valid semver string.`, + ); + } + await fetchBinaries(tag); + await updatePackageJson(versionInfo); +} + +function getVersionInfoFromArgs(tag, prerelease) { + // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + const SEMVER_WITH_LEADING_V = + /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + if (SEMVER_WITH_LEADING_V.test(tag)) { + return { tag, version: tag.slice(1), prerelease }; + } + return { + tag, + version: '0.0.0-' + tag.replaceAll(/[^0-9a-zA-Z-]+/g, '-'), + prerelease: true, + }; +} + +async function deleteOldBinaries() { + const entries = await fs.readdir(BIN_PATH, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + await fs.rm(path.join(BIN_PATH, entry.name), { + recursive: true, + force: true, + }); + } +} + +async function fetchBinaries(tag) { + const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dotslash')); + try { + for (const [platform, archToArtifact] of Object.entries( + artifactsByPlatformAndArch, + )) { + for (const [arch, descriptor] of Object.entries(archToArtifact)) { + const { slug, binary } = descriptor; + console.log( + `Fetching ${platform} ${arch} binary (${slug} ${binary})...`, + ); + const tarballName = `dotslash-${slug}.tar.gz`; + const downloadURL = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${tarballName}`; + const tarballPath = path.join(scratchDir, tarballName); + await download(downloadURL, tarballPath); + const extractDir = path.join(BIN_PATH, slug); + await fs.mkdir(extractDir, { recursive: true }); + spawnSyncSafe('tar', ['-xzf', tarballPath, '-C', extractDir]); + await fs.rm(tarballPath); + if (!(await existsAndIsExecutable(path.join(extractDir, binary)))) { + throw new Error( + `Failed to extract ${binary} from ${tarballPath} to ${extractDir}`, + ); + } + } + } + } finally { + await fs.rm(scratchDir, { force: true, recursive: true }); + } +} + +async function existsAndIsExecutable(filePath) { + try { + await fs.access(filePath, fs.constants.R_OK | fs.constants.X_OK); + return true; + } catch (e) { + return false; + } +} + +async function download(url, dest) { + spawnSyncSafe('curl', ['-L', url, '-o', dest, '--fail-with-body'], { + stdio: 'inherit', + }); +} + +async function updatePackageJson({ version, prerelease }) { + const packageJson = await fs.readFile(PACKAGE_JSON_PATH, 'utf8'); + const packageJsonObj = JSON.parse(packageJson); + packageJsonObj.version = version + (prerelease ? '-' + Date.now() : ''); + await fs.writeFile( + PACKAGE_JSON_PATH, + JSON.stringify(packageJsonObj, null, 2) + '\n', + ); + console.log('Updated package.json to version', packageJsonObj.version); +} + +function spawnSyncSafe(command, args, options) { + args = args ?? []; + console.log('Running:', command, args.join(' ')); + const result = spawnSync(command, args, options); + if (result.status != null && result.status !== 0) { + throw new Error(`Command ${command} exited with status ${result.status}`); + } + if (result.error != null) { + throw result.error; + } + if (result.signal != null) { + throw new Error( + `Command ${command} was killed with signal ${result.signal}`, + ); + } + return result; +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/node/scripts/clean-package.js b/node/scripts/clean-package.js new file mode 100755 index 0000000..dcdc14f --- /dev/null +++ b/node/scripts/clean-package.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +'use strict'; + +const { promises: fs } = require('fs'); +const path = require('path'); + +const PACKAGE_JSON_PATH = path.join(__dirname, '..', 'package.json'); +const BIN_PATH = path.join(__dirname, '..', 'bin'); +const DEFAULT_VERSION = '0.0.0-dev'; + +async function deleteOldBinaries() { + const entries = await fs.readdir(BIN_PATH, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + await fs.rm(path.join(BIN_PATH, entry.name), { + recursive: true, + force: true, + }); + } +} + +async function cleanPackageJson() { + const packageJson = await fs.readFile(PACKAGE_JSON_PATH, 'utf8'); + const packageJsonObj = JSON.parse(packageJson); + packageJsonObj.version = DEFAULT_VERSION; + await fs.writeFile( + PACKAGE_JSON_PATH, + JSON.stringify(packageJsonObj, null, 2) + '\n', + ); +} + +async function main() { + await deleteOldBinaries(); + await cleanPackageJson(); +} + +module.exports = { deleteOldBinaries }; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/website/docs/installation.md b/website/docs/installation.md index 6564447..4087b62 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -110,6 +110,34 @@ update any environment variables to get DotSlash to work. Though note that `cargo install` does not create a universal binary, so you may be better off [building from source](#build-from-source). +## Install from npm + +To use DotSlash in Node.js projects, you can install it as a dependency: + +```shell +npm install --save fb-dotslash +``` + +This will install a suitable `dotslash` binary in `node_modules/.bin` and ensure +it's on the `PATH` when executing any `package.json` scripts, as well as during +`npm exec`, etc. + +:::note + +You can also use `npx fb-dotslash ./some_dotslash_file` to run a DotSlash file +from the command line without installing anything. + +::: + +For more advanced use cases, import the `fb-dotslash` package directly in your +code: + +```js +const dotslash = require('fb-dotslash'); +const {spawnSync} = require('child_process'); +spawnSync(dotslash, ['./some_dotslash_file'], {stdio: 'inherit']); +``` + ## Build from source The short version of the build process is: