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)); +});