From 5d3397fac7886cac7bedb09d419df0ec82b9ac62 Mon Sep 17 00:00:00 2001 From: Teddy Kahwaji Date: Tue, 13 Oct 2026 12:37:48 -0400 Subject: [PATCH] feat(npm): add npm distribution with per-platform binary packages Publish pup as @datadog/pup so it can run with `npx @datadog/pup` on any machine with Node.js, without Homebrew or a manual download. The root package is a small launcher; each release binary ships in its own optionalDependencies package (@datadog/pup-) so npm installs only the one matching the host. - npm/pup: launcher that resolves the platform package, passes arguments and exit codes through, forwards SIGINT/SIGTERM/SIGHUP, and exits nonzero when the binary is killed by a signal Node ignores (SIGPIPE) - npm/scripts/build-packages.js: builds the platform and root packages from GitHub release archives and stamps the version. Every archive must be listed in the release checksums and match them, and each platform package keeps the archive's third-party notices - npm-publish.yml: manual-only publish of an existing release, dry run by default. It verifies the cosign-signed checksums, builds with the packaging code from the workflow's own revision so older releases can be published, and skips packages already published with matching contents so a failed run can be retried. Only a newer stable release moves the `latest` dist-tag; prereleases go to `next` and older releases to `backfill`. Runs are serialized, and a registry lookup error other than not-found stops the publish - ci.yml: runs the launcher and builder tests Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 ++ .github/workflows/npm-publish.yml | 130 ++++++++++++++++++++++ .gitignore | 1 + README.md | 13 +++ npm/pup/README.md | 35 ++++++ npm/pup/bin/pup.js | 41 +++++++ npm/pup/lib/platforms.js | 38 +++++++ npm/pup/package.json | 28 +++++ npm/pup/test/launcher.test.js | 80 ++++++++++++++ npm/pup/test/platforms.test.js | 58 ++++++++++ npm/scripts/build-packages.js | 166 +++++++++++++++++++++++++++++ npm/scripts/build-packages.test.js | 132 +++++++++++++++++++++++ 12 files changed, 733 insertions(+) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 npm/pup/README.md create mode 100644 npm/pup/bin/pup.js create mode 100644 npm/pup/lib/platforms.js create mode 100644 npm/pup/package.json create mode 100644 npm/pup/test/launcher.test.js create mode 100644 npm/pup/test/platforms.test.js create mode 100644 npm/scripts/build-packages.js create mode 100644 npm/scripts/build-packages.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a626ce..76d0ffef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,17 @@ jobs: - name: Report binary size run: ls -lh target/release/pup.exe + npm-packaging: + name: npm Packaging + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - name: Test launcher and package builder + run: node --test 'npm/**/*.test.js' + wasm: name: WASM (WASI + Browser) runs-on: ubuntu-latest diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 00000000..ea2f6eb6 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,130 @@ +name: Publish to npm + +on: + workflow_dispatch: + inputs: + tag: + description: Existing GitHub release tag to publish (e.g. v1.23.7) + required: true + type: string + dry_run: + description: Run npm publish with --dry-run + required: true + default: true + type: boolean + +permissions: + contents: read + id-token: write # npm provenance + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + publish: + name: Publish ${{ inputs.tag }} + runs-on: ubuntu-latest + environment: npm-publish + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Require npm token for real publishes + if: ${{ !inputs.dry_run }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NPM_TOKEN secret is not configured" >&2 + exit 1 + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Download release assets + run: | + VERSION="${TAG#v}" + mkdir -p assets + gh release download "$TAG" --dir assets \ + --pattern "pup_${VERSION}_Darwin_*.tar.gz" \ + --pattern "pup_${VERSION}_Linux_*.tar.gz" \ + --pattern "pup_${VERSION}_Windows_*.zip" \ + --pattern "pup_${VERSION}_checksums.txt" \ + --pattern "pup_${VERSION}_checksums.txt.sigstore.json" + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Verify release signature + working-directory: assets + run: | + CHECKSUMS="pup_${TAG#v}_checksums.txt" + cosign verify-blob \ + --bundle "${CHECKSUMS}.sigstore.json" \ + --certificate-identity "https://github.com/DataDog/pup/.github/workflows/release.yml@refs/tags/${TAG}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$CHECKSUMS" + env: + TAG: ${{ inputs.tag }} + + - name: Build packages + run: | + node npm/scripts/build-packages.js \ + --version "${TAG#v}" \ + --assets assets \ + --checksums "assets/pup_${TAG#v}_checksums.txt" \ + --out npm-out + env: + TAG: ${{ inputs.tag }} + + - name: Publish + run: | + VERSION="${TAG#v}" + if ! LATEST=$(npm view @datadog/pup dist-tags.latest 2>npm-view.err); then + if ! grep -q E404 npm-view.err; then + cat npm-view.err >&2 + exit 1 + fi + LATEST="" + fi + DIST_TAG=latest + if [[ "$VERSION" == *-* ]]; then + DIST_TAG=next + elif [ -n "$LATEST" ] && [ "$(printf '%s\n%s\n' "$LATEST" "$VERSION" | sort -V | tail -n1)" != "$VERSION" ]; then + DIST_TAG=backfill + fi + echo "Publishing ${VERSION} with dist-tag ${DIST_TAG} (current latest: ${LATEST:-none})" + FLAGS="--access public --provenance --tag $DIST_TAG" + if [ "$DRY_RUN" = "true" ]; then + FLAGS="$FLAGS --dry-run" + fi + for dir in npm-out/pup-*/ npm-out/pup/; do + NAME=$(node -p "require('./${dir}package.json').name") + if ! PUBLISHED=$(npm view "${NAME}@${VERSION}" dist.shasum 2>npm-view.err); then + if ! grep -q E404 npm-view.err; then + cat npm-view.err >&2 + exit 1 + fi + PUBLISHED="" + fi + if [ -n "$PUBLISHED" ]; then + LOCAL=$(npm pack "$dir" --dry-run --json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8'))[0].shasum") + if [ "$PUBLISHED" != "$LOCAL" ]; then + echo "${NAME}@${VERSION} is already published with different contents" >&2 + exit 1 + fi + echo "${NAME}@${VERSION} is already published; skipping" + continue + fi + npm publish "$dir" $FLAGS + done + env: + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index c521e65c..2f3586fa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target/ # Binary pup +!/npm/pup/ # IDEs .idea/ diff --git a/README.md b/README.md index 59064ba8..513fe4d5 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,19 @@ brew tap datadog-labs/pack brew install datadog-labs/pack/pup ``` +### npm + +```bash +# Run without installing +npx @datadog/pup auth login +npx @datadog/pup monitors list + +# Or install globally +npm install -g @datadog/pup +``` + +See [npm/pup/README.md](npm/pup/README.md) for how the platform packages work. + ### Build from Source ```bash diff --git a/npm/pup/README.md b/npm/pup/README.md new file mode 100644 index 00000000..9eb3dcf6 --- /dev/null +++ b/npm/pup/README.md @@ -0,0 +1,35 @@ +# @datadog/pup + +The [pup](https://github.com/DataDog/pup) Datadog CLI, distributed through npm. + +## Usage + +Run without installing: + +```bash +npx @datadog/pup auth login +npx @datadog/pup monitors list --tags="team:api-platform" +``` + +Or install globally: + +```bash +npm install -g @datadog/pup +pup --help +``` + +## How it works + +This package contains a small Node.js launcher. The native pup binary ships in a separate package per platform, listed as `optionalDependencies`, so npm downloads only the one matching your machine: + +| Package | Platform | +| --------------------------- | --------------------- | +| `@datadog/pup-darwin-arm64` | macOS (Apple Silicon) | +| `@datadog/pup-darwin-x64` | macOS (Intel) | +| `@datadog/pup-linux-arm64` | Linux arm64 | +| `@datadog/pup-linux-x64` | Linux x86_64 | +| `@datadog/pup-win32-x64` | Windows x86_64 | + +The launcher passes arguments, exit codes, and signals through to the binary. If the platform package is missing, reinstall without `--omit=optional` or `--no-optional`. + +The binaries are the same ones attached to each [GitHub release](https://github.com/DataDog/pup/releases). diff --git a/npm/pup/bin/pup.js b/npm/pup/bin/pup.js new file mode 100644 index 00000000..cd8a6de6 --- /dev/null +++ b/npm/pup/bin/pup.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +'use strict'; + +const {spawn} = require('node:child_process'); +const os = require('node:os'); +const {resolveBinaryPath} = require('../lib/platforms.js'); + +const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']; + +let binary; +try { + binary = resolveBinaryPath(); +} catch (error) { + console.error(error.message); + process.exit(1); +} + +const child = spawn(binary, process.argv.slice(2), {stdio: 'inherit'}); + +for (const signal of FORWARDED_SIGNALS) { + process.on(signal, () => child.kill(signal)); +} + +child.on('error', error => { + console.error(`Failed to run ${binary}: ${error.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + for (const forwarded of FORWARDED_SIGNALS) { + process.removeAllListeners(forwarded); + } + + process.exitCode = 128 + (os.constants.signals[signal] ?? 0); + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); +}); diff --git a/npm/pup/lib/platforms.js b/npm/pup/lib/platforms.js new file mode 100644 index 00000000..42b38113 --- /dev/null +++ b/npm/pup/lib/platforms.js @@ -0,0 +1,38 @@ +'use strict'; + +const PLATFORMS = { + 'darwin-arm64': {os: 'darwin', cpu: 'arm64', asset: 'Darwin_arm64.tar.gz', binary: 'pup'}, + 'darwin-x64': {os: 'darwin', cpu: 'x64', asset: 'Darwin_x86_64.tar.gz', binary: 'pup'}, + 'linux-arm64': {os: 'linux', cpu: 'arm64', asset: 'Linux_arm64.tar.gz', binary: 'pup'}, + 'linux-x64': {os: 'linux', cpu: 'x64', asset: 'Linux_x86_64.tar.gz', binary: 'pup'}, + 'win32-x64': {os: 'win32', cpu: 'x64', asset: 'Windows_x86_64.zip', binary: 'pup.exe'}, +}; + +function platformPackageName(key) { + return `@datadog/pup-${key}`; +} + +function resolveBinaryPath({ + platform = process.platform, + arch = process.arch, + resolve = require.resolve, +} = {}) { + const key = `${platform}-${arch}`; + const entry = PLATFORMS[key]; + if (!entry) { + throw new Error( + `pup does not publish a binary for ${key}. Supported platforms: ${Object.keys(PLATFORMS).join(', ')}.`, + ); + } + + const packageName = platformPackageName(key); + try { + return resolve(`${packageName}/bin/${entry.binary}`); + } catch { + throw new Error( + `The ${packageName} package is not installed. It is an optional dependency of @datadog/pup; reinstall without --omit=optional or --no-optional.`, + ); + } +} + +module.exports = {PLATFORMS, platformPackageName, resolveBinaryPath}; diff --git a/npm/pup/package.json b/npm/pup/package.json new file mode 100644 index 00000000..3ddb50e2 --- /dev/null +++ b/npm/pup/package.json @@ -0,0 +1,28 @@ +{ + "name": "@datadog/pup", + "version": "0.0.0", + "description": "Datadog CLI for AI agents and humans", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/DataDog/pup.git", + "directory": "npm/pup" + }, + "bin": { + "pup": "bin/pup.js" + }, + "files": [ + "bin", + "lib" + ], + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@datadog/pup-darwin-arm64": "0.0.0", + "@datadog/pup-darwin-x64": "0.0.0", + "@datadog/pup-linux-arm64": "0.0.0", + "@datadog/pup-linux-x64": "0.0.0", + "@datadog/pup-win32-x64": "0.0.0" + } +} diff --git a/npm/pup/test/launcher.test.js b/npm/pup/test/launcher.test.js new file mode 100644 index 00000000..0b514778 --- /dev/null +++ b/npm/pup/test/launcher.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {spawn} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {PLATFORMS, platformPackageName} = require('../lib/platforms.js'); + +const LAUNCHER = path.join(__dirname, '..', 'bin', 'pup.js'); +const HOST_KEY = `${process.platform}-${process.arch}`; +const skip = process.platform === 'win32' || !PLATFORMS[HOST_KEY] ? 'needs a supported POSIX host' : false; + +function installFakeBinary(script) { + const nodePath = fs.mkdtempSync(path.join(os.tmpdir(), 'pup-launcher-')); + const binDir = path.join(nodePath, platformPackageName(HOST_KEY), 'bin'); + fs.mkdirSync(binDir, {recursive: true}); + fs.writeFileSync(path.join(binDir, PLATFORMS[HOST_KEY].binary), `#!/bin/sh\n${script}\n`, {mode: 0o755}); + return nodePath; +} + +function runLauncher(nodePath, args = []) { + return spawn(process.execPath, [LAUNCHER, ...args], { + env: {...process.env, NODE_PATH: nodePath}, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function waitForExit(child) { + return new Promise(resolve => { + child.on('exit', (code, signal) => resolve({code, signal})); + }); +} + +test('passes arguments through and propagates the exit code', {skip}, async () => { + const nodePath = installFakeBinary('echo "$@"; exit 7'); + const child = runLauncher(nodePath, ['monitors', 'list', '--tags=team:a b']); + let stdout = ''; + child.stdout.on('data', chunk => { + stdout += chunk; + }); + + assert.deepEqual(await waitForExit(child), {code: 7, signal: null}); + assert.equal(stdout, 'monitors list --tags=team:a b\n'); +}); + +test('forwards SIGTERM to the binary and exits by the same signal', {skip}, async () => { + const nodePath = installFakeBinary(`trap 'echo got-term > "${'$'}0.term"; exit 143' TERM; echo ready; while :; do sleep 0.1; done`); + const marker = path.join(nodePath, platformPackageName(HOST_KEY), 'bin', `${PLATFORMS[HOST_KEY].binary}.term`); + const child = runLauncher(nodePath); + await new Promise(resolve => { + child.stdout.once('data', resolve); + }); + + child.kill('SIGTERM'); + + const result = await waitForExit(child); + assert.equal(fs.readFileSync(marker, 'utf8'), 'got-term\n'); + assert.ok(result.code === 143 || result.signal === 'SIGTERM', JSON.stringify(result)); +}); + +test('a binary killed by SIGPIPE makes the launcher fail', {skip}, async () => { + const nodePath = installFakeBinary('kill -PIPE $$'); + + const result = await waitForExit(runLauncher(nodePath)); + + assert.ok(result.code === 141 || result.signal === 'SIGPIPE', JSON.stringify(result)); +}); + +test('exits 1 with guidance when the platform package is missing', {skip}, async () => { + const child = runLauncher(fs.mkdtempSync(path.join(os.tmpdir(), 'pup-launcher-empty-'))); + let stderr = ''; + child.stderr.on('data', chunk => { + stderr += chunk; + }); + + assert.deepEqual(await waitForExit(child), {code: 1, signal: null}); + assert.match(stderr, /package is not installed/); +}); diff --git a/npm/pup/test/platforms.test.js b/npm/pup/test/platforms.test.js new file mode 100644 index 00000000..c947c325 --- /dev/null +++ b/npm/pup/test/platforms.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {PLATFORMS, platformPackageName, resolveBinaryPath} = require('../lib/platforms.js'); + +test('resolves the platform package binary for every supported platform', () => { + for (const [key, entry] of Object.entries(PLATFORMS)) { + const [platform, arch] = key.split('-'); + const requested = []; + const resolved = resolveBinaryPath({ + platform, + arch, + resolve: id => { + requested.push(id); + return `/node_modules/${id}`; + }, + }); + assert.deepEqual(requested, [`${platformPackageName(key)}/bin/${entry.binary}`]); + assert.equal(resolved, `/node_modules/${platformPackageName(key)}/bin/${entry.binary}`); + } +}); + +test('uses pup.exe only on Windows', () => { + assert.equal(PLATFORMS['win32-x64'].binary, 'pup.exe'); + for (const [key, entry] of Object.entries(PLATFORMS)) { + if (key !== 'win32-x64') { + assert.equal(entry.binary, 'pup'); + } + } +}); + +test('rejects platforms pup does not release', () => { + for (const [platform, arch] of [ + ['win32', 'arm64'], + ['linux', 'ia32'], + ['freebsd', 'x64'], + ]) { + assert.throws( + () => resolveBinaryPath({platform, arch, resolve: () => assert.fail('must not resolve')}), + new RegExp(`does not publish a binary for ${platform}-${arch}`), + ); + } +}); + +test('explains a missing optional dependency', () => { + assert.throws( + () => + resolveBinaryPath({ + platform: 'linux', + arch: 'x64', + resolve: () => { + throw new Error('Cannot find module'); + }, + }), + /@datadog\/pup-linux-x64 package is not installed.*--omit=optional/, + ); +}); diff --git a/npm/scripts/build-packages.js b/npm/scripts/build-packages.js new file mode 100644 index 00000000..10b093f5 --- /dev/null +++ b/npm/scripts/build-packages.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +'use strict'; + +const {execFileSync} = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {parseArgs} = require('node:util'); +const {PLATFORMS, platformPackageName} = require('../pup/lib/platforms.js'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const ROOT_PACKAGE_DIR = path.join(REPO_ROOT, 'npm', 'pup'); +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const THIRD_PARTY_NOTICES = 'LICENSE-3rdparty.csv'; + +function parseCli(argv) { + const {values} = parseArgs({ + args: argv, + options: { + version: {type: 'string'}, + assets: {type: 'string'}, + checksums: {type: 'string'}, + out: {type: 'string'}, + platform: {type: 'string', multiple: true}, + }, + }); + if (!values.version || !VERSION_PATTERN.test(values.version)) { + throw new Error('--version must be a semver version such as 1.23.7'); + } + + if (!values.assets || !values.checksums || !values.out) { + throw new Error('--assets, --checksums and --out are required'); + } + + const platforms = values.platform ?? Object.keys(PLATFORMS); + for (const key of platforms) { + if (!PLATFORMS[key]) { + throw new Error(`Unknown platform ${key}. Known: ${Object.keys(PLATFORMS).join(', ')}`); + } + } + + return { + version: values.version, + assets: values.assets, + checksums: readChecksums(values.checksums), + out: values.out, + platforms, + }; +} + +function readChecksums(file) { + const checksums = new Map(); + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const match = /^([0-9a-f]{64}) [ *](.+)$/.exec(line.trim()); + if (match) { + checksums.set(match[2], match[1]); + } + } + + return checksums; +} + +function verifyArchive(archive, checksums) { + const name = path.basename(archive); + const expected = checksums.get(name); + if (!expected) { + throw new Error(`${name} is not listed in the release checksums`); + } + + const actual = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); + if (actual !== expected) { + throw new Error(`${name} does not match its release checksum`); + } +} + +function extractFiles(archive, destinations) { + const files = Object.keys(destinations); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pup-npm-')); + try { + if (archive.endsWith('.zip')) { + execFileSync('unzip', ['-q', '-o', archive, ...files, '-d', workDir]); + } else { + execFileSync('tar', ['-xzf', archive, '-C', workDir, ...files]); + } + + for (const [file, destination] of Object.entries(destinations)) { + fs.mkdirSync(path.dirname(destination), {recursive: true}); + fs.copyFileSync(path.join(workDir, file), destination); + } + } finally { + fs.rmSync(workDir, {recursive: true, force: true}); + } +} + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function buildPlatformPackage({key, version, assets, checksums, out}) { + const entry = PLATFORMS[key]; + const archive = path.join(assets, `pup_${version}_${entry.asset}`); + if (!fs.existsSync(archive)) { + throw new Error(`Missing release archive ${archive}`); + } + + verifyArchive(archive, checksums); + const packageDir = path.join(out, `pup-${key}`); + const binary = path.join(packageDir, 'bin', entry.binary); + extractFiles(archive, { + [entry.binary]: binary, + [THIRD_PARTY_NOTICES]: path.join(packageDir, THIRD_PARTY_NOTICES), + }); + fs.chmodSync(binary, 0o755); + fs.copyFileSync(path.join(REPO_ROOT, 'LICENSE'), path.join(packageDir, 'LICENSE')); + writeJson(path.join(packageDir, 'package.json'), { + name: platformPackageName(key), + version, + description: `The ${key} binary for @datadog/pup`, + license: 'Apache-2.0', + repository: {type: 'git', url: 'git+https://github.com/DataDog/pup.git'}, + os: [entry.os], + cpu: [entry.cpu], + files: ['bin', THIRD_PARTY_NOTICES], + preferUnplugged: true, + }); + return packageDir; +} + +function buildRootPackage({version, out}) { + const packageDir = path.join(out, 'pup'); + fs.mkdirSync(packageDir, {recursive: true}); + for (const entry of ['bin', 'lib', 'README.md']) { + fs.cpSync(path.join(ROOT_PACKAGE_DIR, entry), path.join(packageDir, entry), {recursive: true}); + } + + fs.copyFileSync(path.join(REPO_ROOT, 'LICENSE'), path.join(packageDir, 'LICENSE')); + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT_PACKAGE_DIR, 'package.json'), 'utf8')); + manifest.version = version; + manifest.optionalDependencies = Object.fromEntries( + Object.keys(PLATFORMS).map(key => [platformPackageName(key), version]), + ); + writeJson(path.join(packageDir, 'package.json'), manifest); + return packageDir; +} + +function main() { + const options = parseCli(process.argv.slice(2)); + fs.mkdirSync(options.out, {recursive: true}); + for (const key of options.platforms) { + console.log(buildPlatformPackage({...options, key})); + } + + console.log(buildRootPackage(options)); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} + +module.exports = {parseCli, buildPlatformPackage, buildRootPackage}; diff --git a/npm/scripts/build-packages.test.js b/npm/scripts/build-packages.test.js new file mode 100644 index 00000000..96e4b361 --- /dev/null +++ b/npm/scripts/build-packages.test.js @@ -0,0 +1,132 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {execFileSync} = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {parseCli, buildPlatformPackage, buildRootPackage} = require('./build-packages.js'); +const {PLATFORMS, platformPackageName} = require('../pup/lib/platforms.js'); + +function tempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pup-npm-test-')); +} + +function fakeReleaseArchive(assets, version, key) { + const staging = tempDir(); + fs.writeFileSync(path.join(staging, PLATFORMS[key].binary), '#!/bin/sh\necho fake\n'); + fs.writeFileSync(path.join(staging, 'LICENSE-3rdparty.csv'), 'Component,Origin,License\n'); + const name = `pup_${version}_${PLATFORMS[key].asset}`; + const archive = path.join(assets, name); + execFileSync('tar', ['-czf', archive, '-C', staging, PLATFORMS[key].binary, 'LICENSE-3rdparty.csv']); + fs.rmSync(staging, {recursive: true, force: true}); + const sha256 = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); + return {name, sha256}; +} + +test('builds a platform package from a release archive', () => { + const assets = tempDir(); + const out = tempDir(); + const {name, sha256} = fakeReleaseArchive(assets, '1.2.3', 'linux-x64'); + + const packageDir = buildPlatformPackage({ + key: 'linux-x64', + version: '1.2.3', + assets, + checksums: new Map([[name, sha256]]), + out, + }); + + const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); + assert.equal(manifest.name, '@datadog/pup-linux-x64'); + assert.equal(manifest.version, '1.2.3'); + assert.deepEqual(manifest.os, ['linux']); + assert.deepEqual(manifest.cpu, ['x64']); + assert.deepEqual(manifest.files, ['bin', 'LICENSE-3rdparty.csv']); + const binary = path.join(packageDir, 'bin', 'pup'); + assert.equal(fs.readFileSync(binary, 'utf8'), '#!/bin/sh\necho fake\n'); + assert.equal(fs.statSync(binary).mode & 0o111, 0o111); + assert.equal(fs.readFileSync(path.join(packageDir, 'LICENSE-3rdparty.csv'), 'utf8'), 'Component,Origin,License\n'); +}); + +test('refuses an archive missing from the release checksums', () => { + const assets = tempDir(); + fakeReleaseArchive(assets, '1.2.3', 'linux-x64'); + + assert.throws( + () => buildPlatformPackage({key: 'linux-x64', version: '1.2.3', assets, checksums: new Map(), out: tempDir()}), + /pup_1\.2\.3_Linux_x86_64\.tar\.gz is not listed in the release checksums/, + ); +}); + +test('refuses an archive that does not match its release checksum', () => { + const assets = tempDir(); + const {name} = fakeReleaseArchive(assets, '1.2.3', 'linux-x64'); + + assert.throws( + () => + buildPlatformPackage({ + key: 'linux-x64', + version: '1.2.3', + assets, + checksums: new Map([[name, '0'.repeat(64)]]), + out: tempDir(), + }), + /does not match its release checksum/, + ); +}); + +test('stamps the version into the root package and every optional dependency', () => { + const out = tempDir(); + + const packageDir = buildRootPackage({version: '1.2.3', out}); + + const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); + assert.equal(manifest.version, '1.2.3'); + assert.deepEqual( + manifest.optionalDependencies, + Object.fromEntries(Object.keys(PLATFORMS).map(key => [platformPackageName(key), '1.2.3'])), + ); + assert.ok(fs.existsSync(path.join(packageDir, 'bin', 'pup.js'))); + assert.ok(fs.existsSync(path.join(packageDir, 'lib', 'platforms.js'))); +}); + +test('committed root manifest lists exactly the supported platforms', () => { + const manifest = require('../pup/package.json'); + assert.deepEqual( + Object.keys(manifest.optionalDependencies).sort(), + Object.keys(PLATFORMS).map(platformPackageName).sort(), + ); +}); + +test('fails when the release archive is missing', () => { + assert.throws( + () => + buildPlatformPackage({ + key: 'darwin-arm64', + version: '1.2.3', + assets: tempDir(), + checksums: new Map(), + out: tempDir(), + }), + /Missing release archive .*pup_1\.2\.3_Darwin_arm64\.tar\.gz/, + ); +}); + +test('rejects invalid versions, unknown platforms, and missing directories', () => { + const checksums = path.join(tempDir(), 'checksums.txt'); + fs.writeFileSync(checksums, `${'a'.repeat(64)} pup_1.2.3_Linux_x86_64.tar.gz\n`); + const base = ['--assets', 'a', '--checksums', checksums, '--out', 'b']; + + assert.throws(() => parseCli(['--version', 'v1.2.3', ...base]), /semver version/); + assert.throws(() => parseCli(['--version', '1.2.3']), /--assets, --checksums and --out are required/); + assert.throws( + () => parseCli(['--version', '1.2.3', ...base, '--platform', 'linux-riscv64']), + /Unknown platform linux-riscv64/, + ); + const options = parseCli(['--version', '1.2.3', ...base]); + assert.deepEqual(options.platforms, Object.keys(PLATFORMS)); + assert.equal(options.checksums.get('pup_1.2.3_Linux_x86_64.tar.gz'), 'a'.repeat(64)); +});