diff --git a/workspaces/install-dynamic-plugins/.changeset/README.md b/workspaces/install-dynamic-plugins/.changeset/README.md
new file mode 100644
index 0000000000..e5b6d8d6a6
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/.changeset/README.md
@@ -0,0 +1,8 @@
+# Changesets
+
+Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
+with multi-package repos, or single-package repos to help you version and publish your code. You can
+find the full documentation for it [in our repository](https://github.com/changesets/changesets)
+
+We have a quick list of common questions to get you started engaging with this project in
+[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
diff --git a/workspaces/install-dynamic-plugins/.changeset/config.json b/workspaces/install-dynamic-plugins/.changeset/config.json
new file mode 100644
index 0000000000..4d034bb99f
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/.changeset/config.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
+ "changelog": "@changesets/cli/changelog",
+ "commit": false,
+ "fixed": [],
+ "linked": [],
+ "access": "public",
+ "baseBranch": "main",
+ "updateInternalDependencies": "patch"
+}
diff --git a/workspaces/install-dynamic-plugins/.changeset/initial-release.md b/workspaces/install-dynamic-plugins/.changeset/initial-release.md
new file mode 100644
index 0000000000..6d234e5540
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/.changeset/initial-release.md
@@ -0,0 +1,5 @@
+---
+'@red-hat-developer-hub/cli-module-install-dynamic-plugins': minor
+---
+
+Initial release. TypeScript/Node.js port of the RHDH init-container installer (originally Python; see [redhat-developer/rhdh#4574](https://github.com/redhat-developer/rhdh/pull/4574)), packaged as a Backstage CLI module. The `install` command is registered through `createCliModule` so the package is auto-discovered by `backstage-cli` when listed as a dependency. The package also ships a self-contained esbuild bundle as its `bin`, so direct `npx install-dynamic-plugins
` invocations (and RHDH's init-container `COPY` of the `.cjs`) stay fast and don't require `@backstage/cli-node` at runtime. Env vars, on-disk layout, `plugin-hash` format, and tar/OCI security guards are byte-compatible with the previous Python implementation.
diff --git a/workspaces/install-dynamic-plugins/.eslintrc.js b/workspaces/install-dynamic-plugins/.eslintrc.js
new file mode 100644
index 0000000000..59b86f8412
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('../../.eslintrc.cjs');
diff --git a/workspaces/install-dynamic-plugins/.gitignore b/workspaces/install-dynamic-plugins/.gitignore
new file mode 100644
index 0000000000..77ad56d128
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/.gitignore
@@ -0,0 +1,54 @@
+# macOS
+.DS_Store
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Coverage directory generated when running tests with coverage
+coverage
+
+# Dependencies
+node_modules/
+
+# Yarn files
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+
+# Node version directives
+.nvmrc
+
+# dotenv environment variables file
+.env
+.env.test
+
+# Build output
+dist
+dist-types
+
+# Temporary change files created by Vim
+*.swp
+
+# MkDocs build output
+site
+
+# Local configuration files
+*.local.yaml
+
+# Sensitive credentials
+*-credentials.yaml
+
+# vscode database functionality support files
+*.session.sql
+
+# E2E test reports
+e2e-test-report/
diff --git a/workspaces/install-dynamic-plugins/.prettierignore b/workspaces/install-dynamic-plugins/.prettierignore
new file mode 100644
index 0000000000..7d4cd34ec7
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/.prettierignore
@@ -0,0 +1,6 @@
+dist
+dist-types
+coverage
+.vscode
+.eslintrc.js
+**/dist/install-dynamic-plugins.cjs
diff --git a/workspaces/install-dynamic-plugins/package.json b/workspaces/install-dynamic-plugins/package.json
new file mode 100644
index 0000000000..99aa9c483b
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@internal/install-dynamic-plugins",
+ "version": "0.0.0",
+ "private": true,
+ "engines": {
+ "node": "22 || 24"
+ },
+ "workspaces": {
+ "packages": [
+ "packages/*"
+ ]
+ },
+ "scripts": {
+ "tsc": "tsc",
+ "tsc:full": "tsc --skipLibCheck true --incremental false",
+ "build:all": "backstage-cli repo build --all",
+ "build:api-reports": "yarn build:api-reports:only --tsc",
+ "build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type,ae-undocumented --validate-release-tags",
+ "build:knip-reports": "backstage-repo-tools knip-reports",
+ "clean": "backstage-cli repo clean",
+ "test": "backstage-cli repo test",
+ "test:all": "backstage-cli repo test --coverage",
+ "fix": "backstage-cli repo fix",
+ "lint": "backstage-cli repo lint --since origin/main",
+ "lint:all": "backstage-cli repo lint",
+ "prettier:check": "prettier --check .",
+ "prettier:fix": "prettier --write .",
+ "new": "backstage-cli new --scope @red-hat-developer-hub",
+ "postinstall": "cd ../../ && yarn install"
+ },
+ "prettier": "@spotify/prettier-config",
+ "lint-staged": {
+ "*.{js,jsx,ts,tsx,mjs,cjs}": [
+ "eslint --fix",
+ "prettier --write"
+ ],
+ "*.{json,md}": [
+ "prettier --write"
+ ]
+ },
+ "dependencies": {
+ "@backstage/cli": "^0.36.0",
+ "@backstage/cli-defaults": "0.1.0",
+ "@backstage/repo-tools": "^0.17.0",
+ "@changesets/cli": "^2.27.1",
+ "@jest/environment-jsdom-abstract": "^30.3.0",
+ "@spotify/prettier-config": "^15.0.0",
+ "jest": "^30.3.0",
+ "jsdom": "^27.1.0",
+ "prettier": "^3.4.2",
+ "typescript": "~5.8.0"
+ },
+ "packageManager": "yarn@4.12.0"
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.eslintrc.js b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.gitignore b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.gitignore
new file mode 100644
index 0000000000..7f3e7f3b96
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+build/
+coverage/
+*.log
+.yarn/
+dist/
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.prettierignore b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.prettierignore
new file mode 100644
index 0000000000..233e7c547e
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/.prettierignore
@@ -0,0 +1,5 @@
+node_modules/
+coverage/
+build/
+dist/
+package-lock.json
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/README.md b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/README.md
new file mode 100644
index 0000000000..43abba36e0
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/README.md
@@ -0,0 +1,143 @@
+# cli-module-install-dynamic-plugins
+
+Backstage CLI module that downloads, extracts, and configures RHDH dynamic plugins listed in a `dynamic-plugins.yaml` file.
+
+This package replaces the previous Python implementation (`install-dynamic-plugins.py`) with a TypeScript/Node.js implementation. The runtime contract — input config, output `app-config.dynamic-plugins.yaml`, on-disk layout, hash-based change detection, lock file — is **unchanged**.
+
+The package has two faces:
+
+- A **self-contained esbuild bundle** (`dist/install-dynamic-plugins.cjs`) used by the package's `bin`. Direct `npx install-dynamic-plugins`, RHDH's init-container `COPY`, and any other standalone invocation hit this path — ~60 ms cold start, no `node_modules` required at runtime.
+- A **`createCliModule` entry** (`dist/index.cjs.js`) exposed through `main`. When a host project lists this package as a dependency, `backstage-cli` auto-discovers the `install` command — `backstage-cli install ` works out of the box.
+
+Both share the same `installer.ts` source, so there is one install pipeline regardless of how the command is invoked.
+
+## Usage
+
+### Direct (bundled bin)
+
+```sh
+npx @red-hat-developer-hub/cli-module-install-dynamic-plugins ./dynamic-plugins-root
+```
+
+Or install globally:
+
+```sh
+npm install -g @red-hat-developer-hub/cli-module-install-dynamic-plugins
+install-dynamic-plugins ./dynamic-plugins-root
+```
+
+### Via `backstage-cli` discovery
+
+When the package is a dependency of a project that uses `backstage-cli`, the `install` command is registered automatically:
+
+```sh
+backstage-cli install ./dynamic-plugins-root
+```
+
+Runtime requirements: Node.js 22 or 24, and `skopeo` on `PATH` for OCI plugin support. `npm` is also expected on `PATH` for NPM-sourced plugins.
+
+## How RHDH consumes it
+
+The container's init container invokes the wrapper:
+
+```sh
+./install-dynamic-plugins.sh /dynamic-plugins-root
+```
+
+The wrapper executes the bundled CommonJS entry point with Node.js:
+
+```sh
+exec node install-dynamic-plugins.cjs "$1"
+```
+
+Both files live at `/opt/app-root/src/` inside the runtime image. Node.js is already present (it runs the Backstage backend), and `skopeo` is installed for OCI inspection — no new system packages are required.
+
+## Architecture
+
+```
+src/
+├── index.ts # main() — argv + orchestration of the full install flow
+├── log.ts # uniform stdout logger
+├── errors.ts # InstallException
+├── types.ts # PluginSpec / Plugin / PluginMap / PullPolicy + constants
+├── util.ts # shared helpers (fileExists, isInside, isPlainObject, tar filters)
+├── run.ts # subprocess wrapper with structured errors
+├── concurrency.ts # Semaphore + mapConcurrent + getWorkers()
+├── which.ts # PATH lookup (no `which` dep)
+├── skopeo.ts # Skopeo wrapper with promise-based inspect cache
+├── image-resolver.ts # registry.access.redhat.com → quay.io fallback
+├── image-cache.ts # OciImageCache — share OCI tarballs across plugins
+├── tar-extract.ts # streaming OCI / NPM extraction with security checks
+├── npm-key.ts # NPM package-spec parsing
+├── oci-key.ts # OCI package-spec parsing + {{inherit}} + auto-path
+├── integrity.ts # streaming SRI integrity verification
+├── merger.ts # plugin merging + deep-merge with conflict detection
+├── plugin-hash.ts # hash for change-detection ("already installed?")
+├── installer-oci.ts # install one OCI plugin
+├── installer-npm.ts # install one NPM (or local) plugin
+├── catalog-index.ts # CATALOG_INDEX_IMAGE extraction
+└── lock-file.ts # exclusive lock + SIGTERM cleanup
+```
+
+### Concurrency strategy (resource-conscious)
+
+OCI plugin downloads are parallelized via `mapConcurrent`. NPM `npm pack` calls stay sequential because the upstream npm registry throttles parallel fetches.
+
+The default worker count comes from `getWorkers()`:
+
+```
+Math.max(1, Math.min(Math.floor(availableParallelism() / 2), 6))
+```
+
+`availableParallelism()` honours cgroup CPU limits, so init containers in OpenShift won't try to use 16 workers on a 0.5 CPU pod. Override with `DYNAMIC_PLUGINS_WORKERS=`.
+
+### Memory budget
+
+All tar extraction is streaming via `node-tar` — large layers never load into RAM. SHA verification streams chunks through `node:crypto`. A typical 10-plugin run sits around 20–80 MB peak RSS, comfortably below an init-container memory limit of 512 Mi.
+
+### Security checks (parity with the previous Python script)
+
+| Check | Source |
+| --------------------------------------------------------------------- | ------------------------------------ |
+| Path-traversal in plugin path (`..`, absolute paths) | `tar-extract.ts` |
+| Per-entry size cap (zip bomb) — `MAX_ENTRY_SIZE`, default 20 MB | `tar-extract.ts`, `catalog-index.ts` |
+| Symlink / hardlink target must stay inside destination | `tar-extract.ts` |
+| Reject device files / FIFOs / unknown entry types | `tar-extract.ts` |
+| `package/` prefix enforced for NPM tarballs | `tar-extract.ts` |
+| SRI integrity verification (`sha256` / `sha384` / `sha512`) | `integrity.ts` |
+| Registry fallback: `registry.access.redhat.com/rhdh` → `quay.io/rhdh` | `image-resolver.ts` |
+
+## Environment variables
+
+| Variable | Default | Purpose |
+| --------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
+| `MAX_ENTRY_SIZE` | `20000000` | Per-entry byte limit when extracting tarballs |
+| `SKIP_INTEGRITY_CHECK` | `false` | When `true`, skip the SRI integrity check for remote NPM packages |
+| `CATALOG_INDEX_IMAGE` | _(unset)_ | OCI image to extract `dynamic-plugins.default.yaml` and catalog entities from |
+| `CATALOG_ENTITIES_EXTRACT_DIR` | `$TMPDIR/extensions` | Where to extract `catalog-entities/` from the catalog-index image |
+| `DYNAMIC_PLUGINS_WORKERS` | `auto` | Worker count override for parallel OCI downloads (`auto` uses `availableParallelism()/2`, capped at 6) |
+| `DYNAMIC_PLUGINS_LOCK_TIMEOUT_MS` | `600000` (10 min) | Max time to wait for the lock file before aborting with an error |
+
+## Development
+
+```sh
+npm install
+npm run tsc # type-check
+npm test # Jest unit tests (105 tests)
+npm run build # produce dist/install-dynamic-plugins.cjs
+```
+
+`dist/install-dynamic-plugins.cjs` **is** committed to the repo (consumed directly by the Containerfile, similar to `.yarn/releases/yarn-*.cjs`). The PR check verifies the bundle is up to date relative to the source.
+
+## Testing in CI
+
+The CI workflow (`.github/workflows/pr.yaml`) runs:
+
+1. `npm install && npm run tsc && npm test` — type check + Jest unit tests
+2. `npm run build` and a `git diff` check on the committed `dist/install-dynamic-plugins.cjs`
+
+## Compatibility notes
+
+- The **input contract** matches the previous Python script exactly: same `dynamic-plugins.yaml` schema (`includes`, `plugins`, `package`, `pluginConfig`, `disabled`, `pullPolicy`, `forceDownload`, `integrity`).
+- The **output contract** matches: same `app-config.dynamic-plugins.yaml`, same plugin directory layout, same `dynamic-plugin-config.hash` / `dynamic-plugin-image.hash` files.
+- `{{inherit}}` semantics, OCI path auto-detection, registry fallback, integrity algorithms, lock-file behaviour are preserved.
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/bin/install-dynamic-plugins b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/bin/install-dynamic-plugins
new file mode 100755
index 0000000000..61c0270c43
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/bin/install-dynamic-plugins
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const path = require('node:path');
+const fs = require('node:fs');
+
+// Detect whether we're running inside the monorepo (src/ checked in) or as an
+// installed npm package (only dist/ + bin/ + README in the tarball). In the
+// monorepo we run TypeScript directly via Backstage's node transform so that
+// tooling (api-reports, repo lint, etc.) can require this bin file before the
+// esbuild bundle has been built.
+/* eslint-disable-next-line no-restricted-syntax */
+const isLocal = fs.existsSync(path.resolve(__dirname, '../src'));
+
+if (isLocal) {
+ require('@backstage/cli/config/nodeTransform.cjs');
+ require('../src/cli');
+} else {
+ // Direct fast path: the package's `main` field is the createCliModule
+ // export (loaded by backstage-cli discovery). For direct/`npx`/init-container
+ // invocations we skip cli-module dispatch and run the self-contained esbuild
+ // bundle instead — keeps the cold start at ~60 ms instead of ~180 ms.
+ require('../dist/install-dynamic-plugins.cjs');
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/cli-report.md b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/cli-report.md
new file mode 100644
index 0000000000..1962ea29a0
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/cli-report.md
@@ -0,0 +1,12 @@
+## CLI Report file for "@red-hat-developer-hub/cli-module-install-dynamic-plugins"
+
+> Do not edit this file. It is a report generated by `yarn build:api-reports`
+
+### `install-dynamic-plugins`
+
+```
+Usage: install-dynamic-plugins [flags...]
+
+Options:
+ -h, --help
+```
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/esbuild.config.mjs b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/esbuild.config.mjs
new file mode 100644
index 0000000000..2d559eedf4
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/esbuild.config.mjs
@@ -0,0 +1,32 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { build } from 'esbuild';
+
+await build({
+ entryPoints: ['src/cli.ts'],
+ bundle: true,
+ platform: 'node',
+ target: 'node22',
+ format: 'cjs',
+ outfile: 'dist/install-dynamic-plugins.cjs',
+ // Minify the production bundle to reduce cold-start parse cost in the
+ // init container. The external sourcemap is what `node --enable-source-maps`
+ // consumes if a stack trace needs to be unminified during debugging.
+ minify: true,
+ sourcemap: 'external',
+ legalComments: 'external',
+ logLevel: 'info',
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/install-dynamic-plugins.sh b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/install-dynamic-plugins.sh
new file mode 100755
index 0000000000..8a80859a00
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/install-dynamic-plugins.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+#
+# Copyright Red Hat, Inc.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+exec node install-dynamic-plugins.cjs "$1"
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/package.json b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/package.json
new file mode 100644
index 0000000000..7262bfba6e
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/package.json
@@ -0,0 +1,61 @@
+{
+ "name": "@red-hat-developer-hub/cli-module-install-dynamic-plugins",
+ "version": "0.1.0",
+ "description": "Backstage CLI module that installs RHDH dynamic plugins from a dynamic-plugins.yaml config (OCI, NPM, local).",
+ "license": "Apache-2.0",
+ "backstage": {
+ "role": "cli-module"
+ },
+ "homepage": "https://github.com/redhat-developer/rhdh-plugins/tree/main/workspaces/install-dynamic-plugins#readme",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/redhat-developer/rhdh-plugins",
+ "directory": "workspaces/install-dynamic-plugins/packages/install-dynamic-plugins"
+ },
+ "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues",
+ "keywords": [
+ "rhdh",
+ "backstage",
+ "dynamic-plugins",
+ "cli",
+ "cli-module",
+ "init-container"
+ ],
+ "engines": {
+ "node": "22 || 24"
+ },
+ "main": "dist/index.cjs.js",
+ "bin": "bin/install-dynamic-plugins",
+ "files": [
+ "bin",
+ "dist/**/*.js",
+ "dist/install-dynamic-plugins.cjs",
+ "README.md"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "scripts": {
+ "build": "backstage-cli package build && node esbuild.config.mjs",
+ "prepack": "backstage-cli package build && node esbuild.config.mjs",
+ "tsc": "tsc",
+ "test": "backstage-cli package test",
+ "lint": "backstage-cli package lint",
+ "clean": "backstage-cli package clean"
+ },
+ "dependencies": {
+ "@backstage/cli-node": "^0.3.2",
+ "cleye": "^2.6.0",
+ "tar": "^7.5.13",
+ "yaml": "^2.8.2"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.36.0",
+ "@types/jest": "^30.0.0",
+ "@types/node": "^22.10.0",
+ "@types/tar": "^6.1.13",
+ "esbuild": "^0.25.0",
+ "typescript": "~5.8.0"
+ }
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/report.api.md b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/report.api.md
new file mode 100644
index 0000000000..fbe85d3e4e
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/report.api.md
@@ -0,0 +1,13 @@
+## API Report File for "@red-hat-developer-hub/cli-module-install-dynamic-plugins"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { CliModule } from '@backstage/cli-node';
+
+// @public
+const _default: CliModule;
+export default _default;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/catalog-index.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/catalog-index.ts
new file mode 100644
index 0000000000..33a7964b86
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/catalog-index.ts
@@ -0,0 +1,328 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as fs from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import * as tar from 'tar';
+import { InstallException } from './errors';
+import { log } from './log';
+import { resolveImage } from './image-resolver';
+import { type Skopeo } from './skopeo';
+import {
+ DOCKER_PROTO,
+ DPDY_FILENAME,
+ MAX_ENTRY_SIZE,
+ OCI_PROTO,
+} from './types';
+import { fileExists, isAllowedEntryType, isInside } from './util';
+
+type OciManifest = {
+ layers?: Array<{ digest: string }>;
+};
+
+/**
+ * Extract the plugin catalog index OCI image (when `CATALOG_INDEX_IMAGE` is
+ * set). Produces:
+ * - `/.catalog-index-temp/dynamic-plugins.default.yaml`
+ * - `/catalog-entities/` (if present in the image)
+ *
+ * Returns the absolute path to the extracted `dynamic-plugins.default.yaml`,
+ * which the caller will substitute into `includes[]`.
+ */
+export async function extractCatalogIndex(
+ skopeo: Skopeo,
+ image: string,
+ mountDir: string,
+ entitiesDir: string,
+): Promise {
+ log(`\n======= Extracting catalog index from ${image}`);
+ const tempDir = path.join(mountDir, '.catalog-index-temp');
+ await fs.mkdir(tempDir, { recursive: true });
+ const tempDirAbs = path.resolve(tempDir);
+
+ await extractCatalogIndexLayers(skopeo, image, tempDirAbs);
+
+ const dpdy = path.join(tempDir, DPDY_FILENAME);
+ if (!(await fileExists(dpdy))) {
+ throw new InstallException(
+ `dynamic-plugins.default.yaml not found in ${image}`,
+ );
+ }
+ log('\t==> Extracted dynamic-plugins.default.yaml');
+
+ // Also surface catalog entities if present.
+ for (const sub of [
+ 'catalog-entities/extensions',
+ 'catalog-entities/marketplace',
+ ]) {
+ const src = path.join(tempDir, sub);
+ if (await fileExists(src)) {
+ await fs.mkdir(entitiesDir, { recursive: true });
+ const dst = path.join(entitiesDir, 'catalog-entities');
+ await fs.rm(dst, { recursive: true, force: true });
+ await copyDir(src, dst);
+ log(`\t==> Extracted catalog entities from ${sub}`);
+ break;
+ }
+ }
+ return dpdy;
+}
+
+/**
+ * Pull an OCI image with `skopeo copy` and untar every layer into `destDirAbs`.
+ * Shared by the primary `extractCatalogIndex` and the per-image
+ * `extractExtraCatalogIndex` flows. Applies the same security filter as
+ * `extractCatalogIndex` (per-entry size cap, path-traversal rejection,
+ * link-target containment, allowed-type whitelist).
+ */
+export async function extractCatalogIndexLayers(
+ skopeo: Skopeo,
+ image: string,
+ destDirAbs: string,
+): Promise {
+ const resolved = await resolveImage(skopeo, image);
+ const workDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'rhdh-catalog-index-'),
+ );
+ try {
+ const url = resolved.startsWith(DOCKER_PROTO)
+ ? resolved
+ : `${DOCKER_PROTO}${resolved.replace(OCI_PROTO, '')}`;
+ const localDir = path.join(workDir, 'idx');
+ log('\t==> Downloading catalog index image');
+ await skopeo.copy(url, `dir:${localDir}`);
+
+ const manifestPath = path.join(localDir, 'manifest.json');
+ if (!(await fileExists(manifestPath))) {
+ throw new InstallException(
+ `manifest.json not found in catalog index image ${image}`,
+ );
+ }
+
+ const manifest = JSON.parse(
+ await fs.readFile(manifestPath, 'utf8'),
+ ) as OciManifest;
+ const layers = manifest.layers ?? [];
+
+ let pending: InstallException | null = null;
+ for (const layer of layers) {
+ if (pending) break;
+ const digest = layer.digest;
+ if (!digest) continue;
+ const [, fname] = digest.split(':');
+ if (!fname) continue;
+ const layerPath = path.join(localDir, fname);
+ if (!(await fileExists(layerPath))) continue;
+
+ await tar.x({
+ file: layerPath,
+ cwd: destDirAbs,
+ preservePaths: false,
+ // The filter captures `pending` (a single-write latch) and runs
+ // synchronously inside the awaited tar.x call — iterations are
+ // serialised, so the closure-in-loop hazard the rule guards against
+ // does not apply.
+ // eslint-disable-next-line no-loop-func
+ filter: (filePath, entry) => {
+ if (pending) return false;
+ const stat = entry as tar.ReadEntry;
+
+ if (stat.size > MAX_ENTRY_SIZE) {
+ pending = new InstallException(`Zip bomb detected in ${filePath}`);
+ return false;
+ }
+
+ if (stat.type === 'SymbolicLink' || stat.type === 'Link') {
+ const linkTarget = path.resolve(destDirAbs, stat.linkpath ?? '');
+ if (!isInside(linkTarget, destDirAbs)) return false;
+ }
+
+ // Reject any entry that would resolve outside destDirAbs.
+ const memberPath = path.resolve(destDirAbs, filePath);
+ if (!isInside(memberPath, destDirAbs)) return false;
+
+ return isAllowedEntryType(stat.type);
+ },
+ });
+ }
+ if (pending) throw pending;
+ } finally {
+ await fs.rm(workDir, { recursive: true, force: true });
+ }
+}
+
+/**
+ * Extract an extra catalog index image (driven by `EXTRA_CATALOG_INDEX_IMAGES`).
+ * Unlike `extractCatalogIndex`, this does NOT require a
+ * `dynamic-plugins.default.yaml` — extra images contribute catalog entities
+ * for the Extensions UI only.
+ *
+ * Writes catalog entities to `//catalog-entities`,
+ * overwriting any prior content at that path. When the source image carries
+ * neither `catalog-entities/extensions/` nor `catalog-entities/marketplace/`,
+ * a warning is logged and the function returns without throwing.
+ *
+ * `previouslyUsedBy` should be the image ref that previously mapped to this
+ * subdirectory name in the same `EXTRA_CATALOG_INDEX_IMAGES` invocation;
+ * pass `null` on first use. When non-null, an overwrite warning is logged
+ * AFTER the extraction header (matches the Python fix-up commit ordering).
+ */
+export async function extractExtraCatalogIndex(
+ skopeo: Skopeo,
+ image: string,
+ subdirectory: string,
+ parentDir: string,
+ previouslyUsedBy: string | null,
+): Promise {
+ if (!isSafeSubdirectoryName(subdirectory)) {
+ throw new InstallException(
+ `Refusing to extract extra catalog index into unsafe subdirectory '${subdirectory}'`,
+ );
+ }
+ log(
+ `\n======= Extracting extra catalog index '${subdirectory}' from ${image}`,
+ );
+ if (previouslyUsedBy) {
+ log(
+ `\t==> WARNING: Subdirectory '${subdirectory}' was already used by '${previouslyUsedBy}'. ` +
+ `The previous extraction will be overwritten.`,
+ );
+ }
+
+ const workDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'rhdh-extra-catalog-index-'),
+ );
+ try {
+ const extractedDir = path.join(workDir, 'extracted');
+ await fs.mkdir(extractedDir, { recursive: true });
+ await extractCatalogIndexLayers(skopeo, image, extractedDir);
+
+ const subdirParent = path.join(parentDir, subdirectory);
+ log(`\t==> Extracting extensions catalog entities to ${subdirParent}`);
+
+ let sourceDir: string | null = null;
+ for (const sub of [
+ 'catalog-entities/extensions',
+ 'catalog-entities/marketplace',
+ ]) {
+ const candidate = path.join(extractedDir, sub);
+ if (await fileExists(candidate)) {
+ sourceDir = candidate;
+ break;
+ }
+ }
+
+ if (!sourceDir) {
+ log(
+ `\t==> WARNING: Extra catalog index image ${image} does not have neither ` +
+ `'catalog-entities/extensions/' nor 'catalog-entities/marketplace/' directory`,
+ );
+ return;
+ }
+
+ await fs.mkdir(subdirParent, { recursive: true });
+ const dst = path.join(subdirParent, 'catalog-entities');
+ await fs.rm(dst, { recursive: true, force: true });
+ await copyDir(sourceDir, dst);
+ log(
+ `\t==> Successfully extracted extensions catalog entities from extra index image to ${subdirParent}`,
+ );
+ } finally {
+ await fs.rm(workDir, { recursive: true, force: true });
+ }
+}
+
+/**
+ * Convert an OCI image reference to a filesystem-safe subdirectory name by
+ * replacing `/`, `:`, and `@` with `_`. Matches the Python
+ * `image_ref_to_subdirectory` helper so the on-disk layout is identical
+ * between the two implementations.
+ */
+export function imageRefToSubdirectory(imageRef: string): string {
+ return imageRef.replaceAll(/[/:@]/g, '_');
+}
+
+/**
+ * Parse the `EXTRA_CATALOG_INDEX_IMAGES` env var. Each comma-separated entry
+ * is either a plain image reference (subdirectory auto-derived via
+ * `imageRefToSubdirectory`) or `=` (explicit subdirectory
+ * name). Empty entries and empty image_refs are skipped with a warning —
+ * the caller still consumes the rest of the list.
+ */
+export function parseExtraCatalogIndexImages(
+ raw: string,
+): Array<[name: string, imageRef: string]> {
+ const out: Array<[string, string]> = [];
+ for (const rawEntry of raw.split(',')) {
+ const entry = rawEntry.trim();
+ if (!entry) continue;
+ let name: string;
+ let imageRef: string;
+ const eq = entry.indexOf('=');
+ if (eq === -1) {
+ imageRef = entry;
+ name = imageRefToSubdirectory(imageRef);
+ } else {
+ name = entry.slice(0, eq).trim();
+ imageRef = entry.slice(eq + 1).trim();
+ }
+ if (!imageRef) {
+ log(
+ `WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with empty image reference: '${entry}'`,
+ );
+ continue;
+ }
+ if (!isSafeSubdirectoryName(name)) {
+ log(
+ String.raw`WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with unsafe subdirectory name '${name}' in '${entry}'. Names must be non-empty and must not contain '/', '\\', or '..'.`,
+ );
+ continue;
+ }
+ out.push([name, imageRef]);
+ }
+ return out;
+}
+
+/**
+ * Reject subdirectory names that are empty or could escape `` once
+ * passed to `path.join` (path separators or `..` segments). Mirrors the
+ * defensive check applied to plugin paths during tar extraction.
+ */
+function isSafeSubdirectoryName(name: string): boolean {
+ if (!name || name === '.' || name === '..') return false;
+ return !/[/\\]/.test(name);
+}
+
+export async function cleanupCatalogIndexTemp(mountDir: string): Promise {
+ await fs.rm(path.join(mountDir, '.catalog-index-temp'), {
+ recursive: true,
+ force: true,
+ });
+}
+
+async function copyDir(src: string, dst: string): Promise {
+ await fs.mkdir(dst, { recursive: true });
+ const entries = await fs.readdir(src, { withFileTypes: true });
+ for (const entry of entries) {
+ const s = path.join(src, entry.name);
+ const d = path.join(dst, entry.name);
+ if (entry.isDirectory()) {
+ await copyDir(s, d);
+ } else if (entry.isFile()) {
+ await fs.copyFile(s, d);
+ }
+ }
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/cli.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/cli.ts
new file mode 100644
index 0000000000..0d0d34f0e0
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/cli.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { InstallException } from './errors';
+import { main } from './installer';
+
+main().catch((err: unknown) => {
+ const msg = err instanceof InstallException ? err.message : String(err);
+ process.stderr.write(`\ninstall-dynamic-plugins failed: ${msg}\n`);
+ process.exit(1);
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/command.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/command.ts
new file mode 100644
index 0000000000..dc63014706
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/command.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import type { CliCommandContext } from '@backstage/cli-node';
+import { main } from './installer';
+
+export default async ({ args, info }: CliCommandContext): Promise => {
+ await main(args, info.usage);
+};
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/concurrency.test.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/concurrency.test.ts
new file mode 100644
index 0000000000..199d4826d6
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/concurrency.test.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ getNpmWorkers,
+ getWorkers,
+ mapConcurrent,
+ Semaphore,
+} from './concurrency';
+
+describe('Semaphore', () => {
+ it('bounds the number of concurrent holders', async () => {
+ const sem = new Semaphore(2);
+ let inFlight = 0;
+ let peak = 0;
+ const work = async () => {
+ await sem.acquire();
+ inFlight++;
+ peak = Math.max(peak, inFlight);
+ await new Promise(r => setTimeout(r, 5));
+ inFlight--;
+ sem.release();
+ };
+ await Promise.all(Array.from({ length: 8 }, work));
+ expect(peak).toBeLessThanOrEqual(2);
+ });
+
+ it('rejects a max < 1', () => {
+ expect(() => new Semaphore(0)).toThrow(RangeError);
+ });
+});
+
+describe('mapConcurrent', () => {
+ it('captures both successes and failures without cancelling peers', async () => {
+ const results = await mapConcurrent([1, 2, 3, 4], 2, async n => {
+ if (n === 2) throw new Error('boom');
+ return n * 2;
+ });
+ expect(results).toHaveLength(4);
+ expect(results.filter(r => r.ok).map(r => r.ok && r.value)).toEqual([
+ 2, 6, 8,
+ ]);
+ expect(
+ results.filter(r => !r.ok).map(r => !r.ok && r.error.message),
+ ).toEqual(['boom']);
+ });
+
+ it('respects the concurrency limit', async () => {
+ let inFlight = 0;
+ let peak = 0;
+ await mapConcurrent(
+ Array.from({ length: 20 }, (_, i) => i),
+ 4,
+ async () => {
+ inFlight++;
+ peak = Math.max(peak, inFlight);
+ await new Promise(r => setTimeout(r, 2));
+ inFlight--;
+ },
+ );
+ expect(peak).toBeLessThanOrEqual(4);
+ });
+});
+
+describe('getWorkers', () => {
+ const originalEnv = process.env.DYNAMIC_PLUGINS_WORKERS;
+ afterEach(() => {
+ if (originalEnv === undefined) delete process.env.DYNAMIC_PLUGINS_WORKERS;
+ else process.env.DYNAMIC_PLUGINS_WORKERS = originalEnv;
+ });
+
+ it('honours an explicit worker count', () => {
+ process.env.DYNAMIC_PLUGINS_WORKERS = '3';
+ expect(getWorkers()).toBe(3);
+ });
+
+ it('clamps non-numeric values to 1', () => {
+ process.env.DYNAMIC_PLUGINS_WORKERS = 'banana';
+ expect(getWorkers()).toBe(1);
+ });
+
+ it('auto-picks a value between 1 and 6', () => {
+ process.env.DYNAMIC_PLUGINS_WORKERS = 'auto';
+ const w = getWorkers();
+ expect(w).toBeGreaterThanOrEqual(1);
+ expect(w).toBeLessThanOrEqual(6);
+ });
+});
+
+describe('getNpmWorkers', () => {
+ const originalEnv = process.env.DYNAMIC_PLUGINS_NPM_WORKERS;
+ afterEach(() => {
+ if (originalEnv === undefined)
+ delete process.env.DYNAMIC_PLUGINS_NPM_WORKERS;
+ else process.env.DYNAMIC_PLUGINS_NPM_WORKERS = originalEnv;
+ });
+
+ it('honours an explicit NPM worker count', () => {
+ process.env.DYNAMIC_PLUGINS_NPM_WORKERS = '2';
+ expect(getNpmWorkers()).toBe(2);
+ });
+
+ it('falls back to 1 for non-numeric values', () => {
+ process.env.DYNAMIC_PLUGINS_NPM_WORKERS = 'banana';
+ expect(getNpmWorkers()).toBe(1);
+ });
+
+ it('auto-picks a value between 1 and 3 (lower cap than OCI)', () => {
+ process.env.DYNAMIC_PLUGINS_NPM_WORKERS = 'auto';
+ const w = getNpmWorkers();
+ expect(w).toBeGreaterThanOrEqual(1);
+ expect(w).toBeLessThanOrEqual(3);
+ });
+
+ it('explicit NPM worker count is independent of the OCI cap', () => {
+ process.env.DYNAMIC_PLUGINS_NPM_WORKERS = '8';
+ expect(getNpmWorkers()).toBe(8);
+ });
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/concurrency.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/concurrency.ts
new file mode 100644
index 0000000000..5524fb6972
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/concurrency.ts
@@ -0,0 +1,120 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as os from 'node:os';
+
+/**
+ * Minimal semaphore for bounding concurrent async work.
+ * Matches the Python `ThreadPoolExecutor(max_workers=N)` worker model from
+ * install-dynamic-plugins-fast.py — single-threaded JS means no lock needed
+ * on the counter itself.
+ */
+export class Semaphore {
+ private available: number;
+ private readonly queue: Array<() => void> = [];
+
+ constructor(max: number) {
+ if (max < 1) throw new RangeError(`Semaphore max must be >= 1, got ${max}`);
+ this.available = max;
+ }
+
+ async acquire(): Promise {
+ if (this.available > 0) {
+ this.available--;
+ return undefined;
+ }
+ return new Promise(resolve => this.queue.push(resolve));
+ }
+
+ release(): void {
+ const next = this.queue.shift();
+ if (next) next();
+ else this.available++;
+ }
+}
+
+export type Outcome =
+ | { ok: true; value: T; item: Item }
+ | { ok: false; error: Error; item: Item };
+
+/**
+ * Run `fn` over `items` with at most `limit` concurrent executions.
+ * Returns every outcome — errors are captured, not thrown, so one failure
+ * does not cancel the others. Mirrors the behaviour of fast.py's parallel install loop.
+ */
+export async function mapConcurrent- (
+ items: readonly Item[],
+ limit: number,
+ fn: (item: Item) => Promise,
+): Promise>> {
+ const sem = new Semaphore(Math.max(1, limit));
+ return Promise.all(
+ items.map(async item => {
+ await sem.acquire();
+ try {
+ return { ok: true as const, value: await fn(item), item };
+ } catch (err) {
+ return { ok: false as const, error: err as Error, item };
+ } finally {
+ sem.release();
+ }
+ }),
+ );
+}
+
+/** Conservative upper bound for parallel `skopeo` calls. */
+const MAX_OCI_WORKERS = 6;
+
+/**
+ * Lower cap for NPM installs because `npm pack` hits the public registry
+ * and shares a single CLI cache (`~/.npm/_cacache`) — excessive concurrency
+ * triggers throttling and cache contention without a wall-clock benefit.
+ */
+const MAX_NPM_WORKERS = 3;
+
+/**
+ * Worker count selection, honouring `DYNAMIC_PLUGINS_WORKERS` env and cgroup
+ * CPU limits (via `availableParallelism`). Conservative default for OpenShift
+ * init containers: half of available CPUs, capped at `MAX_OCI_WORKERS`.
+ */
+export function getWorkers(): number {
+ return resolveWorkers(process.env.DYNAMIC_PLUGINS_WORKERS, MAX_OCI_WORKERS);
+}
+
+/**
+ * Worker count for concurrent NPM installs. Override via
+ * `DYNAMIC_PLUGINS_NPM_WORKERS` (set to `1` to restore the original
+ * sequential behaviour).
+ */
+export function getNpmWorkers(): number {
+ return resolveWorkers(
+ process.env.DYNAMIC_PLUGINS_NPM_WORKERS,
+ MAX_NPM_WORKERS,
+ );
+}
+
+function resolveWorkers(rawEnv: string | undefined, cap: number): number {
+ const env = rawEnv ?? 'auto';
+ if (env !== 'auto') {
+ const n = Number.parseInt(env, 10);
+ if (!Number.isFinite(n) || n < 1) return 1;
+ return n;
+ }
+ const cpus =
+ typeof os.availableParallelism === 'function'
+ ? os.availableParallelism()
+ : os.cpus().length;
+ return Math.max(1, Math.min(Math.floor(cpus / 2), cap));
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/errors.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/errors.ts
new file mode 100644
index 0000000000..849e87b93c
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/errors.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export class InstallException extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'InstallException';
+ }
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/extra-catalog-index.test.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/extra-catalog-index.test.ts
new file mode 100644
index 0000000000..4d7f45a039
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/extra-catalog-index.test.ts
@@ -0,0 +1,358 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ chmodSync,
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import * as fs from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import * as tar from 'tar';
+import {
+ extractExtraCatalogIndex,
+ imageRefToSubdirectory,
+ parseExtraCatalogIndexImages,
+} from './catalog-index';
+import { Skopeo } from './skopeo';
+
+describe('imageRefToSubdirectory', () => {
+ it('replaces /, :, and @ with _', () => {
+ expect(
+ imageRefToSubdirectory('quay.io/rhdh/plugin-catalog-index:1.10'),
+ ).toBe('quay.io_rhdh_plugin-catalog-index_1.10');
+ expect(imageRefToSubdirectory('quay.io/rhdh/index@sha256:abc123')).toBe(
+ 'quay.io_rhdh_index_sha256_abc123',
+ );
+ });
+
+ it('returns the input unchanged when no special characters are present', () => {
+ expect(imageRefToSubdirectory('plain-name')).toBe('plain-name');
+ });
+});
+
+describe('parseExtraCatalogIndexImages', () => {
+ it('parses a single plain image ref with auto-derived subdirectory', () => {
+ expect(parseExtraCatalogIndexImages('quay.io/rhdh/index:1.0')).toEqual([
+ ['quay.io_rhdh_index_1.0', 'quay.io/rhdh/index:1.0'],
+ ]);
+ });
+
+ it('parses explicit name=ref entries', () => {
+ expect(
+ parseExtraCatalogIndexImages(
+ 'community=quay.io/rhdh-community/index:1.10',
+ ),
+ ).toEqual([['community', 'quay.io/rhdh-community/index:1.10']]);
+ });
+
+ it('parses mixed explicit + auto-derived entries in order', () => {
+ expect(
+ parseExtraCatalogIndexImages(
+ 'community=quay.io/rhdh-community/index:1.10,quay.io/partner/index:latest',
+ ),
+ ).toEqual([
+ ['community', 'quay.io/rhdh-community/index:1.10'],
+ ['quay.io_partner_index_latest', 'quay.io/partner/index:latest'],
+ ]);
+ });
+
+ it('trims whitespace around each entry and around name=ref', () => {
+ expect(
+ parseExtraCatalogIndexImages(
+ ' community = quay.io/x:1.0 , quay.io/y:2.0 ',
+ ),
+ ).toEqual([
+ ['community', 'quay.io/x:1.0'],
+ ['quay.io_y_2.0', 'quay.io/y:2.0'],
+ ]);
+ });
+
+ it('skips empty entries silently', () => {
+ expect(parseExtraCatalogIndexImages(',,quay.io/x:1.0,,')).toEqual([
+ ['quay.io_x_1.0', 'quay.io/x:1.0'],
+ ]);
+ });
+
+ it('warns and skips entries with an empty image reference', () => {
+ const warn = jest
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ try {
+ expect(parseExtraCatalogIndexImages('community=,quay.io/x:1.0')).toEqual([
+ ['quay.io_x_1.0', 'quay.io/x:1.0'],
+ ]);
+ const out = warn.mock.calls.map(args => String(args[0])).join('\n');
+ expect(out).toMatch(
+ /WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with empty image reference/,
+ );
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it('warns and skips entries whose explicit name is empty after trimming', () => {
+ const warn = jest
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ try {
+ expect(parseExtraCatalogIndexImages('=quay.io/x:1,quay.io/y:2')).toEqual([
+ ['quay.io_y_2', 'quay.io/y:2'],
+ ]);
+ const out = warn.mock.calls.map(args => String(args[0])).join('\n');
+ expect(out).toMatch(/unsafe subdirectory name ''/);
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it.each([
+ ['..', '..=quay.io/x:1'],
+ ['.', '.=quay.io/x:1'],
+ ['foo/bar', 'foo/bar=quay.io/x:1'],
+ [String.raw`..\evil`, String.raw`..\evil=quay.io/x:1`],
+ ])(
+ 'rejects path-traversing or separator-bearing subdirectory name %p',
+ (_badName, entry) => {
+ const warn = jest
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ try {
+ const result = parseExtraCatalogIndexImages(`${entry},quay.io/safe:1`);
+ expect(result).toEqual([['quay.io_safe_1', 'quay.io/safe:1']]);
+ const out = warn.mock.calls.map(args => String(args[0])).join('\n');
+ expect(out).toMatch(/unsafe subdirectory name/);
+ } finally {
+ warn.mockRestore();
+ }
+ },
+ );
+
+ it('accepts URL-encoded separators without URL-decoding (character-based check)', () => {
+ // %2F is the URL encoding of '/'. We intentionally do NOT decode it, so
+ // a name like '..%2Fetc' is accepted as a literal directory name.
+ expect(parseExtraCatalogIndexImages('..%2Fetc=quay.io/x:1')).toEqual([
+ ['..%2Fetc', 'quay.io/x:1'],
+ ]);
+ });
+});
+
+/** Stage a `catalog-entities//` tree for the fake skopeo to pack. */
+async function stageLayer(
+ sub: 'extensions' | 'marketplace',
+ file: string,
+ body: string,
+): Promise {
+ const stage = mkdtempSync(join(tmpdir(), 'extra-layer-stage-'));
+ const dir = join(stage, 'catalog-entities', sub);
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, file), body);
+ return stage;
+}
+
+describe('extractExtraCatalogIndex', () => {
+ let workRoot: string;
+ let fakeSkopeoDir: string;
+
+ /**
+ * Build a fake `skopeo` binary that, on `copy dir:`, materialises
+ * a manifest.json + a single layer tarball at . The layer contents are
+ * packed at fixture-build time from `layerStageDir`.
+ */
+ async function makeFakeSkopeo(layerStageDir: string): Promise {
+ const layerTarPath = join(fakeSkopeoDir, 'layer.tar');
+ await tar.c({ gzip: false, file: layerTarPath, cwd: layerStageDir }, ['.']);
+ const binPath = join(fakeSkopeoDir, 'skopeo');
+ const digest = 'sha256:fakefakefakefakefakefakefakefake';
+ const digestFile = digest.split(':')[1];
+ writeFileSync(
+ binPath,
+ `#!/bin/sh
+DST=""
+for arg in "$@"; do
+ case "$arg" in
+ dir:*) DST="\${arg#dir:}" ;;
+ esac
+done
+mkdir -p "$DST"
+cp "${layerTarPath}" "$DST/${digestFile}"
+cat > "$DST/manifest.json" < {
+ workRoot = mkdtempSync(join(tmpdir(), 'extra-cidx-'));
+ fakeSkopeoDir = mkdtempSync(join(tmpdir(), 'fake-skopeo-extra-'));
+ });
+
+ afterEach(() => {
+ rmSync(workRoot, { recursive: true, force: true });
+ rmSync(fakeSkopeoDir, { recursive: true, force: true });
+ });
+
+ it("writes catalog entities to //catalog-entities/ from 'extensions/'", async () => {
+ const stage = await stageLayer(
+ 'extensions',
+ 'plugin.yaml',
+ 'kind: Plugin\n',
+ );
+ const binPath = await makeFakeSkopeo(stage);
+ rmSync(stage, { recursive: true, force: true });
+ const skopeo = new Skopeo(binPath);
+ const parent = join(workRoot, 'extra');
+ await extractExtraCatalogIndex(
+ skopeo,
+ 'quay.io/rhdh-community/index:1.10',
+ 'community',
+ parent,
+ null,
+ );
+ const dst = join(parent, 'community', 'catalog-entities');
+ await expect(fs.readFile(join(dst, 'plugin.yaml'), 'utf8')).resolves.toBe(
+ 'kind: Plugin\n',
+ );
+ });
+
+ it('falls back to marketplace/ when extensions/ is missing', async () => {
+ const stage = await stageLayer(
+ 'marketplace',
+ 'mp.yaml',
+ 'kind: Marketplace\n',
+ );
+ const binPath = await makeFakeSkopeo(stage);
+ rmSync(stage, { recursive: true, force: true });
+ const skopeo = new Skopeo(binPath);
+ const parent = join(workRoot, 'extra');
+ await extractExtraCatalogIndex(
+ skopeo,
+ 'quay.io/x/index:1',
+ 'partner',
+ parent,
+ null,
+ );
+ await expect(
+ fs.readFile(
+ join(parent, 'partner', 'catalog-entities', 'mp.yaml'),
+ 'utf8',
+ ),
+ ).resolves.toBe('kind: Marketplace\n');
+ });
+
+ it('logs a warning and does not throw when neither extensions/ nor marketplace/ is present', async () => {
+ // Stage an unrelated path so the layer extracts cleanly without writing
+ // either of the two recognised directories.
+ const stage = mkdtempSync(join(tmpdir(), 'extra-layer-empty-'));
+ mkdirSync(join(stage, 'other'), { recursive: true });
+ writeFileSync(join(stage, 'other', 'README'), 'noop');
+ const binPath = await makeFakeSkopeo(stage);
+ rmSync(stage, { recursive: true, force: true });
+ const skopeo = new Skopeo(binPath);
+ const writes: string[] = [];
+ const warn = jest
+ .spyOn(process.stdout, 'write')
+ .mockImplementation((chunk: unknown) => {
+ writes.push(String(chunk));
+ return true;
+ });
+ try {
+ const parent = join(workRoot, 'extra');
+ await expect(
+ extractExtraCatalogIndex(
+ skopeo,
+ 'quay.io/x/empty:1',
+ 'empty',
+ parent,
+ null,
+ ),
+ ).resolves.toBeUndefined();
+ const out = writes.join('');
+ expect(out).toMatch(
+ /WARNING: Extra catalog index image quay\.io\/x\/empty:1 does not have neither 'catalog-entities\/extensions\/' nor 'catalog-entities\/marketplace\/' directory/,
+ );
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it('logs the duplicate-subdir warning AFTER the extraction header when previouslyUsedBy is set', async () => {
+ const stage = await stageLayer(
+ 'extensions',
+ 'plugin.yaml',
+ 'kind: Plugin\n',
+ );
+ const binPath = await makeFakeSkopeo(stage);
+ rmSync(stage, { recursive: true, force: true });
+ const skopeo = new Skopeo(binPath);
+ const writes: string[] = [];
+ const warn = jest
+ .spyOn(process.stdout, 'write')
+ .mockImplementation((chunk: unknown) => {
+ writes.push(String(chunk));
+ return true;
+ });
+ try {
+ const parent = join(workRoot, 'extra');
+ await extractExtraCatalogIndex(
+ skopeo,
+ 'quay.io/second/index:1',
+ 'community',
+ parent,
+ 'quay.io/first/index:1',
+ );
+ const out = writes.join('');
+ const headerIdx = out.indexOf(
+ "Extracting extra catalog index 'community'",
+ );
+ const warningIdx = out.indexOf(
+ "WARNING: Subdirectory 'community' was already used by 'quay.io/first/index:1'",
+ );
+ expect(headerIdx).toBeGreaterThanOrEqual(0);
+ expect(warningIdx).toBeGreaterThan(headerIdx);
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it.each(['', '.', '..', 'foo/bar', String.raw`foo\bar`])(
+ 'refuses to extract into unsafe subdirectory %p (defense in depth)',
+ async badName => {
+ const stage = await stageLayer(
+ 'extensions',
+ 'plugin.yaml',
+ 'kind: Plugin\n',
+ );
+ const binPath = await makeFakeSkopeo(stage);
+ rmSync(stage, { recursive: true, force: true });
+ const skopeo = new Skopeo(binPath);
+ await expect(
+ extractExtraCatalogIndex(
+ skopeo,
+ 'quay.io/x:1',
+ badName,
+ join(workRoot, 'extra'),
+ null,
+ ),
+ ).rejects.toThrow(/unsafe subdirectory/);
+ },
+ );
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/finalize-install.test.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/finalize-install.test.ts
new file mode 100644
index 0000000000..bdd7c4db7b
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/finalize-install.test.ts
@@ -0,0 +1,123 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { finalizeInstall } from './installer';
+import { GLOBAL_CONFIG_FILENAME } from './types';
+
+describe('finalizeInstall', () => {
+ let root: string;
+ let globalConfigFile: string;
+ let stdoutSpy: jest.SpyInstance<
+ ReturnType,
+ Parameters
+ >;
+
+ beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'finalize-'));
+ globalConfigFile = join(root, GLOBAL_CONFIG_FILENAME);
+ stdoutSpy = jest
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ stdoutSpy.mockRestore();
+ rmSync(root, { recursive: true, force: true });
+ });
+
+ it('writes the global config and removes obsolete plugin dirs on success', async () => {
+ const obsoleteDir = join(root, 'obsolete-plugin');
+ mkdirSync(obsoleteDir);
+ writeFileSync(
+ join(obsoleteDir, 'dynamic-plugin-config.hash'),
+ 'stale-hash',
+ );
+ const installed = new Map([
+ ['stale-hash', 'obsolete-plugin'],
+ ]);
+
+ const code = await finalizeInstall(
+ [],
+ globalConfigFile,
+ { dynamicPlugins: { rootDirectory: 'dynamic-plugins-root' } },
+ root,
+ installed,
+ );
+
+ expect(code).toBe(0);
+ expect(existsSync(globalConfigFile)).toBe(true);
+ expect(readFileSync(globalConfigFile, 'utf8')).toContain(
+ 'rootDirectory: dynamic-plugins-root',
+ );
+ expect(existsSync(obsoleteDir)).toBe(false);
+ });
+
+ it('skips the config write and cleanup when errors were collected', async () => {
+ const existingDir = join(root, 'keep-me');
+ mkdirSync(existingDir);
+ writeFileSync(
+ join(existingDir, 'dynamic-plugin-config.hash'),
+ 'prior-hash',
+ );
+ const sentinel =
+ 'dynamicPlugins:\n rootDirectory: dynamic-plugins-root\n# previous run\n';
+ writeFileSync(globalConfigFile, sentinel);
+ const installed = new Map([['prior-hash', 'keep-me']]);
+
+ const code = await finalizeInstall(
+ ['oci://bogus/image:tag: connection refused'],
+ globalConfigFile,
+ { dynamicPlugins: { rootDirectory: 'dynamic-plugins-root' } },
+ root,
+ installed,
+ );
+
+ expect(code).toBe(1);
+ // Previous config on disk must be preserved untouched.
+ expect(readFileSync(globalConfigFile, 'utf8')).toBe(sentinel);
+ // Previously-installed plugin dir must not be cleaned up.
+ expect(existsSync(existingDir)).toBe(true);
+
+ const logged = stdoutSpy.mock.calls.map(c => String(c[0])).join('');
+ expect(logged).toContain('1 plugin(s) failed');
+ expect(logged).toContain('oci://bogus/image:tag: connection refused');
+ expect(logged).toContain(
+ `Skipping ${GLOBAL_CONFIG_FILENAME} write and cleanup`,
+ );
+ });
+
+ it('does not create the config file on error when no previous run exists', async () => {
+ const code = await finalizeInstall(
+ ['npm:broken-plugin: integrity mismatch'],
+ globalConfigFile,
+ { dynamicPlugins: { rootDirectory: 'dynamic-plugins-root' } },
+ root,
+ new Map(),
+ );
+
+ expect(code).toBe(1);
+ expect(existsSync(globalConfigFile)).toBe(false);
+ });
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-cache.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-cache.ts
new file mode 100644
index 0000000000..352e8c9312
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-cache.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { createHash } from 'node:crypto';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import { InstallException } from './errors';
+import { log } from './log';
+import { resolveImage } from './image-resolver';
+import { type Skopeo } from './skopeo';
+import { DOCKER_PROTO, OCI_PROTO } from './types';
+
+type OciManifest = {
+ layers?: Array<{ digest: string }>;
+ annotations?: Record;
+};
+
+/**
+ * Shared cache that keeps each OCI image's single-layer tarball on disk and
+ * returns the path. If several plugins point at the same image (very common
+ * for multi-plugin overlays) we download once and extract slices from that
+ * same tarball.
+ *
+ * The cache stores *promises*, so concurrent `getTarball` calls for the same
+ * image share the in-flight `skopeo copy` rather than racing. This is the
+ * JS equivalent of fast.py's `threading.Lock` guard around the cache.
+ */
+export class OciImageCache {
+ private readonly tarballs = new Map>();
+
+ constructor(
+ private readonly skopeo: Skopeo,
+ private readonly tmpDir: string,
+ ) {}
+
+ async getTarball(image: string): Promise {
+ const resolved = await resolveImage(this.skopeo, image);
+ let pending = this.tarballs.get(resolved);
+ if (!pending) {
+ pending = this.downloadAndLocateTarball(resolved);
+ this.tarballs.set(resolved, pending);
+ pending.catch(() => this.tarballs.delete(resolved));
+ }
+ return pending;
+ }
+
+ async getDigest(image: string): Promise {
+ const resolved = await resolveImage(this.skopeo, image);
+ const dockerUrl = resolved.replace(OCI_PROTO, DOCKER_PROTO);
+ const data = await this.skopeo.inspect(dockerUrl);
+ const digest = data.Digest;
+ if (!digest) throw new InstallException(`No digest returned for ${image}`);
+ const [, hash] = digest.split(':');
+ if (!hash)
+ throw new InstallException(`Malformed digest ${digest} for ${image}`);
+ return hash;
+ }
+
+ /**
+ * Plugin paths are published via the `io.backstage.dynamic-packages` OCI
+ * annotation (base64-encoded JSON array of `{path: {...}}` objects). An
+ * image with no annotation returns an empty list.
+ */
+ async getPluginPaths(image: string): Promise {
+ const resolved = await resolveImage(this.skopeo, image);
+ const dockerUrl = resolved.replace(OCI_PROTO, DOCKER_PROTO);
+ const manifest = (await this.skopeo.inspectRaw(dockerUrl)) as OciManifest;
+ const annotation = manifest.annotations?.['io.backstage.dynamic-packages'];
+ if (!annotation) return [];
+ let entries: unknown;
+ try {
+ const decoded = Buffer.from(annotation, 'base64').toString('utf8');
+ entries = JSON.parse(decoded);
+ } catch (err) {
+ throw new InstallException(
+ `Could not decode 'io.backstage.dynamic-packages' annotation on ${image}: ${(err as Error).message}`,
+ );
+ }
+ if (!Array.isArray(entries)) return [];
+ const paths: string[] = [];
+ for (const entry of entries) {
+ if (entry && typeof entry === 'object') {
+ paths.push(...Object.keys(entry as Record));
+ }
+ }
+ return paths;
+ }
+
+ private async downloadAndLocateTarball(resolved: string): Promise {
+ const digest = createHash('sha256').update(resolved).digest('hex');
+ const localDir = path.join(this.tmpDir, digest);
+ await fs.mkdir(localDir, { recursive: true });
+ const dockerUrl = resolved.replace(OCI_PROTO, DOCKER_PROTO);
+ log(`\t==> Downloading ${resolved}`);
+ await this.skopeo.copy(dockerUrl, `dir:${localDir}`);
+
+ const manifestPath = path.join(localDir, 'manifest.json');
+ const manifest = JSON.parse(
+ await fs.readFile(manifestPath, 'utf8'),
+ ) as OciManifest;
+ const firstLayer = manifest.layers?.[0]?.digest;
+ if (!firstLayer) {
+ throw new InstallException(`OCI manifest for ${resolved} has no layers`);
+ }
+ const [, filename] = firstLayer.split(':');
+ if (!filename) {
+ throw new InstallException(
+ `Malformed layer digest ${firstLayer} in ${resolved}`,
+ );
+ }
+ return path.join(localDir, filename);
+ }
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-resolver.test.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-resolver.test.ts
new file mode 100644
index 0000000000..646bade826
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-resolver.test.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { resolveImage } from './image-resolver';
+import type { Skopeo } from './skopeo';
+
+function fakeSkopeo(exists: (url: string) => boolean): Skopeo {
+ return { exists: async (url: string) => exists(url) } as unknown as Skopeo;
+}
+
+describe('resolveImage', () => {
+ it('returns non-RHDH images unchanged', async () => {
+ const sk = fakeSkopeo(() => true);
+ await expect(
+ resolveImage(sk, 'oci://quay.io/other/plugin:1.0'),
+ ).resolves.toBe('oci://quay.io/other/plugin:1.0');
+ });
+
+ it('returns the RHDH image unchanged when it exists', async () => {
+ const sk = fakeSkopeo(() => true);
+ await expect(
+ resolveImage(sk, 'oci://registry.access.redhat.com/rhdh/plugin:1.0'),
+ ).resolves.toBe('oci://registry.access.redhat.com/rhdh/plugin:1.0');
+ });
+
+ it('falls back to quay.io/rhdh when the RHDH image is missing', async () => {
+ const sk = fakeSkopeo(() => false);
+ await expect(
+ resolveImage(sk, 'oci://registry.access.redhat.com/rhdh/plugin:1.0'),
+ ).resolves.toBe('oci://quay.io/rhdh/plugin:1.0');
+ });
+
+ it('preserves the docker:// protocol on fallback', async () => {
+ const sk = fakeSkopeo(() => false);
+ await expect(
+ resolveImage(sk, 'docker://registry.access.redhat.com/rhdh/plugin:1.0'),
+ ).resolves.toBe('docker://quay.io/rhdh/plugin:1.0');
+ });
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-resolver.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-resolver.ts
new file mode 100644
index 0000000000..1b466f77c7
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/image-resolver.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { log } from './log';
+import { type Skopeo } from './skopeo';
+import { DOCKER_PROTO, OCI_PROTO, RHDH_FALLBACK, RHDH_REGISTRY } from './types';
+
+/**
+ * Resolve a (possibly oci:// / docker://) image reference. If it points at
+ * `registry.access.redhat.com/rhdh/...` and that registry rejects the image,
+ * fall back to `quay.io/rhdh/...` (same protocol). Mirrors fast.py `resolve_image`.
+ */
+export async function resolveImage(
+ skopeo: Skopeo,
+ image: string,
+): Promise {
+ const { proto, raw } = stripProto(image);
+ if (!raw.startsWith(RHDH_REGISTRY)) return image;
+
+ const dockerUrl = `${DOCKER_PROTO}${raw}`;
+ if (await skopeo.exists(dockerUrl)) return image;
+
+ const fallback = raw.replace(RHDH_REGISTRY, RHDH_FALLBACK);
+ log(`\t==> Falling back to ${RHDH_FALLBACK} for ${raw}`);
+ return `${proto}${fallback}`;
+}
+
+function stripProto(image: string): { proto: string; raw: string } {
+ if (image.startsWith(OCI_PROTO))
+ return { proto: OCI_PROTO, raw: image.slice(OCI_PROTO.length) };
+ if (image.startsWith(DOCKER_PROTO))
+ return { proto: DOCKER_PROTO, raw: image.slice(DOCKER_PROTO.length) };
+ return { proto: '', raw: image };
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/index.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/index.ts
new file mode 100644
index 0000000000..f506dd8350
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/index.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { createCliModule } from '@backstage/cli-node';
+import packageJson from '../package.json';
+
+/**
+ * Entry exposed to `backstage-cli` discovery. The package's `bin`
+ * (`install-dynamic-plugins`) keeps using the self-contained esbuild bundle
+ * for direct/init-container invocations — this default export only matters
+ * when a host project loads the package via `backstage-cli`.
+ */
+export default createCliModule({
+ packageJson,
+ init: async reg => {
+ reg.addCommand({
+ path: ['install'],
+ description:
+ 'Install RHDH dynamic plugins listed in dynamic-plugins.yaml into the given directory.',
+ execute: { loader: () => import('./command') },
+ });
+ },
+});
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer-npm.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer-npm.ts
new file mode 100644
index 0000000000..bc75bb2ecf
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer-npm.ts
@@ -0,0 +1,155 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import { InstallException } from './errors';
+import { verifyIntegrity } from './integrity';
+import { log } from './log';
+import { run } from './run';
+import { extractNpmPackage } from './tar-extract';
+import { CONFIG_HASH_FILE, type Plugin } from './types';
+import { markAsFresh } from './util';
+
+export type NpmInstallResult = {
+ pluginPath: string | null;
+ pluginConfig: Record;
+};
+
+/**
+ * Install a single NPM-packaged (or local) plugin into `destination`.
+ * Runs `npm pack` to produce the tarball, verifies integrity for remote
+ * packages (unless skipped), then extracts.
+ *
+ * Concurrency is the caller's responsibility — `installNpm` in `index.ts`
+ * runs a bounded `mapConcurrent` (default 3 workers via `getNpmWorkers()`)
+ * over a list of plugins that have already passed the `definitelyNoOp`
+ * pre-pass, so by the time this function is called the plugin definitely
+ * needs work.
+ */
+export async function installNpmPlugin(
+ plugin: Plugin,
+ destination: string,
+ skipIntegrity: boolean,
+ installed: Map,
+): Promise {
+ if (plugin.disabled) {
+ return { pluginPath: null, pluginConfig: {} };
+ }
+ const hash = plugin.plugin_hash;
+ if (!hash) {
+ throw new InstallException(
+ `Internal error: plugin ${plugin.package} missing plugin_hash`,
+ );
+ }
+ const pkg = plugin.package;
+ const config: Record = plugin.pluginConfig ?? {};
+
+ const isLocal = pkg.startsWith('./');
+ const actualPkg = isLocal ? path.join(process.cwd(), pkg.slice(2)) : pkg;
+
+ const verifyRemoteIntegrity = !isLocal && !skipIntegrity;
+ if (verifyRemoteIntegrity && !plugin.integrity) {
+ throw new InstallException(
+ `No integrity hash provided for Package ${pkg}. This is an insecure installation. ` +
+ `To ignore this error, set the SKIP_INTEGRITY_CHECK environment variable to 'true'.`,
+ );
+ }
+
+ log('\t==> Running npm pack');
+ const archiveName = await npmPack(actualPkg, destination);
+ if (!isSafeArchiveName(archiveName)) {
+ throw new InstallException(
+ `npm pack returned an unsafe filename for ${pkg}: '${archiveName}'`,
+ );
+ }
+ const archive = path.join(destination, archiveName);
+
+ if (verifyRemoteIntegrity) {
+ log('\t==> Verifying package integrity');
+ // `plugin.integrity` is guaranteed present — the check above throws otherwise.
+ await verifyIntegrity(pkg, archive, plugin.integrity as string);
+ }
+
+ const pluginPath = await extractNpmPackage(archive);
+ await fs.writeFile(
+ path.join(destination, pluginPath, CONFIG_HASH_FILE),
+ hash,
+ );
+
+ markAsFresh(installed, pluginPath);
+ return { pluginPath, pluginConfig: config };
+}
+
+/**
+ * Run `npm pack --json` and extract the archive filename from the structured
+ * output. The text form of `npm pack` intermixes warnings with the filename
+ * (last-line parsing is fragile); `--json` gives `[{ filename, ... }]`.
+ */
+async function npmPack(
+ actualPkg: string,
+ destination: string,
+): Promise {
+ // `--ignore-scripts` blocks `preinstall` / `prepack` / `prepare` lifecycle
+ // hooks that NPM packages can declare. Dynamic plugins are not expected
+ // to ship build steps that need to run at install time, and skipping the
+ // hooks both removes a code-execution-on-install attack surface and
+ // shaves a fork+exec per package off the wall clock.
+ const { stdout } = await run(
+ ['npm', 'pack', '--json', '--ignore-scripts', actualPkg],
+ `npm pack failed for ${actualPkg}`,
+ { cwd: destination },
+ );
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(stdout);
+ } catch (err) {
+ throw new InstallException(
+ `npm pack produced invalid JSON for ${actualPkg}: ${(err as Error).message}`,
+ );
+ }
+ if (!Array.isArray(parsed) || parsed.length === 0) {
+ throw new InstallException(
+ `npm pack produced no archives for ${actualPkg}`,
+ );
+ }
+ const first = parsed[0];
+ if (!isNpmPackJsonEntry(first)) {
+ throw new InstallException(
+ `npm pack output missing 'filename' for ${actualPkg}`,
+ );
+ }
+ return first.filename;
+}
+
+function isNpmPackJsonEntry(value: unknown): value is { filename: string } {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ typeof (value as { filename?: unknown }).filename === 'string'
+ );
+}
+
+/**
+ * Reject any filename that would let `npm pack` escape `destination` once
+ * passed to `path.join` — directory separators, leading `..`, or empty.
+ * `npm pack` is expected to emit a flat `-.tgz`, so any
+ * non-flat name is treated as adversarial.
+ */
+function isSafeArchiveName(name: string): boolean {
+ if (!name || name === '.' || name === '..') return false;
+ if (name.startsWith('..')) return false;
+ return !/[/\\]/.test(name);
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer-oci.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer-oci.ts
new file mode 100644
index 0000000000..454baa9874
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer-oci.ts
@@ -0,0 +1,152 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import { InstallException } from './errors';
+import { type OciImageCache } from './image-cache';
+import { log } from './log';
+import { extractOciPlugin } from './tar-extract';
+import {
+ CONFIG_HASH_FILE,
+ effectivePullPolicy,
+ IMAGE_HASH_FILE,
+ type Plugin,
+ PullPolicy,
+} from './types';
+import { fileExists, markAsFresh } from './util';
+
+/**
+ * Split an OCI package spec into `!`. Uses
+ * `indexOf` so plugin paths containing `!` (legal per the OCI grammar) are
+ * preserved on the right side instead of being silently truncated by
+ * `String#split`.
+ */
+function splitOciPackage(
+ pkg: string,
+): { imagePart: string; pluginPath: string } | null {
+ const bang = pkg.indexOf('!');
+ if (bang === -1) return null;
+ const imagePart = pkg.slice(0, bang);
+ const pluginPath = pkg.slice(bang + 1);
+ if (!imagePart || !pluginPath) return null;
+ return { imagePart, pluginPath };
+}
+
+export type OciInstallResult = {
+ /** The installed plugin's directory name (relative to destination), or null when skipped. */
+ pluginPath: string | null;
+ pluginConfig: Record;
+};
+
+/**
+ * Install a single OCI-packaged plugin into `destination`. Returns the
+ * on-disk directory name and the plugin's own config (for merging into the
+ * global app-config).
+ */
+export async function installOciPlugin(
+ plugin: Plugin,
+ destination: string,
+ imageCache: OciImageCache,
+ installed: Map,
+): Promise {
+ if (plugin.disabled) {
+ return { pluginPath: null, pluginConfig: {} };
+ }
+ const hash = plugin.plugin_hash;
+ if (!hash) {
+ throw new InstallException(
+ `Internal error: plugin ${plugin.package} missing plugin_hash`,
+ );
+ }
+ const pkg = plugin.package;
+ const config: Record = plugin.pluginConfig ?? {};
+ const pullPolicy = effectivePullPolicy(plugin);
+
+ if (
+ await isAlreadyInstalled(
+ pkg,
+ hash,
+ pullPolicy,
+ destination,
+ imageCache,
+ installed,
+ )
+ ) {
+ installed.delete(hash);
+ return { pluginPath: null, pluginConfig: config };
+ }
+
+ if (!plugin.version) {
+ throw new InstallException(`No version for ${pkg}`);
+ }
+ const parts = splitOciPackage(pkg);
+ if (!parts) {
+ throw new InstallException(
+ `OCI package ${pkg} missing !plugin-path suffix`,
+ );
+ }
+ const { imagePart, pluginPath } = parts;
+
+ const tarball = await imageCache.getTarball(imagePart);
+ await extractOciPlugin(tarball, pluginPath, destination);
+
+ const pluginDir = path.join(destination, pluginPath);
+ await fs.mkdir(pluginDir, { recursive: true });
+ await fs.writeFile(
+ path.join(pluginDir, IMAGE_HASH_FILE),
+ await imageCache.getDigest(imagePart),
+ );
+ await fs.writeFile(path.join(pluginDir, CONFIG_HASH_FILE), hash);
+
+ markAsFresh(installed, pluginPath);
+ return { pluginPath, pluginConfig: config };
+}
+
+/**
+ * Returns true when the plugin is already installed and can be skipped:
+ * - IfNotPresent policy → skip unconditionally
+ * - Always policy → skip only when the remote digest matches what's on disk
+ */
+async function isAlreadyInstalled(
+ pkg: string,
+ hash: string,
+ pullPolicy: PullPolicy,
+ destination: string,
+ imageCache: OciImageCache,
+ installed: Map,
+): Promise {
+ const pathInstalled = installed.get(hash);
+ if (pathInstalled === undefined) return false;
+
+ if (pullPolicy === PullPolicy.IF_NOT_PRESENT) {
+ log(`\t==> ${pkg}: already installed, skipping`);
+ return true;
+ }
+
+ if (pullPolicy !== PullPolicy.ALWAYS) return false;
+
+ const digestFile = path.join(destination, pathInstalled, IMAGE_HASH_FILE);
+ if (!(await fileExists(digestFile))) return false;
+
+ const localDigest = (await fs.readFile(digestFile, 'utf8')).trim();
+ const parts = splitOciPackage(pkg);
+ if (!parts) return false;
+ const remoteDigest = await imageCache.getDigest(parts.imagePart);
+ if (localDigest !== remoteDigest) return false;
+
+ log(`\t==> ${pkg}: digest unchanged, skipping`);
+ return true;
+}
diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer.ts
new file mode 100644
index 0000000000..2f8ee15328
--- /dev/null
+++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/installer.ts
@@ -0,0 +1,650 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { existsSync } from 'node:fs';
+import * as fs from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { cli } from 'cleye';
+import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
+import {
+ cleanupCatalogIndexTemp,
+ extractCatalogIndex,
+ extractExtraCatalogIndex,
+ parseExtraCatalogIndexImages,
+} from './catalog-index';
+import {
+ getNpmWorkers,
+ getWorkers,
+ mapConcurrent,
+ type Outcome,
+} from './concurrency';
+import { InstallException } from './errors';
+import { OciImageCache } from './image-cache';
+import { installNpmPlugin } from './installer-npm';
+import { installOciPlugin } from './installer-oci';
+import { createLock, registerLockCleanup, removeLock } from './lock-file';
+import { log } from './log';
+import {
+ deepMerge,
+ filterDisabledOciPlugins,
+ mergePlugin,
+ preMergeOciDisabledState,
+} from './merger';
+import { computePluginHash } from './plugin-hash';
+import { Skopeo } from './skopeo';
+import {
+ CONFIG_HASH_FILE,
+ DPDY_FILENAME,
+ type DynamicPluginsConfig,
+ effectivePullPolicy,
+ GLOBAL_CONFIG_FILENAME,
+ LOCK_FILENAME,
+ OCI_PROTO,
+ type Plugin,
+ type PluginMap,
+ type PluginSpec,
+ PullPolicy,
+} from './types';
+import { fileExists, isPlainObject } from './util';
+
+const CONFIG_FILE = 'dynamic-plugins.yaml';
+
+/**
+ * Entry point for the install command. `args` is the argv slice after the
+ * command path; `programName` overrides the `--help` usage line so it matches
+ * the actual invocation (e.g. `install-dynamic-plugins install` when called
+ * via the cli-module discovery path). Exported for the cli-module command
+ * loader and unit tests; not part of the package's public API.
+ *
+ * @internal
+ */
+export async function main(
+ args: string[] = process.argv.slice(2),
+ programName = 'install-dynamic-plugins',
+): Promise {
+ const argv = cli(
+ {
+ name: programName,
+ parameters: [''],
+ help: {
+ description:
+ 'Install RHDH dynamic plugins listed in dynamic-plugins.yaml into the given directory.',
+ },
+ },
+ undefined,
+ args,
+ );
+ const root = path.resolve(argv._.dynamicPluginsRoot);
+ const lockPath = path.join(root, LOCK_FILENAME);
+ registerLockCleanup(lockPath);
+ await fs.mkdir(root, { recursive: true });
+ await createLock(lockPath);
+
+ let exitCode = 0;
+ try {
+ exitCode = await runInstaller(root);
+ } finally {
+ await cleanupCatalogIndexTemp(root).catch(() => undefined);
+ await removeLock(lockPath).catch(() => undefined);
+ }
+ process.exit(exitCode);
+}
+
+async function runInstaller(root: string): Promise {
+ const skopeo = new Skopeo();
+ const workers = getWorkers();
+ log(`======= Workers: ${workers} (CPUs: ${os.cpus().length})`);
+
+ // Resolve the config file path against CWD at startup so the dependency on
+ // CWD is explicit in the operator log; includes are resolved relative to
+ // the config file's directory (matches the Python installer).
+ const configFileAbs = path.resolve(CONFIG_FILE);
+ const configDir = path.dirname(configFileAbs);
+ const globalConfigFile = path.join(root, GLOBAL_CONFIG_FILENAME);
+ log(`======= Config file: ${configFileAbs}`);
+
+ const entitiesDir =
+ process.env.CATALOG_ENTITIES_EXTRACT_DIR ??
+ path.join(os.tmpdir(), 'extensions');
+ const catalogDpdy = await maybeExtractCatalogIndex(skopeo, root, entitiesDir);
+ await maybeExtractExtraCatalogIndexes(skopeo, entitiesDir);
+ const content = await loadDynamicPluginsConfig(
+ configFileAbs,
+ globalConfigFile,
+ );
+ if (!content) return 0;
+
+ const imageCache = new OciImageCache(
+ skopeo,
+ await fs.mkdtemp(path.join(os.tmpdir(), 'rhdh-oci-cache-')),
+ );
+
+ const allPlugins = await loadAllPlugins(
+ content,
+ configFileAbs,
+ configDir,
+ catalogDpdy,
+ imageCache,
+ );
+ const installed = await readInstalledPluginHashes(root);
+ const globalConfig: Record = {
+ dynamicPlugins: { rootDirectory: 'dynamic-plugins-root' },
+ };
+ const { oci, npm, skipped } = categorize(allPlugins);
+ handleSkippedLocals(skipped, globalConfig);
+
+ const skipIntegrity =
+ (process.env.SKIP_INTEGRITY_CHECK ?? '').toLowerCase() === 'true';
+ const errors: string[] = [];
+ await installOci(
+ oci,
+ root,
+ imageCache,
+ installed,
+ workers,
+ globalConfig,
+ errors,
+ );
+ await installNpm(npm, root, skipIntegrity, installed, globalConfig, errors);
+
+ return finalizeInstall(
+ errors,
+ globalConfigFile,
+ globalConfig,
+ root,
+ installed,
+ );
+}
+
+/** Optional `CATALOG_INDEX_IMAGE` extraction — returns the path to the
+ * extracted `dynamic-plugins.default.yaml`, or `null` when the env var is
+ * unset. */
+async function maybeExtractCatalogIndex(
+ skopeo: Skopeo,
+ root: string,
+ entitiesDir: string,
+): Promise {
+ const catalogImage = process.env.CATALOG_INDEX_IMAGE ?? '';
+ if (!catalogImage) return null;
+ return extractCatalogIndex(skopeo, catalogImage, root, entitiesDir);
+}
+
+/**
+ * Optional `EXTRA_CATALOG_INDEX_IMAGES` extraction. Each entry is extracted
+ * into an isolated subdirectory under `/extra/` so multiple
+ * indexes can coexist without clobbering the primary index's
+ * `catalog-entities/`. Duplicate subdirectory names within the same env-var
+ * value emit an overwrite warning (handled by `extractExtraCatalogIndex`).
+ */
+async function maybeExtractExtraCatalogIndexes(
+ skopeo: Skopeo,
+ entitiesDir: string,
+): Promise {
+ const raw = process.env.EXTRA_CATALOG_INDEX_IMAGES ?? '';
+ if (!raw) return;
+ const extraParent = path.join(entitiesDir, 'extra');
+ const seen = new Map();
+ for (const [name, imageRef] of parseExtraCatalogIndexImages(raw)) {
+ const prev = seen.get(name) ?? null;
+ seen.set(name, imageRef);
+ await extractExtraCatalogIndex(skopeo, imageRef, name, extraParent, prev);
+ }
+}
+
+/** Read and parse `dynamic-plugins.yaml`. Writes an empty global config and
+ * returns `null` for the two short-circuit cases (file missing, file empty)
+ * so the caller can early-exit with code 0. */
+async function loadDynamicPluginsConfig(
+ configFileAbs: string,
+ globalConfigFile: string,
+): Promise {
+ if (!(await fileExists(configFileAbs))) {
+ log(`No ${CONFIG_FILE} found at ${configFileAbs}. Skipping.`);
+ await fs.writeFile(globalConfigFile, '');
+ return null;
+ }
+ const rawContent = await fs.readFile(configFileAbs, 'utf8');
+ const content = parseYaml(rawContent) as DynamicPluginsConfig | null;
+ if (!content) {
+ log(`${configFileAbs} is empty. Skipping.`);
+ await fs.writeFile(globalConfigFile, '');
+ return null;
+ }
+ return content;
+}
+
+/** Resolve include paths, substitute the catalog-index placeholder, merge
+ * everything into a single `PluginMap`, and compute change-detection hashes.
+ *
+ * Two-phase to match the Python pre-merge OCI-disable pass: load every
+ * include file's plugin list into memory FIRST, compute the effectively
+ * disabled OCI registries, then filter those entries out of every list
+ * before merging. Without this pass an OCI plugin marked `disabled: true`
+ * at level 1 would still trigger a `skopeo` round-trip during the level-0
+ * merge — wasted work and a footgun in restricted-network init containers.
+ */
+async function loadAllPlugins(
+ content: DynamicPluginsConfig,
+ configFileAbs: string,
+ configDir: string,
+ catalogDpdy: string | null,
+ imageCache: OciImageCache,
+): Promise {
+ const allPlugins: PluginMap = {};
+ const includes = resolveIncludes(
+ content.includes ?? [],
+ configDir,
+ catalogDpdy,
+ );
+
+ const includeLists: Array<[string, PluginSpec[]]> = [];
+ for (const inc of includes) {
+ if (!(await fileExists(inc))) {
+ log(`WARNING: include file ${inc} not found, skipping`);
+ continue;
+ }
+ log(`\n======= Including plugins from ${inc}`);
+ const parsed = parseYaml(
+ await fs.readFile(inc, 'utf8'),
+ ) as DynamicPluginsConfig | null;
+ if (parsed && !isPlainObject(parsed)) {
+ throw new InstallException(`${inc} must contain a mapping`);
+ }
+ const plugins = parsed?.plugins ?? [];
+ if (!Array.isArray(plugins)) {
+ throw new InstallException(
+ `${inc} must contain a 'plugins' list (got ${typeof plugins})`,
+ );
+ }
+ includeLists.push([inc, plugins]);
+ }
+ const mainPlugins = content.plugins ?? [];
+
+ const disabledRegistries = preMergeOciDisabledState(
+ includeLists,
+ mainPlugins,
+ configFileAbs,
+ );
+
+ for (const [inc, plugins] of includeLists) {
+ for (const plugin of filterDisabledOciPlugins(
+ plugins,
+ disabledRegistries,
+ )) {
+ await mergePlugin(plugin, allPlugins, inc, /* level */ 0, imageCache);
+ }
+ }
+ for (const plugin of filterDisabledOciPlugins(
+ mainPlugins,
+ disabledRegistries,
+ )) {
+ await mergePlugin(
+ plugin,
+ allPlugins,
+ configFileAbs,
+ /* level */ 1,
+ imageCache,
+ );
+ }
+
+ for (const p of Object.values(allPlugins)) {
+ p.plugin_hash = computePluginHash(p);
+ }
+ return allPlugins;
+}
+
+function resolveIncludes(
+ rawIncludes: readonly string[],
+ configDir: string,
+ catalogDpdy: string | null,
+): string[] {
+ const includes = rawIncludes.map(inc =>
+ path.isAbsolute(inc) ? inc : path.resolve(configDir, inc),
+ );
+ if (catalogDpdy) {
+ const idx = includes.findIndex(inc => path.basename(inc) === DPDY_FILENAME);
+ if (idx !== -1) includes[idx] = catalogDpdy;
+ }
+ return includes;
+}
+
+/**
+ * Write the global config, prune obsolete plugin dirs, and compute the exit
+ * code. Exported for unit tests; not part of the package's public API.
+ *
+ * @internal
+ */
+export async function finalizeInstall(
+ errors: string[],
+ globalConfigFile: string,
+ globalConfig: Record,
+ root: string,
+ installed: Map,
+): Promise {
+ if (errors.length > 0) {
+ log(`\n======= ${errors.length} plugin(s) failed:`);
+ for (const err of errors) log(` - ${err}`);
+ // Skip writing the global config and cleaning up previously-installed
+ // plugin dirs so the filesystem does not end up in an "almost valid"
+ // state. Exit 1 is enough for init containers to block startup, but a
+ // manual restart of the main container (or a deployment that does not
+ // enforce init-container success) could otherwise pick up a partial
+ // config — e.g. a frontend plugin without its backend dep, yielding a
+ // broken UI. Preserving the prior state makes the next run a clean retry.
+ log(
+ `\n======= Skipping ${GLOBAL_CONFIG_FILENAME} write and cleanup because of install failures. ` +
+ `Fix the errors above and re-run; the previous successful state is preserved.`,
+ );
+ return 1;
+ }
+
+ await fs.writeFile(globalConfigFile, stringifyYaml(globalConfig));
+ await cleanupRemoved(root, installed);
+
+ log('\n======= All plugins installed successfully');
+ return 0;
+}
+
+type Categorized = {
+ oci: Plugin[];
+ npm: Plugin[];
+ skipped: Plugin[];
+};
+
+function categorize(allPlugins: PluginMap): Categorized {
+ const oci: Plugin[] = [];
+ const npm: Plugin[] = [];
+ const skipped: Plugin[] = [];
+ for (const plugin of Object.values(allPlugins)) {
+ if (plugin.disabled) {
+ log(`\n======= Skipping disabled plugin ${plugin.package}`);
+ continue;
+ }
+ if (plugin.package.startsWith(OCI_PROTO)) {
+ oci.push(plugin);
+ continue;
+ }
+ if (plugin.package.startsWith('./')) {
+ const localPath = path.join(process.cwd(), plugin.package.slice(2));
+ if (existsSync(localPath)) npm.push(plugin);
+ else skipped.push(plugin);
+ continue;
+ }
+ npm.push(plugin);
+ }
+ return { oci, npm, skipped };
+}
+
+function handleSkippedLocals(
+ skipped: Plugin[],
+ globalConfig: Record,
+): void {
+ if (skipped.length === 0) return;
+ log(
+ `\n======= Skipping ${skipped.length} local plugins (directories not found)`,
+ );
+ for (const plugin of skipped) {
+ const abs = path.join(process.cwd(), plugin.package.slice(2));
+ log(`\t==> ${plugin.package} (not found at ${abs})`);
+ if (isPlainObject(plugin.pluginConfig)) {
+ deepMerge(plugin.pluginConfig, globalConfig);
+ }
+ }
+}
+
+type InstallOutcome = {
+ pluginPath: string | null;
+ pluginConfig: Record;
+};
+
+async function installOci(
+ plugins: Plugin[],
+ root: string,
+ imageCache: OciImageCache,
+ installed: Map,
+ workers: number,
+ globalConfig: Record,
+ errors: string[],
+): Promise {
+ await runInstallPipeline({
+ plugins,
+ workers,
+ label: 'OCI',
+ installFn: plugin => installOciPlugin(plugin, root, imageCache, installed),
+ installed,
+ globalConfig,
+ errors,
+ });
+}
+
+async function installNpm(
+ plugins: Plugin[],
+ root: string,
+ skipIntegrity: boolean,
+ installed: Map,
+ globalConfig: Record,
+ errors: string[],
+): Promise {
+ // `npm pack` writes the tarball to `cwd` with a package-derived filename
+ // (`-.tgz`), so concurrent invocations against different
+ // packages don't clash on the filename. The npm CLI cache
+ // (`~/.npm/_cacache`) handles its own locking. Cap defaults to 3 to keep
+ // the registry happy — override with `DYNAMIC_PLUGINS_NPM_WORKERS=1` to
+ // restore the original sequential behaviour.
+ await runInstallPipeline({
+ plugins,
+ workers: getNpmWorkers(),
+ label: 'NPM',
+ installFn: plugin =>
+ installNpmPlugin(plugin, root, skipIntegrity, installed),
+ installed,
+ globalConfig,
+ errors,
+ });
+}
+
+type RunInstallPipelineArgs = {
+ plugins: Plugin[];
+ workers: number;
+ label: 'OCI' | 'NPM';
+ installFn: (plugin: Plugin) => Promise;
+ installed: Map;
+ globalConfig: Record;
+ errors: string[];
+};
+
+/**
+ * Shared install pipeline used by both `installOci` and `installNpm`:
+ * 1. Synchronous pre-pass that short-circuits "definitely no-op" plugins
+ * (hash present, no force, pull policy not Always) without spinning
+ * up the worker pool — avoids the parallel-skopeo / parallel-npm-pack
+ * overhead in the steady-state restart case.
+ * 2. `mapConcurrent` over the plugins that actually need work, capped by
+ * `workers`.
+ * 3. Single-pass over the outcomes that records errors and merges plugin
+ * configs into the global config.
+ *
+ * Keeping both categories on this shared body so a behaviour change (a new
+ * fast-path filter, a different log format, an extra error pathway) does
+ * not have to be made twice in two slightly-divergent copies.
+ */
+async function runInstallPipeline(args: RunInstallPipelineArgs): Promise {
+ const {
+ plugins,
+ workers,
+ label,
+ installFn,
+ installed,
+ globalConfig,
+ errors,
+ } = args;
+ if (plugins.length === 0) return;
+
+ const needsWork = partitionDefinitelyNoOp(
+ plugins,
+ installed,
+ globalConfig,
+ errors,
+ );
+ if (needsWork.length === 0) return;
+
+ const workerSuffix = workers === 1 ? '' : 's';
+ log(
+ `\n======= Installing ${needsWork.length} ${label} plugin(s) (${workers} worker${workerSuffix})`,
+ );
+
+ const results = await mapConcurrent(needsWork, workers, async plugin => {
+ log(`\n======= Installing ${label} plugin ${plugin.package}`);
+ return installFn(plugin);
+ });
+
+ applyInstallOutcomes(results, globalConfig, errors);
+}
+
+/**
+ * Synchronous pre-pass: drop plugins that are definitely a no-op (hash on
+ * disk, not forced, not Always-pull) without paying the worker-pool /
+ * Promise overhead, and return the remaining plugins that actually need
+ * installation work. Side-effect: removes the no-op plugins from
+ * `installed` and merges their `pluginConfig` into `globalConfig`.
+ */
+function partitionDefinitelyNoOp(
+ plugins: Plugin[],
+ installed: Map,
+ globalConfig: Record,
+ errors: string[],
+): Plugin[] {
+ const needsWork: Plugin[] = [];
+ for (const plugin of plugins) {
+ if (definitelyNoOp(plugin, installed)) {
+ log(`\t==> ${plugin.package}: already installed, skipping`);
+ installed.delete(plugin.plugin_hash);
+ mergeConfigSafely(
+ plugin.pluginConfig,
+ globalConfig,
+ plugin.package,
+ errors,
+ );
+ } else {
+ needsWork.push(plugin);
+ }
+ }
+ return needsWork;
+}
+
+/**
+ * Drain a `mapConcurrent` outcome list: record errors, merge configs into
+ * the global config, log success lines. Pulled out of `runInstallPipeline`
+ * to keep that orchestrator small enough to read top-to-bottom.
+ */
+function applyInstallOutcomes(
+ results: ReadonlyArray>,
+ globalConfig: Record,
+ errors: string[],
+): void {
+ for (const outcome of results) {
+ if (!outcome.ok) {
+ errors.push(`${outcome.item.package}: ${outcome.error.message}`);
+ log(`\t==> ERROR: ${outcome.item.package}: ${outcome.error.message}`);
+ continue;
+ }
+ const { value, item } = outcome;
+ const merged = mergeConfigSafely(
+ value.pluginConfig,
+ globalConfig,
+ item.package,
+ errors,
+ );
+ if (merged && value.pluginPath) {
+ log(`\t==> Installed ${item.package}`);
+ }
+ }
+}
+
+/**
+ * Merge `pluginConfig` into `globalConfig` if it is a plain object. Returns
+ * `false` and pushes onto `errors` when `deepMerge` raises a key conflict —
+ * the caller uses this to skip the "Installed" success log so the operator
+ * sees only the error line for the affected plugin.
+ */
+function mergeConfigSafely(
+ pluginConfig: Record | undefined,
+ globalConfig: Record,
+ pkg: string,
+ errors: string[],
+): boolean {
+ if (!isPlainObject(pluginConfig)) return true;
+ try {
+ deepMerge(pluginConfig, globalConfig);
+ return true;
+ } catch (err) {
+ errors.push(`${pkg}: ${(err as Error).message}`);
+ return false;
+ }
+}
+
+/**
+ * Cheap synchronous check: a plugin is "definitely" a no-op when its hash
+ * is already on disk, the user did not force a re-download, and the pull
+ * policy doesn't demand a remote-digest comparison. ALWAYS-pull plugins
+ * still go through the regular install path because they need a
+ * `skopeo inspect` to compare local vs remote digest.
+ *
+ * Type guard: narrows `plugin.plugin_hash` to a non-undefined `string`
+ * inside the `if (definitelyNoOp(...))` branch so the caller does not
+ * need a `as string` cast on the subsequent `installed.delete` call.
+ */
+function definitelyNoOp(
+ plugin: Plugin,
+ installed: Map,
+): plugin is Plugin & { plugin_hash: string } {
+ if (!plugin.plugin_hash || !installed.has(plugin.plugin_hash)) return false;
+ if (plugin.forceDownload) return false;
+ return effectivePullPolicy(plugin) !== PullPolicy.ALWAYS;
+}
+
+async function cleanupRemoved(
+ root: string,
+ installed: Map,
+): Promise {
+ for (const [, dir] of installed) {
+ const pluginDir = path.join(root, dir);
+ log(`\n======= Removing obsolete plugin ${dir}`);
+ await fs.rm(pluginDir, { recursive: true, force: true });
+ }
+}
+
+async function readInstalledPluginHashes(
+ root: string,
+): Promise