Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions e2e-tests/community-plugins-build-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down
13 changes: 8 additions & 5 deletions e2e-tests/rhdh-plugins-build-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down
40 changes: 40 additions & 0 deletions e2e-tests/support/plugin-export-build.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string[]> {
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<string, string>,
): Promise<object[]> {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
122 changes: 112 additions & 10 deletions src/commands/package-dynamic-plugins/command.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand Down Expand Up @@ -146,7 +150,7 @@ export async function command(opts: OptionValues): Promise<void> {
const pluginRegistryMetadata = [];
const pluginConfigs: Record<string, string> = {};
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');
Expand All @@ -158,13 +162,14 @@ export async function command(opts: OptionValues): Promise<void> {
.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,
Expand Down Expand Up @@ -211,7 +216,7 @@ export async function command(opts: OptionValues): Promise<void> {
}
} 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}`,
);
}
}
Expand Down Expand Up @@ -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<void> {
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
Expand Down
Loading