From 766fc19de9b1e39f8e88a23f90710b0e666742f0 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Mon, 18 Aug 2025 09:20:00 -0700 Subject: [PATCH 1/2] feat: create npm package (#74) Summary: Closes https://github.com/facebook/dotslash/issues/50. - [x] Basic structure of the package - [x] Publish under a temporary name ([`motizilberman/dotslash`](https://www.npmjs.com/package/motizilberman/dotslash)) - Done using `npm run build -- --version 0.5.7 --prerelease` - [x] Test on all platforms - [x] Iterate on README and test the workflows described there - [x] Add docs - [x] Set up GitHub Action for publishing to npm on every release - [x] Add Flow and TypeScript definition files for convenience - [x] Decide on a final package name and publish a non-prerelease version - ~We can either ask npm nicely to free up `dotslash` - currently unavailable because of [`dot-slash`](https://www.npmjs.com/package/dot-slash), a package last published 10 years ago with 5 weekly downloads - or go with e.g. `fb-dotslash` (which I have [reserved](https://www.npmjs.com/package/fb-dotslash)).~ - --> `fb-dotslash` - [x] Transfer package to Meta - --> added `fb` as a maintainer Possible follow-up scope (definitely not in this PR): - Add an *optional* postinstall script that replaces `bin/dotslash` with a symlink to the correct binary, thus making DotSlash'd tools start even faster in environments that respect postinstall scripts. ## How to publish the package ``` cd node npm ci npm run lint npm run build -- --version $RELEASED_DOTSLASH_VERSION # add --prerelease for testing npm publish ``` Differential Revision: D79818880 Pulled By: motiz88 --- .github/workflows/release.yml | 21 +++++ .github/workflows/test-node.yml | 21 +++++ .gitignore | 2 + node/.prettierrc | 9 ++ node/README.md | 42 +++++++++ node/bin/dotslash | 22 +++++ node/index.d.ts | 14 +++ node/index.js | 20 ++++ node/index.js.flow | 15 +++ node/package-lock.json | 38 ++++++++ node/package.json | 40 ++++++++ node/platforms.js | 47 ++++++++++ node/scripts/build-package.js | 160 ++++++++++++++++++++++++++++++++ node/scripts/clean-package.js | 56 +++++++++++ website/docs/installation.md | 28 ++++++ 15 files changed, 535 insertions(+) create mode 100644 .github/workflows/test-node.yml create mode 100644 node/.prettierrc create mode 100644 node/README.md create mode 100755 node/bin/dotslash create mode 100644 node/index.d.ts create mode 100644 node/index.js create mode 100644 node/index.js.flow create mode 100644 node/package-lock.json create mode 100644 node/package.json create mode 100644 node/platforms.js create mode 100755 node/scripts/build-package.js create mode 100755 node/scripts/clean-package.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a6a36a..f4f6e28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -242,3 +242,24 @@ 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 + 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: From 8f3dd6bb6baaa240647e4d8bc182c7115f41e774 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 19 Aug 2025 19:00:47 +0100 Subject: [PATCH 2/2] fix: re-add id-token write permission to npm-publish job --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4f6e28..110fff7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,6 +248,9 @@ 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