diff --git a/CHANGELOG.md b/CHANGELOG.md index dcae17e..7750bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@red-hat-developer-hub/cli` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.10.7 - 2026-05-08 + +### Fixed + +- **`plugin package`:** each `dist-dynamic` plugin is staged with **`npm pack`** and **`tar`** (strip the `package/` root) instead of a recursive filesystem copy. This matches npm publish contents, omits `node_modules/.bin` entries that could point outside the image (see [RHDHBUGS-1968](https://redhat.atlassian.net/browse/RHDHBUGS-1968)), and avoids spurious “link outside of the archive” warnings when dynamic plugins are installed from OCI. **Requires `bash`, `npm` (7+ for `--pack-destination`), and `tar` on `PATH`** (for example Git Bash on Windows). + ## 1.10.6 - 2026-04-28 ### Fixed diff --git a/e2e-tests/community-plugins-build-package.test.ts b/e2e-tests/community-plugins-build-package.test.ts index 33dac10..9bd8227 100644 --- a/e2e-tests/community-plugins-build-package.test.ts +++ b/e2e-tests/community-plugins-build-package.test.ts @@ -10,6 +10,7 @@ import { logSection, parseDynamicPluginAnnotation, runCommand, + topLevelEntriesAfterNpmPackStaging, } from './support/plugin-export-build'; // you can use COMMUNITY_PLUGINS_REPO_ARCHIVE env variable to specify a path existing local archive of the community plugins repository @@ -169,11 +170,13 @@ describe('export and package backstage-community plugin', () => { `ls -lah ${path.join(getFullPluginPath(), 'dist-dynamic')}`, ); - const filesInImage = fs.readdirSync(path.join(imageContentDir, key)); - const filesInDerivedPackage = fs.readdirSync( - path.join(getFullPluginPath(), 'dist-dynamic'), - ); - expect(filesInImage.length).toEqual(filesInDerivedPackage.length); + const distDynamicPath = path.join(getFullPluginPath(), 'dist-dynamic'); + const expectedTopLevel = + await topLevelEntriesAfterNpmPackStaging(distDynamicPath); + const filesInImage = fs + .readdirSync(path.join(imageContentDir, key)) + .sort(); + expect(filesInImage).toEqual(expectedTopLevel); const indexJson = JSON.parse( fs.readFileSync(path.join(imageContentDir, 'index.json'), 'utf-8'), diff --git a/e2e-tests/rhdh-plugins-build-package.test.ts b/e2e-tests/rhdh-plugins-build-package.test.ts index e28beb2..84ef47b 100644 --- a/e2e-tests/rhdh-plugins-build-package.test.ts +++ b/e2e-tests/rhdh-plugins-build-package.test.ts @@ -10,6 +10,7 @@ import { logSection, parseDynamicPluginAnnotation, runCommand, + topLevelEntriesAfterNpmPackStaging, } from './support/plugin-export-build'; // you can use RHDH_PLUGINS_REPO_ARCHIVE env variable to specify a path to an existing local archive of the rhdh-plugins repository @@ -169,11 +170,13 @@ describe('export and package rhdh-plugins scorecard workspace plugin', () => { `ls -lah ${path.join(getFullPluginPath(), 'dist-dynamic')}`, ); - const filesInImage = fs.readdirSync(path.join(imageContentDir, key)); - const filesInDerivedPackage = fs.readdirSync( - path.join(getFullPluginPath(), 'dist-dynamic'), - ); - expect(filesInImage.length).toEqual(filesInDerivedPackage.length); + const distDynamicPath = path.join(getFullPluginPath(), 'dist-dynamic'); + const expectedTopLevel = + await topLevelEntriesAfterNpmPackStaging(distDynamicPath); + const filesInImage = fs + .readdirSync(path.join(imageContentDir, key)) + .sort(); + expect(filesInImage).toEqual(expectedTopLevel); const indexJson = JSON.parse( fs.readFileSync(path.join(imageContentDir, 'index.json'), 'utf-8'), diff --git a/e2e-tests/support/plugin-export-build.ts b/e2e-tests/support/plugin-export-build.ts index 1903da3..e1de336 100644 --- a/e2e-tests/support/plugin-export-build.ts +++ b/e2e-tests/support/plugin-export-build.ts @@ -1,5 +1,6 @@ import fs from 'fs-extra'; import { exec as execCallback } from 'node:child_process'; +import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import type { ReadEntry } from 'tar'; @@ -79,6 +80,45 @@ export async function runCommand( } } +/** + * Top-level directory names using the same steps as `plugin package`: + * `npm pack --pack-destination …`, then `tar -xzf … --strip-components=1`. + * Names are sorted for stable comparison with staged OCI contents. + */ +export async function topLevelEntriesAfterNpmPackStaging( + distDynamicDir: string, +): Promise { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rhdh-e2e-npm-pack-')); + const packDest = fs.mkdtempSync( + path.join(os.tmpdir(), 'rhdh-e2e-npm-pack-out-'), + ); + try { + await runCommand( + `npm pack --pack-destination "${packDest}" --foreground-scripts=false`, + { cwd: distDynamicDir }, + ); + const tgzs = fs + .readdirSync(packDest) + .filter(f => f.endsWith('.tgz')) + .sort(); + if (tgzs.length !== 1) { + throw new Error( + `expected exactly one .tgz in ${packDest}, got: ${tgzs.join(', ')}`, + ); + } + const extractInto = path.join(work, 'extracted'); + fs.mkdirSync(extractInto, { recursive: true }); + await runCommand( + `tar -xzf "${path.join(packDest, tgzs[0])}" -C "${extractInto}" --strip-components=1`, + { cwd: distDynamicDir }, + ); + return fs.readdirSync(extractInto).sort(); + } finally { + await fs.remove(work).catch(() => undefined); + await fs.remove(packDest).catch(() => undefined); + } +} + export async function parseDynamicPluginAnnotation( imageAnnotations: Record, ): Promise { diff --git a/package.json b/package.json index 86d1ace..f078cef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@red-hat-developer-hub/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "1.10.6", + "version": "1.10.7", "publishConfig": { "access": "public" }, diff --git a/src/commands/package-dynamic-plugins/command.ts b/src/commands/package-dynamic-plugins/command.ts index 829d480..813acbe 100644 --- a/src/commands/package-dynamic-plugins/command.ts +++ b/src/commands/package-dynamic-plugins/command.ts @@ -1,15 +1,19 @@ import { PackageRoles } from '@backstage/cli-node'; +import { spawn } from 'node:child_process'; +import { closeSync, openSync } from 'node:fs'; + import chalk from 'chalk'; import { OptionValues } from 'commander'; import fs from 'fs-extra'; import { PackageJson } from 'type-fest'; import YAML from 'yaml'; -import os from 'os'; -import path from 'path'; +import os from 'node:os'; +import path from 'node:path'; import { paths } from '../../lib/paths'; +import { waitForExit } from '../../lib/run'; import { Task } from '../../lib/tasks'; export async function command(opts: OptionValues): Promise { @@ -146,7 +150,7 @@ export async function command(opts: OptionValues): Promise { const pluginRegistryMetadata = []; const pluginConfigs: Record = {}; try { - // copy the dist-dynamic output folder for each plugin to some temp directory and generate the metadata entry for each plugin + // Stage each dist-dynamic tree via npm pack + tar (see RHDHBUGS-1968) and metadata for the registry for (const pluginPkg of packages) { const { packageDirectory, packageFilePath } = pluginPkg; const distDynamicDirectory = path.join(packageDirectory, 'dist-dynamic'); @@ -158,13 +162,14 @@ export async function command(opts: OptionValues): Promise { .replace(/^@/, '') .replace(/\//, '-'); const targetDirectory = path.join(tmpDir, packageName); - Task.log(`Copying '${distDynamicDirectory}' to '${targetDirectory}`); + Task.log( + `Packing '${distDynamicDirectory}' into staging directory '${targetDirectory}' (npm pack + tar)`, + ); try { - // Copy the exported package to the staging area and ensure symlinks - // are copied as normal folders - fs.cpSync(distDynamicDirectory, targetDirectory, { - recursive: true, - dereference: true, + await stageDistDynamicViaNpmPack({ + distDynamicDirectory, + targetDirectory, + packScratchParent: tmpDir, }); const { name, @@ -211,7 +216,7 @@ export async function command(opts: OptionValues): Promise { } } catch (err) { Task.log( - `Encountered an error copying static assets for plugin ${packageFilePath}, the plugin will not be packaged. The error was ${err}`, + `Encountered an error staging plugin ${packageFilePath} via npm pack, the plugin will not be packaged. The error was ${err}`, ); } } @@ -308,6 +313,103 @@ COPY . . return; } +type StageDistDynamicViaNpmPackOptions = { + distDynamicDirectory: string; + targetDirectory: string; + /** Directory under which a unique `npm-pack-*` scratch dir is created. */ + packScratchParent: string; +}; + +/** + * Stages `dist-dynamic` like a published tarball: `npm pack` to a scratch + * directory, then `tar -xzf … --strip-components=1` into the target. Omits + * `node_modules/.bin` and other paths npm does not ship (RHDHBUGS-1968). + * + * Sends npm/tar stdout and stderr to a temp log file (path is printed only on + * failure), not to the terminal, by wiring those streams to fd(s) opened on + * that file in the `spawn` options. + * + * Uses `spawn` with `shell: false`; pack paths are inlined with `bashSingleQuoted`. + * Does not pass a custom `env` (child inherits PATH so user-managed toolchains + * resolve — see adjacent NOSONAR). `Task.forCommand` lacks custom env support; `run()` + * uses `shell: true` on `spawn` and can replay npm output to the CLI. + * + * Requires `bash`, `npm` 7+ for `--pack-destination`, and `tar` on `PATH` + * (Linux, macOS, Git Bash, or WSL on Windows). + */ +async function stageDistDynamicViaNpmPack( + options: StageDistDynamicViaNpmPackOptions, +): Promise { + const { distDynamicDirectory, targetDirectory, packScratchParent } = options; + const packdir = fs.mkdtempSync(path.join(packScratchParent, 'npm-pack-')); + const packLogPath = path.join( + packScratchParent, + `npm-pack-output-${process.pid}-${Date.now()}.log`, + ); + + try { + fs.rmSync(targetDirectory, { recursive: true, force: true }); + fs.mkdirSync(targetDirectory, { recursive: true }); + + const logFd = openSync(packLogPath, 'w'); + try { + // Controlled bash -lc script (paths from bashSingleQuoted); not user-defined. + // child inherits PATH so npm/bash/tar resolve (nvm, fnm, Homebrew). + const child = spawn( + 'bash', // NOSONAR typescript:S4036 + ['-lc', npmPackExtractScript(packdir, targetDirectory)], + { + cwd: distDynamicDirectory, + stdio: ['ignore', logFd, logFd], + shell: false, + }, + ); + await waitForExit(child, 'npm pack / tar'); + } finally { + closeSync(logFd); + } + + await fs.remove(packLogPath).catch(() => undefined); + } catch (err) { + if (await fs.pathExists(packLogPath)) { + const logContents = await fs + .readFile(packLogPath, 'utf8') + .catch(() => ''); + const logHeader = chalk.yellow(`npm pack / tar output (${packLogPath}):`); + process.stderr.write(`${logHeader}\n\n${logContents}\n`); + await fs.remove(packLogPath).catch(() => undefined); + } + throw err; + } finally { + fs.rmSync(packdir, { recursive: true, force: true }); + } +} + +/** + * Single-quote a string for safe embedding in `bash -lc` (POSIX single-quoted + * literal, with `'` escaped as `'\''`). + */ +function bashSingleQuoted(value: string): string { + /** Bash: end `'...'`, emit a literal `'`, resume quoted segment — `'\''`. */ + const escapedForBash = String.raw`'\''`; + const escapedValue = value.replaceAll("'", escapedForBash); + return `'${escapedValue}'`; +} + +/** + * `npm pack` into `packdir`, then extract into `extractToDir` with the same + * layout as today’s staging tree (`package/` stripped). Paths are bash-quoted in + * the script instead of passed via extra `env` entries. + */ +function npmPackExtractScript(packdir: string, extractToDir: string): string { + const qPack = bashSingleQuoted(packdir); + const qExtract = bashSingleQuoted(extractToDir); + return `set -euo pipefail +npm pack --pack-destination ${qPack} --foreground-scripts=false +tar -xzf ${qPack}/*.tgz -C ${qExtract} --strip-components=1 +`; +} + /** * Scan the monorepo "plugins" directory and find all plugins that are eligible * to be exported as dynamic plugins