diff --git a/.changeset/config.json b/.changeset/config.json index c9a9fd515..613f398c9 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -28,7 +28,9 @@ "@openchoreo/backstage-plugin-thunder-idp-client-node", "@openchoreo/backstage-plugin-catalog-backend-module-openchoreo-users", "@openchoreo/cell-diagram", - "@openchoreo/backstage-portal-backend" + "@openchoreo/backstage-portal-backend", + "@openchoreo/backstage-portal-app", + "@openchoreo/create-portal" ] ], "access": "public", diff --git a/.changeset/create-portal-cli.md b/.changeset/create-portal-cli.md new file mode 100644 index 000000000..ad7fc380e --- /dev/null +++ b/.changeset/create-portal-cli.md @@ -0,0 +1,11 @@ +--- +'@openchoreo/create-portal': minor +--- + +New `npx @openchoreo/create-portal` CLI that scaffolds a custom OpenChoreo +Portal: a thin Backstage app on the published portal packages, pinned to one +release. The template is rendered from the live monorepo at pack time +(private assistant wiring stripped, `workspace:^` ranges pinned to the +lockstep release version), ships inside the CLI tarball, and is pushed to the +`openchoreo/portal-template` repo per release as the `git merge` upgrade +base. diff --git a/.changeset/portal-app-publishable.md b/.changeset/portal-app-publishable.md new file mode 100644 index 000000000..8595a8242 --- /dev/null +++ b/.changeset/portal-app-publishable.md @@ -0,0 +1,21 @@ +--- +'@openchoreo/backstage-portal-app': minor +'@openchoreo/backstage-plugin-react': minor +'@openchoreo/backstage-plugin': patch +'@openchoreo/backstage-plugin-openchoreo-ci': patch +--- + +Make `@openchoreo/backstage-portal-app` publishable. The portal shell no +longer depends on the private Portal Assistant plugin: the assistant +integration contract (`portalAssistantIntegrationApiRef` / +`usePortalAssistant`, re-exported by the shell) now lives in +`@openchoreo/backstage-plugin-react`, and the stock portal app injects the +assistant through it via `createPortalApp({ features })`, mirroring how the +backend adds the assistant outside `portalBackendFeatures`. Without a +registered integration every slot renders nothing. + +This also restores the two assistant surfaces dropped by the NFS entity-page +migration: the component Overview and Build tabs mount the +`BuildFailureNotifier` slot again, and the deploy panel's investigate action +falls back to the integration's `renderInvestigateAction` when no prop is +passed. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ea0af294..49807f053 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -242,9 +242,83 @@ jobs: echo "Published ${published}, skipped ${skipped}." + publish-template: + name: Verify scaffold and publish template + needs: [build, publish-npm] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + RELEASE_TAG: ${{ needs.build.outputs.release-tag }} + IS_PRERELEASE: ${{ needs.build.outputs.is-prerelease }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: 'yarn' + + - name: Install dependencies + # This job runs the create-portal CLI from the repo checkout, which + # needs the workspace's node_modules. It holds no OIDC token (npm + # publishing already finished in `publish-npm`), so dependency + # lifecycle scripts here cannot reach a publish credential. + run: yarn install --immutable + + - name: Render the portal template + # templates/ is gitignored (rendered at pack time, not committed), so + # a fresh checkout must render it before the CLI can run. + run: yarn workspace @openchoreo/create-portal generate-template + + # Runs AFTER publish (the scaffold's pinned ^X.Y.Z versions must exist + # on the registry) and BEFORE the template push / Docker retag, so a + # broken scaffold aborts the release before anything user-visible moves. + - name: Smoke-test the portal scaffold + run: | + node packages/create-portal/bin/create-portal \ + --name openchoreo-portal --path /tmp/verify-portal --skip-install + cd /tmp/verify-portal + yarn install --no-immutable + yarn tsc + + - name: Push template to openchoreo/portal-template + env: + TEMPLATE_PUSH_TOKEN: ${{ secrets.PORTAL_TEMPLATE_PUSH_TOKEN }} + run: | + if [ -z "${TEMPLATE_PUSH_TOKEN}" ]; then + echo "::warning::PORTAL_TEMPLATE_PUSH_TOKEN not set — skipping the portal-template publish" + exit 0 + fi + node packages/create-portal/bin/create-portal \ + --name openchoreo-portal --path /tmp/portal-template-render --skip-install + rm -rf /tmp/portal-template-render/.git + git clone "https://x-access-token:${TEMPLATE_PUSH_TOKEN}@github.com/openchoreo/portal-template.git" /tmp/portal-template-repo + # Release retries re-run this step; the tag from the first attempt + # already exists, so skip instead of failing the whole release. + if git -C /tmp/portal-template-repo rev-parse -q --verify "refs/tags/${RELEASE_TAG}" > /dev/null; then + echo "::notice::portal-template already has ${RELEASE_TAG} — skipping the template publish" + exit 0 + fi + rsync -a --delete --exclude .git /tmp/portal-template-render/ /tmp/portal-template-repo/ + cd /tmp/portal-template-repo + git add -A + git -c user.name=openchoreo-bot -c user.email=bot@openchoreo.dev \ + commit -m "chore: portal template for ${RELEASE_TAG}" --allow-empty + git tag "${RELEASE_TAG}" + # Stable releases advance main; prereleases advance a force-pushed + # `next` branch so main only ever contains released templates. + if [ "${IS_PRERELEASE}" = "true" ]; then + git push --force origin HEAD:next "${RELEASE_TAG}" + else + git push origin HEAD:main "${RELEASE_TAG}" + fi + retag-image: name: Retag release image - needs: [build, publish-npm] + needs: [build, publish-npm, publish-template] runs-on: ubuntu-24.04 permissions: packages: write diff --git a/.gitignore b/.gitignore index 66b8a7667..81f9f4835 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,6 @@ site e2e-test-report/ -.backstage-db/ \ No newline at end of file +.backstage-db/ +# create-portal rendered template (generated at prepack/CI; templates-src/ is the source) +packages/create-portal/templates/ diff --git a/.prettierignore b/.prettierignore index 45890be5f..e70203dc8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,5 @@ dist-types coverage .vscode storybook-static +packages/create-portal/templates/ +packages/create-portal/templates-src/ diff --git a/app-config.local.yaml.example b/app-config.local.yaml.example index a61dd07e2..54ed43fb4 100644 --- a/app-config.local.yaml.example +++ b/app-config.local.yaml.example @@ -76,7 +76,7 @@ openchoreo: secretManagement: enabled: true # Set to false to hide Secrets settings tab assistant: - enabled: true # Opt-in. Set to true after deploying perch-agent and OPENCHOREO_PERCH_AGENT_URL + enabled: false # Opt-in. Set to true after deploying the Portal Assistant and uncommenting `portalAssistantUrl` above # Thunder IDP configuration (k3d cluster) thunder: diff --git a/app-config.production.yaml b/app-config.production.yaml index a3042d605..0f78a452c 100644 --- a/app-config.production.yaml +++ b/app-config.production.yaml @@ -148,6 +148,13 @@ auth: dangerouslyAllowOutsideDevelopment: ${BACKSTAGE_GUEST_DANGEROUSLY_ALLOW_OUTSIDE_DEVELOPMENT} catalog: + # The Dockerfile ships this file AS /app/app-config.yaml (it replaces the + # base config, not layers on it), so the global kind allowlist must be + # restated here — without it Backstage's defaults apply, which exclude the + # OpenChoreo kinds. + rules: + - allow: + [Component, System, API, Resource, Location, User, Group, Environment] locations: # Placeholder groups for Backstage entity owner references - type: file diff --git a/examples/template/template.yaml b/examples/template/template.yaml index cfba98e87..3b5d6f8f0 100644 --- a/examples/template/template.yaml +++ b/examples/template/template.yaml @@ -20,6 +20,10 @@ spec: title: Name type: string description: Unique name of the component + # Rendered into content/package.json and content/index.js — keep it + # package-name safe. + pattern: '^[a-z0-9][a-z0-9-]*$' + maxLength: 63 ui:autofocus: true ui:options: rows: 5 diff --git a/packages/app/e2e-tests/a11y.spec.ts b/packages/app/e2e-tests/a11y.spec.ts index 88066e0fa..ed03554f2 100644 --- a/packages/app/e2e-tests/a11y.spec.ts +++ b/packages/app/e2e-tests/a11y.spec.ts @@ -32,6 +32,19 @@ const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice']; // is present. If neither button shows up, the session is already live // and we proceed. async function dismissSignIn(page: import('@playwright/test').Page) { + // Bounded wait for either a sign-in control or the post-login sidebar — + // `isVisible()` returns immediately, so checking straight after `goto` + // races SPA hydration and could skip the sign-in click entirely. + await page + .getByRole('button', { name: /^(Enter|Sign In)$/ }) + .or( + page.locator( + 'nav[aria-label*="sidebar" i], a[href="/"][aria-label="Home"]', + ), + ) + .first() + .waitFor({ state: 'visible', timeout: 15_000 }) + .catch(() => undefined); for (const name of ['Enter', 'Sign In'] as const) { const btn = page.getByRole('button', { name }); if (await btn.isVisible().catch(() => false)) { diff --git a/packages/app/package.json b/packages/app/package.json index 71883bb1f..f2fc9281b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -20,7 +20,11 @@ }, "dependencies": { "@backstage/cli": "^0.36.2", + "@backstage/core-plugin-api": "^1.12.6", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/ui": "^0.15.0", + "@openchoreo/backstage-plugin-openchoreo-observability": "workspace:^", + "@openchoreo/backstage-plugin-openchoreo-portal-assistant": "workspace:^", "@openchoreo/backstage-portal-app": "workspace:^", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index fb3ccdc28..2b4a863b7 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,3 +1,4 @@ import { createPortalApp } from '@openchoreo/backstage-portal-app'; +import { assistantFeature } from './assistant'; -export default createPortalApp().createRoot(); +export default createPortalApp({ features: [assistantFeature] }).createRoot(); diff --git a/packages/app/src/assistant.test.tsx b/packages/app/src/assistant.test.tsx new file mode 100644 index 000000000..e5563b761 --- /dev/null +++ b/packages/app/src/assistant.test.tsx @@ -0,0 +1,30 @@ +import { + AssistantDrawerProvider, + FailedBuildSnackbar, + PerchAgentClient, +} from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; +import { assistantFeature, assistantIntegration } from './assistant'; + +describe('assistant wiring', () => { + it('fills every shell integration slot', () => { + expect(assistantIntegration.AppWrapper).toBe(AssistantDrawerProvider); + expect(assistantIntegration.BuildFailureNotifier).toBe(FailedBuildSnackbar); + expect(assistantIntegration.renderInvestigateAction).toEqual( + expect.any(Function), + ); + }); + + it('constructs the perch client the api factory registers', () => { + // Replaces the factory coverage that lived in portal-app's apis.test.ts + // before the assistant moved out of the published shell. + const client = new PerchAgentClient({ + discoveryApi: { getBaseUrl: async () => 'http://localhost' } as any, + fetchApi: { fetch: (() => undefined) as any } as any, + }); + expect(client).toBeInstanceOf(PerchAgentClient); + }); + + it('exposes the assistant as an installable frontend feature', () => { + expect(assistantFeature).toBeDefined(); + }); +}); diff --git a/packages/app/src/assistant.tsx b/packages/app/src/assistant.tsx new file mode 100644 index 000000000..876513eda --- /dev/null +++ b/packages/app/src/assistant.tsx @@ -0,0 +1,78 @@ +import { + ApiBlueprint, + createFrontendModule, +} from '@backstage/frontend-plugin-api'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { + portalAssistantIntegrationApiRef, + PortalAssistantIntegration, +} from '@openchoreo/backstage-portal-app'; +import { LogRowActionBlueprint } from '@openchoreo/backstage-plugin-openchoreo-observability/alpha'; +import { + AssistantDrawerProvider, + FailedBuildSnackbar, + InvestigateDependencyButton, + InvestigateLogButton, + PerchAgentClient, + perchAgentApiRef, +} from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; + +// What the assistant contributes to the shell's optional integration slots: +// the global drawer provider, the failed-build prompt on entity Overview and +// Build tabs, and the deploy panel's investigate action. +export const assistantIntegration: PortalAssistantIntegration = { + AppWrapper: AssistantDrawerProvider, + BuildFailureNotifier: FailedBuildSnackbar, + renderInvestigateAction: scope => , +}; + +/** + * Wires the Portal Assistant (Perch) into the portal shell. The assistant is + * a PRIVATE plugin — deliberately not part of the published + * `@openchoreo/backstage-portal-app` bundle — so the stock portal injects it + * here, mirroring how packages/backend adds the assistant backend outside + * `portalBackendFeatures`. A custom portal scaffold simply omits this module. + */ +export const assistantFeature = createFrontendModule({ + pluginId: 'app', + extensions: [ + // Perch agent client. NOTE: ``perchAgentApiRef`` is also declared on + // ``openchoreoPerchPlugin.apis``, but that declaration is never picked + // up at runtime — the plugin exports plain React components and never + // registers a routable or component extension, so the plugin loader + // never visits its ``apis`` array. This factory is the one actually + // wired in; removing it causes ``NotImplementedError: No implementation + // available for apiRef{plugin.openchoreo-portal-assistant.service}`` in + // AssistantDrawerProvider. + ApiBlueprint.make({ + name: 'perch-agent', + params: defineParams => + defineParams({ + api: perchAgentApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new PerchAgentClient({ discoveryApi, fetchApi }), + }), + }), + ApiBlueprint.make({ + name: 'assistant-integration', + params: defineParams => + defineParams({ + api: portalAssistantIntegrationApiRef, + deps: {}, + factory: () => assistantIntegration, + }), + }), + // Per-row "investigate" action for the observability runtime-logs tables. + // Registered here (not inside the observability plugin) so observability + // owns no dependency on perch — the host composes the two. + LogRowActionBlueprint.make({ + name: 'investigate-log', + params: { + renderer: (log, getLogsSnapshot) => ( + + ), + }, + }), + ], +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 337c305b9..5011450e5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -32,12 +32,15 @@ backend.add(portalBackendFeatures); // For production, config is in app-config.production.yaml with Helm-injected env vars // backend.add(import('@immobiliarelabs/backstage-plugin-gitlab-backend')); +// portal-template:strip-start // Portal Assistant backend — forwards Portal Assistant frontend traffic to the // portal-assistant service in the OpenChoreo control plane. Plugin // self-disables when openchoreo.portalAssistantUrl is not set. -// (Private package — deliberately not part of the published portal bundle.) +// (Private package — deliberately not part of the published portal bundle; +// the strip markers let the create-portal template generator drop this block.) backend.add( import('@openchoreo/backstage-plugin-openchoreo-portal-assistant-backend'), ); +// portal-template:strip-end backend.start(); diff --git a/packages/create-portal/.eslintrc.js b/packages/create-portal/.eslintrc.js new file mode 100644 index 000000000..89880d8cf --- /dev/null +++ b/packages/create-portal/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + ignorePatterns: ['templates/**', 'templates-src/**', 'scripts/**'], + rules: { + 'no-console': 0, + }, +}); diff --git a/packages/create-portal/README.md b/packages/create-portal/README.md new file mode 100644 index 000000000..0a94b8083 --- /dev/null +++ b/packages/create-portal/README.md @@ -0,0 +1,52 @@ +# @openchoreo/create-portal + +Scaffolds a **custom OpenChoreo Portal**: a thin Backstage app that depends on +the published `@openchoreo/backstage-portal-app` and +`@openchoreo/backstage-portal-backend` packages, pinned to one portal release. +You own the generated repo — add plugins, replace pages, re-brand — and +upgrading to the next OpenChoreo release is a single lockstep version bump +plus a small skeleton diff. + +## Usage + +```sh +npx @openchoreo/create-portal +``` + +Flags: + +| Flag | Meaning | +| ----------------------- | ---------------------------------------------------------------------------------------------- | +| `--name ` | Portal name (lowercase, digits, dashes); skips the prompt. `OPENCHOREO_PORTAL_NAME` works too. | +| `--path ` | Scaffold into an existing directory instead of `./`. | +| `--registry ` | npm registry the scaffold resolves `@openchoreo/*` from (default: npmjs). | +| `--skip-install` | Skip `yarn install` + `yarn tsc` after scaffolding. | +| `--template-path ` | Use an external template directory instead of the built-in one. | + +If `--registry` points at a private registry, set an `npmAuthToken` for the +`openchoreo` scope in your user-level `~/.yarnrc.yml` before installing — +don't commit registry tokens to the scaffold's `.yarnrc.yml`. + +The generated portal's README covers local development, adding plugins, +branding, image builds, and the upgrade flow against the per-release +[`openchoreo/portal-template`](https://github.com/openchoreo/portal-template) +repo. + +## How the template stays current + +The template is **rendered from the live monorepo** by +`scripts/generate-template.js` (run automatically at `prepack`, so every +published CLI version carries a template matching its release): + +- Most files copy verbatim from the repo (configs, `packages/app` assets, + scaffolder templates) — monorepo changes flow through automatically. +- `package.json` files are transformed: private packages (the Portal + Assistant) are stripped and `workspace:^` ranges are pinned to the CLI's + own version — correct because releases stamp every workspace to one + version. +- A few files are owned overrides in `templates-src/` (`App.tsx` without the + assistant, the scaffold README, the upgrade anchor). + +Invariants (no private packages, no unpinned `workspace:` ranges, scaffolder +templates untouched, every `.hbs` renders) are enforced by the generator +itself and by `src/generateTemplate.test.ts`. diff --git a/packages/create-portal/bin/create-portal b/packages/create-portal/bin/create-portal new file mode 100755 index 000000000..e216ea1dd --- /dev/null +++ b/packages/create-portal/bin/create-portal @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-syntax */ +const path = require('node:path'); + +// Figure out whether we're running inside the backstage-plugins repo (src/ +// present, run through the CLI's on-the-fly TS transform) or as an installed +// package (built dist/ is the entry point). +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (!isLocal) { + require('..'); +} else { + require('@backstage/cli/config/nodeTransform.cjs'); + require('../src'); +} diff --git a/packages/create-portal/package.json b/packages/create-portal/package.json new file mode 100644 index 000000000..eff210e7b --- /dev/null +++ b/packages/create-portal/package.json @@ -0,0 +1,45 @@ +{ + "name": "@openchoreo/create-portal", + "version": "0.1.0", + "description": "CLI that scaffolds a custom OpenChoreo Portal on the published @openchoreo/* portal packages", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/openchoreo/backstage-plugins.git", + "directory": "packages/create-portal" + }, + "backstage": { + "role": "cli" + }, + "publishConfig": { + "access": "public" + }, + "main": "dist/index.cjs.js", + "bin": "bin/create-portal", + "files": [ + "bin", + "dist", + "templates" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "generate-template": "node scripts/generate-template.js", + "lint": "backstage-cli package lint", + "prepack": "node scripts/generate-template.js && backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^12.1.0", + "fs-extra": "^11.2.0", + "handlebars": "^4.7.8", + "inquirer": "^8.2.6" + }, + "devDependencies": { + "@backstage/cli": "^0.36.2", + "@types/fs-extra": "^11.0.4", + "@types/inquirer": "^8.2.10" + } +} diff --git a/packages/create-portal/scripts/generate-template.js b/packages/create-portal/scripts/generate-template.js new file mode 100644 index 000000000..da684f2ab --- /dev/null +++ b/packages/create-portal/scripts/generate-template.js @@ -0,0 +1,413 @@ +#!/usr/bin/env node + +/** + * Portal template generator + * + * Renders `templates/default-portal/` — the scaffold that `create-portal` + * ships inside its npm tarball and that the release workflow pushes to the + * `openchoreo/portal-template` repo — from the LIVE monorepo in three passes: + * + * 1. Verbatim copies of monorepo files (configs, assets, scaffolder + * templates). Self-healing: monorepo edits flow into the template on the + * next generation with no manual sync. + * 2. Transforms of monorepo files (package.jsons, backend index) that pin + * versions and strip the private Portal Assistant wiring. + * 3. Owned overrides from `templates-src/` for files that deliberately + * differ from the monorepo (App.tsx without the assistant, README, + * upgrade anchor). + * + * Version pinning: every `workspace:^` dependency on an `@openchoreo/*` + * package becomes `^`. This is correct because + * the release process (scripts/set-version.js) stamps every workspace to one + * exact version before publish — one number identifies the whole release. + * + * The generator fails loudly on drift: missing strip markers in the backend + * index, or any private-package reference surviving into the output. + */ + +const fs = require('fs'); +const path = require('path'); + +const PKG_DIR = path.resolve(__dirname, '..'); +const REPO_ROOT = path.resolve(PKG_DIR, '../..'); +const TEMPLATES_SRC = path.join(PKG_DIR, 'templates-src'); + +const ASSISTANT_PACKAGE_PREFIX = + '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; +const STRIP_START = '// portal-template:strip-start'; +const STRIP_END = '// portal-template:strip-end'; + +// Private workspaces that stay part of the scaffold itself (they are the +// scaffold) — everything else that is `private: true` gets dropped. +const SCAFFOLD_LOCAL_WORKSPACES = new Set(['app', 'backend']); + +// Monorepo root files copied verbatim. +const ROOT_FILES = [ + 'backstage.json', + 'tsconfig.json', + 'playwright.config.ts', + 'app-config.yaml', + 'app-config.production.yaml', + 'app-config.local.yaml.example', + '.dockerignore', + '.eslintignore', + '.eslintrc.js', + '.prettierignore', +]; + +// Monorepo directories copied verbatim. `templates/` are Backstage +// scaffolder templates full of `${{ parameters.* }}` — they must NEVER get an +// `.hbs` suffix or handlebars would mangle them at scaffold time. +const ROOT_DIRS = ['catalog-entities', 'examples', 'templates']; + +// Root package.json scripts that make sense in a scaffolded portal (release +// and changeset machinery stays behind in the monorepo). +const ROOT_SCRIPT_ALLOWLIST = [ + 'start', + 'build:backend', + 'build:all', + 'build-image', + 'tsc', + 'tsc:full', + 'clean', + 'test', + 'test:all', + 'test:e2e', + 'test:e2e:a11y', + 'fix', + 'lint:all', + 'prettier:check', + 'prettier:write', +]; + +// packages/app source files NOT copied: the owned App.tsx/App.test.tsx from +// templates-src replace the monorepo ones (which wire the private assistant), +// and the assistant module itself is dropped entirely. +const APP_SRC_EXCLUDE = new Set([ + 'App.tsx', + 'App.test.tsx', + 'assistant.tsx', + 'assistant.test.tsx', +]); + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function writeFileEnsured(dest, contents) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, contents); +} + +function copyFileEnsured(src, dest) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); +} + +function copyDir(src, dest, { exclude = () => false } = {}) { + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (exclude(entry.name)) continue; + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(from, to, {}); + } else { + copyFileEnsured(from, to); + } + } +} + +function listFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listFiles(full)); + } else { + out.push(full); + } + } + return out; +} + +/** Map of workspace package name -> { private } across packages/* plugins/*. */ +function readWorkspaceIndex() { + const index = new Map(); + for (const group of ['packages', 'plugins']) { + const groupDir = path.join(REPO_ROOT, group); + for (const dir of fs.readdirSync(groupDir)) { + const manifestPath = path.join(groupDir, dir, 'package.json'); + if (fs.existsSync(manifestPath)) { + const manifest = readJson(manifestPath); + index.set(manifest.name, { private: manifest.private === true }); + } + } + } + return index; +} + +/** + * Rewrites a dependency block: drops deps on private workspaces (except the + * scaffold's own local ones) and pins remaining `workspace:^` deps to the + * CLI's version. + */ +function transformDeps(deps, workspaceIndex, cliVersion) { + if (!deps) return deps; + const out = {}; + for (const [name, range] of Object.entries(deps)) { + const workspace = workspaceIndex.get(name); + if (workspace?.private && !SCAFFOLD_LOCAL_WORKSPACES.has(name)) { + continue; + } + if (typeof range === 'string' && range.startsWith('workspace:')) { + out[name] = SCAFFOLD_LOCAL_WORKSPACES.has(name) + ? 'workspace:^' + : `^${cliVersion}`; + } else { + out[name] = range; + } + } + return out; +} + +function transformPackageManifest(manifest, workspaceIndex, cliVersion) { + const out = { ...manifest, version: '0.1.0' }; + delete out.repository; + for (const field of ['dependencies', 'devDependencies']) { + if (out[field]) { + out[field] = transformDeps(out[field], workspaceIndex, cliVersion); + } + } + return out; +} + +function toJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function generateTemplate({ + outputDir = path.join(PKG_DIR, 'templates', 'default-portal'), +} = {}) { + const cliVersion = readJson(path.join(PKG_DIR, 'package.json')).version; + const workspaceIndex = readWorkspaceIndex(); + + fs.rmSync(outputDir, { recursive: true, force: true }); + fs.mkdirSync(outputDir, { recursive: true }); + + // --- Pass 1: verbatim copies ------------------------------------------- + + for (const file of ROOT_FILES) { + const src = path.join(REPO_ROOT, file); + if (!fs.existsSync(src)) { + throw new Error( + `Expected monorepo file missing: ${file} — update the generator manifest`, + ); + } + copyFileEnsured(src, path.join(outputDir, file)); + } + + // npm pack strips bare .gitignore files from tarballs, so ship it with an + // .hbs suffix that the scaffolding step removes (same trick as + // @backstage/create-app). + copyFileEnsured( + path.join(REPO_ROOT, '.gitignore'), + path.join(outputDir, '.gitignore.hbs'), + ); + + for (const dir of ROOT_DIRS) { + copyDir(path.join(REPO_ROOT, dir), path.join(outputDir, dir)); + } + copyDir( + path.join(REPO_ROOT, '.yarn/releases'), + path.join(outputDir, '.yarn/releases'), + ); + + // packages/app — everything except the assistant wiring and the files the + // owned overrides replace. + const appDir = path.join(REPO_ROOT, 'packages/app'); + copyDir(path.join(appDir, 'src'), path.join(outputDir, 'packages/app/src'), { + exclude: name => APP_SRC_EXCLUDE.has(name), + }); + copyDir( + path.join(appDir, 'public'), + path.join(outputDir, 'packages/app/public'), + ); + copyDir( + path.join(appDir, 'e2e-tests'), + path.join(outputDir, 'packages/app/e2e-tests'), + ); + for (const file of ['.eslintrc.js', '.eslintignore']) { + const src = path.join(appDir, file); + if (fs.existsSync(src)) { + copyFileEnsured(src, path.join(outputDir, 'packages/app', file)); + } + } + + // packages/backend — sources are transformed below; configs and the + // Dockerfile copy verbatim (npmjs needs no auth, so the monorepo image + // build works unchanged in a scaffold). + const backendDir = path.join(REPO_ROOT, 'packages/backend'); + for (const file of ['.eslintrc.js', 'README.md', 'Dockerfile']) { + const src = path.join(backendDir, file); + if (fs.existsSync(src)) { + copyFileEnsured(src, path.join(outputDir, 'packages/backend', file)); + } + } + + // --- Pass 2: transforms ------------------------------------------------- + + // Root package.json -> package.json.hbs ({{name}} filled at scaffold time). + const rootManifest = readJson(path.join(REPO_ROOT, 'package.json')); + const templateRoot = { + ...rootManifest, + name: '{{name}}', + version: '0.1.0', + }; + delete templateRoot.repository; + delete templateRoot['lint-staged']; + templateRoot.scripts = Object.fromEntries( + ROOT_SCRIPT_ALLOWLIST.filter(k => rootManifest.scripts[k]).map(k => [ + k, + rootManifest.scripts[k], + ]), + ); + if (templateRoot.devDependencies) { + templateRoot.devDependencies = { ...templateRoot.devDependencies }; + delete templateRoot.devDependencies['@changesets/cli']; + } + writeFileEnsured( + path.join(outputDir, 'package.json.hbs'), + toJson(templateRoot), + ); + + // Workspace manifests with pinned @openchoreo/* versions. + writeFileEnsured( + path.join(outputDir, 'packages/app/package.json'), + toJson( + transformPackageManifest( + readJson(path.join(appDir, 'package.json')), + workspaceIndex, + cliVersion, + ), + ), + ); + writeFileEnsured( + path.join(outputDir, 'packages/backend/package.json'), + toJson( + transformPackageManifest( + readJson(path.join(backendDir, 'package.json')), + workspaceIndex, + cliVersion, + ), + ), + ); + + // Backend index with the private assistant block stripped. + const backendIndexPath = path.join(backendDir, 'src/index.ts'); + const backendIndex = fs.readFileSync(backendIndexPath, 'utf8'); + if ( + !backendIndex.includes(STRIP_START) || + !backendIndex.includes(STRIP_END) + ) { + throw new Error( + `Strip markers not found in ${backendIndexPath} — the generator can no ` + + `longer separate the private assistant backend from the scaffold. ` + + `Restore the "${STRIP_START}" / "${STRIP_END}" comments.`, + ); + } + const strippedIndex = backendIndex + .replace( + new RegExp(`[\\t ]*${STRIP_START}[\\s\\S]*?${STRIP_END}\\n?`, 'g'), + '', + ) + .replace(/\n{3,}/g, '\n\n'); + writeFileEnsured( + path.join(outputDir, 'packages/backend/src/index.ts'), + strippedIndex, + ); + + // .yarnrc.yml -> .yarnrc.yml.hbs: the monorepo config plus the scoped + // registry knob ({{registry}} filled at scaffold time). Derived from the + // live file so a yarn version bump flows through automatically. + // + // The monorepo age-gates freshly published npm versions + // (npmMinimalAgeGate), but the scaffold pins @openchoreo/* versions + // published the same day as the CLI — pre-approve the scope so a + // release-day `yarn install` (and the release workflow's scaffold smoke + // test, minutes after publish) isn't refused. Every other dependency + // keeps the age gate. + let yarnrc = fs + .readFileSync(path.join(REPO_ROOT, '.yarnrc.yml'), 'utf8') + .trimEnd(); + if (/^npmPreapprovedPackages:$/m.test(yarnrc)) { + yarnrc = yarnrc.replace( + /^npmPreapprovedPackages:$/m, + `npmPreapprovedPackages:\n - '@openchoreo/*'`, + ); + } else if (yarnrc.includes('npmMinimalAgeGate')) { + yarnrc += `\n\nnpmPreapprovedPackages:\n - '@openchoreo/*'`; + } + writeFileEnsured( + path.join(outputDir, '.yarnrc.yml.hbs'), + `${yarnrc}\n\n` + + `npmScopes:\n` + + ` openchoreo:\n` + + ` npmRegistryServer: '{{registry}}'\n`, + ); + + // --- Pass 3: owned overrides ------------------------------------------- + + copyDir(TEMPLATES_SRC, outputDir); + + // --- Self-checks -------------------------------------------------------- + + const violations = []; + for (const file of listFiles(outputDir)) { + if (path.relative(outputDir, file).startsWith('.yarn/')) continue; + const contents = fs.readFileSync(file, 'utf8'); + if (contents.includes(ASSISTANT_PACKAGE_PREFIX)) { + violations.push( + `${path.relative(outputDir, file)}: references the private ` + + `${ASSISTANT_PACKAGE_PREFIX} package`, + ); + } + if ( + file.endsWith('package.json') && + !path.relative(outputDir, file).startsWith('templates/') + ) { + const manifest = JSON.parse(contents); + for (const field of ['dependencies', 'devDependencies']) { + for (const [name, range] of Object.entries(manifest[field] ?? {})) { + if ( + String(range).startsWith('workspace:') && + !SCAFFOLD_LOCAL_WORKSPACES.has(name) + ) { + violations.push( + `${path.relative(outputDir, file)}: unpinned workspace ` + + `dependency ${name}`, + ); + } + } + } + } + } + if (violations.length > 0) { + throw new Error( + `Generated template failed self-checks:\n ${violations.join('\n ')}`, + ); + } + + return { outputDir, cliVersion }; +} + +module.exports = { generateTemplate }; + +if (require.main === module) { + const { outputDir, cliVersion } = generateTemplate(); + process.stdout.write( + `Rendered portal template (release ${cliVersion}) at ${path.relative( + process.cwd(), + outputDir, + )}\n`, + ); +} diff --git a/packages/create-portal/src/createPortal.ts b/packages/create-portal/src/createPortal.ts new file mode 100644 index 000000000..66a1129f4 --- /dev/null +++ b/packages/create-portal/src/createPortal.ts @@ -0,0 +1,158 @@ +import chalk from 'chalk'; +import { OptionValues } from 'commander'; +import inquirer, { Answers } from 'inquirer'; +import { resolve as resolvePath } from 'node:path'; +import fs from 'fs-extra'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { version } from '../package.json'; +import { + Task, + buildAppTask, + checkAppExistsTask, + checkPathExistsTask, + createTemporaryPortalFolderTask, + moveAppTask, + templatingTask, + tryInitGitRepository, +} from './lib/tasks'; + +const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +async function resolveName(opts: OptionValues): Promise { + const preset = opts.name ?? process.env.OPENCHOREO_PORTAL_NAME; + if (preset) { + if (!NAME_PATTERN.test(preset)) { + throw new Error( + 'Portal name must be lowercase and contain only letters, digits, and dashes', + ); + } + return preset; + } + + const answers: Answers = await inquirer.prompt([ + { + type: 'input', + name: 'name', + default: 'openchoreo-portal', + message: chalk.blue('Enter a name for the portal [required]'), + validate: (value: string) => { + if (!value) { + return chalk.red('Please enter a name for the portal'); + } else if (!NAME_PATTERN.test(value)) { + return chalk.red( + 'Portal name must be lowercase and contain only letters, digits, and dashes.', + ); + } + return true; + }, + }, + ]); + return answers.name; +} + +export async function createPortal(opts: OptionValues): Promise { + const name = await resolveName(opts); + + // Resolves from both src/ (repo run, via the bin shim's TS transform) and + // dist/ (installed package) — the templates directory sits beside either. + // eslint-disable-next-line no-restricted-syntax + const builtInTemplate = resolvePath(__dirname, '../templates/default-portal'); + const templateDir = opts.templatePath + ? resolvePath(process.cwd(), opts.templatePath) + : builtInTemplate; + + if (!(await fs.pathExists(templateDir))) { + const hint = opts.templatePath + ? '' + : ' When running from the backstage-plugins repo, generate it first with `yarn workspace @openchoreo/create-portal generate-template`.'; + throw new Error(`Portal template not found at ${templateDir}.${hint}`); + } + + const appDir = opts.path + ? resolvePath(process.cwd(), opts.path) + : resolvePath(process.cwd(), name); + + // Rendered into the .hbs template files. `version` pins the scaffold to + // this CLI's release: all @openchoreo/* packages version in lockstep, so + // one number identifies the whole release. + const context = { + name, + version, + registry: opts.registry, + }; + + Task.log(); + Task.log(`Creating your custom OpenChoreo Portal (release ${version})…`); + + try { + if (opts.path) { + Task.section('Checking that supplied path exists'); + await checkPathExistsTask(appDir); + + Task.section('Preparing files'); + await templatingTask(templateDir, appDir, context); + } else { + Task.section('Checking if the directory is available'); + await checkAppExistsTask(process.cwd(), name); + + Task.section('Creating a temporary portal directory'); + const tempDir = await createTemporaryPortalFolderTask(name); + + Task.section('Preparing files'); + await templatingTask(templateDir, tempDir, context); + + Task.section('Moving to final location'); + await moveAppTask(tempDir, appDir, name); + } + + // Seed the local dev config so `yarn start` works immediately; the + // example documents every environment variable it expects. + const localConfigExample = resolvePath( + appDir, + 'app-config.local.yaml.example', + ); + if (await fs.pathExists(localConfigExample)) { + await Task.forItem('creating', 'app-config.local.yaml', async () => { + await fs.copyFile( + localConfigExample, + resolvePath(appDir, 'app-config.local.yaml'), + ); + }); + } + + if (await tryInitGitRepository(appDir)) { + await Task.forItem('init', 'git repository', async () => {}); + } + + if (!opts.skipInstall) { + Task.section('Installing dependencies'); + await buildAppTask(appDir); + } + + Task.log(); + Task.log(chalk.green(`🥇 Successfully created ${chalk.cyan(name)}`)); + + Task.section('All set! Now you might want to'); + if (opts.skipInstall) { + Task.log( + ` Install the dependencies: ${chalk.cyan( + `cd ${opts.path ?? name} && yarn install`, + )}`, + ); + } + Task.log( + ` Run the portal: ${chalk.cyan( + `cd ${opts.path ?? name} && yarn start`, + )}`, + ); + Task.log( + ` Read the scaffold README for configuration, Docker builds, and upgrades`, + ); + Task.log(); + Task.exit(); + } catch (error) { + Task.error(String(error)); + Task.error('🔥 Failed to create portal!'); + Task.exit(1); + } +} diff --git a/packages/create-portal/src/generateTemplate.test.ts b/packages/create-portal/src/generateTemplate.test.ts new file mode 100644 index 000000000..0ed674df5 --- /dev/null +++ b/packages/create-portal/src/generateTemplate.test.ts @@ -0,0 +1,199 @@ +import fs from 'fs-extra'; +import handlebars from 'handlebars'; +import os from 'node:os'; +import { join as joinPath, relative as relativePath } from 'node:path'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { version as cliVersion } from '../package.json'; +import { listFilesRecursively } from './lib/tasks'; + +// Plain-JS module so prepack can run it without a build. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { generateTemplate } = require('../scripts/generate-template'); + +const SAMPLE_CONTEXT = { + name: 'test-portal', + version: '9.9.9', + registry: 'https://registry.example.com', +}; + +describe('generateTemplate', () => { + let outputDir: string; + let files: string[] = []; + + beforeAll(async () => { + outputDir = await fs.mkdtemp(joinPath(os.tmpdir(), 'portal-template-')); + generateTemplate({ outputDir }); + files = (await listFilesRecursively(outputDir)).map(f => + relativePath(outputDir, f), + ); + }, 60_000); + + afterAll(async () => { + await fs.rm(outputDir, { recursive: true, force: true }); + }); + + it('renders the expected scaffold skeleton', () => { + for (const expected of [ + 'backstage.json', + 'tsconfig.json', + 'package.json.hbs', + '.gitignore.hbs', + '.yarnrc.yml.hbs', + '.openchoreo-portal.json.hbs', + 'README.md.hbs', + 'app-config.yaml', + 'app-config.production.yaml', + 'app-config.local.yaml.example', + 'catalog-entities/org.yaml', + 'templates/create-openchoreo-componenttype/template.yaml', + 'packages/app/src/App.tsx', + 'packages/app/src/buiOverrides.css', + 'packages/app/public/index.html', + 'packages/app/package.json', + 'packages/backend/package.json', + 'packages/backend/src/index.ts', + 'packages/backend/Dockerfile', + 'plugins/README.md', + ]) { + expect(files).toContain(expected); + } + + // The scaffold ships whatever yarn bundle the monorepo pins — assert + // against the live yarnPath so yarn bumps don't break this test. + const yarnPath = fs + .readFileSync(joinPath(__dirname, '../../../.yarnrc.yml'), 'utf8') + .match(/^yarnPath:\s*(\S+)$/m)?.[1]; + expect(yarnPath).toBeDefined(); + expect(files).toContain(yarnPath); + }); + + it('pins every @openchoreo/* dependency to the CLI version and keeps no stray workspace ranges', async () => { + const badPins: unknown[] = []; + const strayWorkspaceRanges: unknown[] = []; + for (const manifestPath of [ + 'packages/app/package.json', + 'packages/backend/package.json', + ]) { + const manifest = await fs.readJson(joinPath(outputDir, manifestPath)); + for (const field of ['dependencies', 'devDependencies'] as const) { + for (const [name, range] of Object.entries( + manifest[field] ?? {}, + )) { + if (name.startsWith('@openchoreo/') && range !== `^${cliVersion}`) { + badPins.push({ manifestPath, name, range }); + } + if ( + name !== 'app' && + name !== 'backend' && + range.startsWith('workspace:') + ) { + strayWorkspaceRanges.push({ manifestPath, name, range }); + } + } + } + } + expect(badPins).toEqual([]); + expect(strayWorkspaceRanges).toEqual([]); + }); + + it('strips the private assistant everywhere', async () => { + const backendIndex = await fs.readFile( + joinPath(outputDir, 'packages/backend/src/index.ts'), + 'utf8', + ); + expect(backendIndex).not.toContain('portal-assistant'); + expect(backendIndex).not.toContain('portal-template:strip'); + expect(backendIndex).toContain('backend.add(portalBackendFeatures)'); + + for (const file of files.filter(f => !f.startsWith('.yarn/'))) { + const contents = await fs.readFile(joinPath(outputDir, file), 'utf8'); + expect({ file, clean: true }).toEqual({ + file, + clean: !contents.includes( + '@openchoreo/backstage-plugin-openchoreo-portal-assistant', + ), + }); + } + }); + + it('keeps Backstage scaffolder templates untouched by handlebars', async () => { + const template = await fs.readFile( + joinPath( + outputDir, + 'templates/create-openchoreo-componenttype/template.yaml', + ), + 'utf8', + ); + // eslint-disable-next-line no-template-curly-in-string + expect(template).toContain('${{'); + expect(files.filter(f => f.startsWith('templates/'))).not.toContainEqual( + expect.stringMatching(/\.hbs$/), + ); + }); + + it('renders every .hbs file with the scaffold context', async () => { + const hbsFiles = files.filter(f => f.endsWith('.hbs')); + expect(hbsFiles.length).toBeGreaterThan(0); + for (const file of hbsFiles) { + const source = await fs.readFile(joinPath(outputDir, file), 'utf8'); + const render = () => + handlebars.compile(source, { strict: true })(SAMPLE_CONTEXT); + expect(render).not.toThrow(); + } + }); + + it('renders a valid root package.json with the portal name and no release machinery', async () => { + const source = await fs.readFile( + joinPath(outputDir, 'package.json.hbs'), + 'utf8', + ); + const manifest = JSON.parse( + handlebars.compile(source, { strict: true })(SAMPLE_CONTEXT), + ); + expect(manifest.name).toBe('test-portal'); + expect(manifest.workspaces.packages).toEqual(['packages/*', 'plugins/*']); + expect(manifest.scripts['build-image']).toBeDefined(); + expect(manifest.scripts['release:publish']).toBeUndefined(); + expect(manifest.devDependencies['@changesets/cli']).toBeUndefined(); + }); + + it('templates the registry knob and upgrade anchor', async () => { + const yarnrc = handlebars.compile( + await fs.readFile(joinPath(outputDir, '.yarnrc.yml.hbs'), 'utf8'), + { strict: true }, + )(SAMPLE_CONTEXT); + expect(yarnrc).toContain( + "npmRegistryServer: 'https://registry.example.com'", + ); + // npmjs needs no auth; private-registry users add their own token. + expect(yarnrc).not.toContain('npmAuthToken'); + expect(yarnrc).not.toContain('GITHUB_TOKEN'); + + const anchor = JSON.parse( + handlebars.compile( + await fs.readFile( + joinPath(outputDir, '.openchoreo-portal.json.hbs'), + 'utf8', + ), + { strict: true }, + )(SAMPLE_CONTEXT), + ); + expect(anchor).toEqual({ + template: 'openchoreo/portal-template', + release: 'v9.9.9', + createdWith: '@openchoreo/create-portal@9.9.9', + }); + }); + + it('ships the monorepo Dockerfile verbatim', async () => { + const templateDockerfile = await fs.readFile( + joinPath(outputDir, 'packages/backend/Dockerfile'), + 'utf8', + ); + const monorepoDockerfile = await fs.readFile( + joinPath(__dirname, '../../backend/Dockerfile'), + 'utf8', + ); + expect(templateDockerfile).toBe(monorepoDockerfile); + }); +}); diff --git a/packages/create-portal/src/index.ts b/packages/create-portal/src/index.ts new file mode 100644 index 000000000..109ea2b15 --- /dev/null +++ b/packages/create-portal/src/index.ts @@ -0,0 +1,56 @@ +/** + * A CLI that scaffolds a custom OpenChoreo Portal. + * + * @packageDocumentation + */ + +import { program } from 'commander'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { version } from '../package.json'; +import { createPortal } from './createPortal'; +import { exitWithError } from './lib/errors'; + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; + +const main = (argv: string[]) => { + program + .name('create-portal') + .version(version) + .description( + 'Scaffolds a custom OpenChoreo Portal pinned to one portal release', + ) + .option( + '--name ', + 'Portal name (lowercase letters, digits, dashes); skips the prompt', + ) + .option( + '--path ', + 'Location to store the portal, defaulting to a new folder with the portal name', + ) + .option( + '--registry ', + `npm registry the scaffold resolves @openchoreo/* packages from (default: ${DEFAULT_REGISTRY})`, + DEFAULT_REGISTRY, + ) + .option( + '--skip-install', + 'Skip the install and type-check steps after scaffolding', + ) + .option( + '--template-path ', + 'Use an external portal template instead of the built-in one', + ) + .action(cmd => createPortal(cmd)); + + program.parse(argv); +}; + +process.on('unhandledRejection', rejection => { + if (rejection instanceof Error) { + exitWithError(rejection); + } else { + exitWithError(new Error(`Unknown rejection: '${rejection}'`)); + } +}); + +main(process.argv); diff --git a/packages/create-portal/src/lib/errors.ts b/packages/create-portal/src/lib/errors.ts new file mode 100644 index 000000000..bc3b4425a --- /dev/null +++ b/packages/create-portal/src/lib/errors.ts @@ -0,0 +1,6 @@ +import chalk from 'chalk'; + +export function exitWithError(error: Error): never { + process.stderr.write(`\n${chalk.red(String(error))}\n\n`); + process.exit(1); +} diff --git a/packages/create-portal/src/lib/tasks.ts b/packages/create-portal/src/lib/tasks.ts new file mode 100644 index 000000000..815b2fda1 --- /dev/null +++ b/packages/create-portal/src/lib/tasks.ts @@ -0,0 +1,251 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +// Adapted from @backstage/create-app (packages/create-app/src/lib/tasks.ts). + +import chalk from 'chalk'; +import fs from 'fs-extra'; +import handlebars from 'handlebars'; +import { + basename, + dirname, + join as joinPath, + relative as relativePath, + resolve as resolvePath, +} from 'node:path'; +import { exec as execCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import os from 'node:os'; + +const TEN_MINUTES_MS = 1000 * 60 * 10; +const exec = promisify(execCb); + +export class Task { + static log(message: string = '') { + process.stdout.write(`${message}\n`); + } + + static error(message: string = '') { + process.stdout.write(`\n${chalk.red(message)}\n\n`); + } + + static section(name: string) { + process.stdout.write(`\n ${chalk.green(`${name}:`)}\n`); + } + + static exit(code: number = 0) { + process.exit(code); + } + + static async forItem( + task: string, + item: string, + taskFunc: () => Promise, + ): Promise { + const prefix = ` ${chalk.green(task.padEnd(14))}${chalk.cyan(item)}`; + try { + await taskFunc(); + process.stdout.write(`${prefix} ${chalk.green('✔')}\n`); + } catch (error) { + process.stdout.write(`${prefix} ${chalk.red('✖')}\n`); + throw error; + } + } +} + +/** Recursively lists all files (not directories) under `dir`. */ +export async function listFilesRecursively(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(entry => { + const full = joinPath(dir, entry.name); + return entry.isDirectory() ? listFilesRecursively(full) : [full]; + }), + ); + return files.flat(); +} + +/** + * Renders a template directory into `destinationDir`. Files ending in `.hbs` + * are rendered through handlebars with `context` and written without the + * suffix; everything else is byte-copied. + */ +export async function templatingTask( + templateDir: string, + destinationDir: string, + context: Record, +) { + const files = await listFilesRecursively(templateDir).catch(error => { + throw new Error(`Failed to read template directory: ${error.message}`); + }); + + for (const file of files) { + const destinationFile = resolvePath( + destinationDir, + relativePath(templateDir, file), + ); + await fs.ensureDir(dirname(destinationFile)); + + if (file.endsWith('.hbs')) { + await Task.forItem('templating', basename(file), async () => { + const destination = destinationFile.replace(/\.hbs$/, ''); + const template = await fs.readFile(file); + const compiled = handlebars.compile(template.toString(), { + strict: true, + }); + await fs.writeFile(destination, compiled(context)).catch(error => { + throw new Error( + `Failed to create file: ${destination}: ${error.message}`, + ); + }); + }); + } else { + await Task.forItem('copying', basename(file), async () => { + await fs.copyFile(file, destinationFile).catch(error => { + throw new Error( + `Failed to copy file to ${destinationFile}: ${error.message}`, + ); + }); + }); + } + } + + // The shipped yarn binary must stay executable (fs.copyFile preserves the + // mode, but npm pack does not for template payloads). + const yarnReleases = resolvePath(destinationDir, '.yarn/releases'); + if (await fs.pathExists(yarnReleases)) { + for (const file of await fs.readdir(yarnReleases)) { + await fs.chmod(resolvePath(yarnReleases, file), 0o755); + } + } +} + +/** Throws if `rootDir/name` already exists. */ +export async function checkAppExistsTask(rootDir: string, name: string) { + await Task.forItem('checking', name, async () => { + const destination = resolvePath(rootDir, name); + if (await fs.pathExists(destination)) { + throw new Error( + `A directory with the same name already exists: ${chalk.cyan( + destination, + )}\nPlease try again with a different portal name`, + ); + } + }); +} + +/** Ensures `path` exists as an empty directory. */ +export async function checkPathExistsTask(path: string) { + await Task.forItem('checking', path, async () => { + await fs.mkdirs(path).catch(error => { + throw new Error(`Failed to create portal directory: ${error.message}`); + }); + // The renderer writes straight into this directory, so refuse to run + // where it could overwrite existing files. + if ((await fs.readdir(path)).length > 0) { + throw new Error(`Portal directory must be empty: ${chalk.cyan(path)}`); + } + }); +} + +/** Creates a scratch directory to render into before the final move. */ +export async function createTemporaryPortalFolderTask(name: string) { + return fs.mkdtemp(resolvePath(os.tmpdir(), name)); +} + +/** Moves the rendered scaffold from `tempDir` to `destination`. */ +export async function moveAppTask( + tempDir: string, + destination: string, + id: string, +) { + await Task.forItem('moving', id, async () => { + await fs + .move(tempDir, destination) + .catch(error => { + throw new Error( + `Failed to move portal from ${tempDir} to ${destination}: ${error.message}`, + ); + }) + .finally(() => { + fs.removeSync(tempDir); + }); + }); +} + +/** Initializes a git repository in `dir` unless it is already inside one. */ +export async function tryInitGitRepository(dir: string) { + try { + await exec('git rev-parse --is-inside-work-tree', { cwd: dir }); + return false; + } catch { + /* not a repo — proceed */ + } + + try { + await exec('git init', { cwd: dir }); + await exec('git add .', { cwd: dir }); + await exec('git commit -m "Initial commit"', { cwd: dir }); + return true; + } catch { + await fs.rm(resolvePath(dir, '.git'), { recursive: true, force: true }); + return false; + } +} + +/** + * Runs `yarn install` and `yarn tsc` in the scaffolded portal. + */ +export async function buildAppTask(appDir: string) { + process.chdir(appDir); + + const runCmd = async (cmd: string) => { + await Task.forItem('executing', cmd, async () => { + await exec(cmd).catch(error => { + process.stdout.write(error.stderr ?? ''); + process.stdout.write(error.stdout ?? ''); + throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); + }); + }); + }; + + const installTimeout = setTimeout(() => { + Task.error( + "⏱️ It's taking a long time to install dependencies; you may want to exit (Ctrl-C) and run 'yarn install' and 'yarn tsc' manually", + ); + }, TEN_MINUTES_MS); + + try { + await runCmd('yarn install'); + } catch (error) { + Task.error( + [ + 'Dependency installation failed. Check your network and registry', + 'access, then retry:', + '', + ` ${chalk.cyan(`cd ${appDir} && yarn install`)}`, + '', + 'If the scaffold resolves @openchoreo/* from a private registry, set', + 'an npmAuthToken for the openchoreo scope in your user-level', + '~/.yarnrc.yml — never commit a registry token to the repo.', + ].join('\n'), + ); + throw error; + } finally { + clearTimeout(installTimeout); + } + await runCmd('yarn tsc'); +} diff --git a/packages/create-portal/templates-src/.openchoreo-portal.json.hbs b/packages/create-portal/templates-src/.openchoreo-portal.json.hbs new file mode 100644 index 000000000..224c5e611 --- /dev/null +++ b/packages/create-portal/templates-src/.openchoreo-portal.json.hbs @@ -0,0 +1,5 @@ +{ + "template": "openchoreo/portal-template", + "release": "v{{version}}", + "createdWith": "@openchoreo/create-portal@{{version}}" +} diff --git a/packages/create-portal/templates-src/README.md.hbs b/packages/create-portal/templates-src/README.md.hbs new file mode 100644 index 000000000..ec6b47737 --- /dev/null +++ b/packages/create-portal/templates-src/README.md.hbs @@ -0,0 +1,98 @@ +# {{name}} + +Your custom [OpenChoreo](https://openchoreo.dev) Portal — a thin +[Backstage](https://backstage.io) app built on the published +`@openchoreo/backstage-portal-app` and `@openchoreo/backstage-portal-backend` +packages, pinned to portal release `{{version}}`. + +You own this repository: add plugins, replace pages, and re-brand without +forking the OpenChoreo monorepo. + +## Prerequisites + +- Node.js 20 or 22, and Yarn (via `corepack enable`; the repo pins its own + Yarn under `.yarn/releases`). +- `@openchoreo/*` packages resolve from `{{registry}}` (see `.yarnrc.yml`). + If you point that at a private registry, set an `npmAuthToken` for the + `openchoreo` scope in your user-level `~/.yarnrc.yml` — don't commit + registry tokens to this repo. + +## Local development + +```sh +yarn install +yarn start # frontend on :3000, backend on :7007 +``` + +Local settings live in `app-config.local.yaml` (gitignored; recreate it from +`app-config.local.yaml.example`). Point `OPENCHOREO_API_URL` and +`THUNDER_BASE_URL` at your OpenChoreo control plane. + +## Adding plugins + +Optional OpenChoreo plugins, community Backstage plugins, and your in-house +plugins are all the same thing: packages this portal depends on. + +- **Frontend**: `yarn workspace app add `, then pass the plugin's + feature through `createPortalApp({ features: [...] })` in + `packages/app/src/App.tsx`. +- **Backend**: `yarn workspace backend add `, then + `backend.add(import(''))` in `packages/backend/src/index.ts`. +- **In-house**: scaffold into `plugins/` with `yarn new`. + +## Branding + +Logo, product name, and brand color are runtime configuration — no rebuild: + +```yaml +app: + branding: + name: Acme Portal + iconLogo: data:image/svg+xml;base64,… + fullLogo: data:image/svg+xml;base64,… + theme: + light: + primaryColor: '#0f766e' + dark: + primaryColor: '#2dd4bf' +``` + +Browser-chrome assets (favicons, PWA manifest) live in `packages/app/public/` +and are yours to replace. + +## Building the image + +Run `yarn install` at least once first so `yarn.lock` exists (commit it), then: + +```sh +docker build . -f packages/backend/Dockerfile \ + -t registry.example.com/acme/portal:0.1.0 +``` + +Deploy it by pointing the OpenChoreo Helm chart at your image: + +```sh +helm upgrade openchoreo-control-plane … \ + --set backstage.image.repository=registry.example.com/acme/portal \ + --set backstage.image.tag=0.1.0 +``` + +## Upgrading to a new portal release + +All `@openchoreo/*` packages version in lockstep, so an upgrade is one +version bump across `packages/app/package.json` and +`packages/backend/package.json`, plus a review of skeleton changes. + +`.openchoreo-portal.json` records the template release this repo was +generated from. To pick up skeleton changes, merge the matching tag of the +[`openchoreo/portal-template`](https://github.com/openchoreo/portal-template) +repo: + +```sh +git remote add portal-template https://github.com/openchoreo/portal-template +git fetch portal-template --tags +git merge # e.g. v{{version}} -> the next release +``` + +Review the merge, run `yarn install && yarn tsc && yarn test:all`, rebuild +your image, and update `.openchoreo-portal.json`'s `release` field. diff --git a/packages/create-portal/templates-src/packages/app/src/App.test.tsx b/packages/create-portal/templates-src/packages/app/src/App.test.tsx new file mode 100644 index 000000000..badbe8f2a --- /dev/null +++ b/packages/create-portal/templates-src/packages/app/src/App.test.tsx @@ -0,0 +1,29 @@ +import { render, waitFor } from '@testing-library/react'; +import app from './App'; + +describe('App', () => { + it('should render', async () => { + process.env = { + ...process.env, + NODE_ENV: 'test', + APP_CONFIG: [ + { + data: { + app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7007' }, + techdocs: { + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', + }, + }, + context: 'test', + }, + ] as any, + }; + + const rendered = render(app); + + await waitFor(() => { + expect(rendered.baseElement).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/create-portal/templates-src/packages/app/src/App.tsx b/packages/create-portal/templates-src/packages/app/src/App.tsx new file mode 100644 index 000000000..38e2eff8b --- /dev/null +++ b/packages/create-portal/templates-src/packages/app/src/App.tsx @@ -0,0 +1,6 @@ +import { createPortalApp } from '@openchoreo/backstage-portal-app'; + +// Your portal's frontend. Pass additional frontend features (plugins and +// modules) through `createPortalApp({ features: [...] })` — see the +// @openchoreo/backstage-portal-app docs. +export default createPortalApp().createRoot(); diff --git a/packages/create-portal/templates-src/plugins/README.md b/packages/create-portal/templates-src/plugins/README.md new file mode 100644 index 000000000..faa554d7f --- /dev/null +++ b/packages/create-portal/templates-src/plugins/README.md @@ -0,0 +1,15 @@ +# plugins/ + +Workspace root for your in-house Backstage plugins. Scaffold one with: + +```sh +yarn new +``` + +Plugins created here are workspaces of this repo (see the root +`package.json`), so the frontend and backend can depend on them with +`"your-plugin": "workspace:^"`. + +This directory ships with only this README so the Docker build's +`COPY plugins plugins` step and the workspace glob stay valid before your +first plugin exists. diff --git a/packages/portal-app/README.md b/packages/portal-app/README.md index 4127cddb4..27d1d803e 100644 --- a/packages/portal-app/README.md +++ b/packages/portal-app/README.md @@ -20,10 +20,10 @@ export default createPortalApp({ ## Status -This package is **private for now**. It is the landing place for app-shell -pieces as they are migrated to the new frontend system — parts of the shell -still run through `@backstage/core-compat-api` (legacy bridge) and are being -migrated piece by piece. It becomes publishable once the migration removes the -legacy-bridged internals (and the dependency on the private portal-assistant -plugin is decoupled); that PR flips `private` and adds the package to the -changeset linked group. +This package is **published** as part of the lockstep OpenChoreo release. The +shell is fully on the new frontend system and has no dependency on any private +plugin: optional assistant features integrate through the +`portalAssistantIntegrationApiRef` slots (see `usePortalAssistant`), which +render nothing when no implementation is registered. The stock portal injects +its assistant via `createPortalApp({ features })`; custom portals simply omit +it. diff --git a/packages/portal-app/package.json b/packages/portal-app/package.json index 84263f8f5..19e807b8b 100644 --- a/packages/portal-app/package.json +++ b/packages/portal-app/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "description": "The OpenChoreo Portal's frontend shell as composable building blocks (app assembly, sign-in, navigation, pages, scaffolder fields)", "license": "Apache-2.0", - "private": true, "main": "src/index.ts", "types": "src/index.ts", "publishConfig": { @@ -77,7 +76,6 @@ "@openchoreo/backstage-plugin-common": "workspace:^", "@openchoreo/backstage-plugin-openchoreo-ci": "workspace:^", "@openchoreo/backstage-plugin-openchoreo-observability": "workspace:^", - "@openchoreo/backstage-plugin-openchoreo-portal-assistant": "workspace:^", "@openchoreo/backstage-plugin-openchoreo-workflows": "workspace:^", "@openchoreo/backstage-plugin-platform-engineer-core": "workspace:^", "@openchoreo/backstage-plugin-react": "workspace:^", diff --git a/packages/portal-app/src/apis.test.ts b/packages/portal-app/src/apis.test.ts index a47e1bc3c..a4ad4bd83 100644 --- a/packages/portal-app/src/apis.test.ts +++ b/packages/portal-app/src/apis.test.ts @@ -12,15 +12,11 @@ import { } from '@backstage/core-plugin-api'; import { visitsApiRef } from '@backstage/plugin-home'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { - perchAgentApiRef, - PerchAgentClient, -} from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; import { apis } from './apis'; -const stubDiscovery = { getBaseUrl: async () => 'http://localhost' } as any; -const stubFetch = { fetch: globalThis.fetch ?? (() => undefined) } as any; +// Minimal stubs — none of the factories under test inspect dep state at +// construction time beyond holding the reference. const stubIdentity = { getCredentials: async () => ({}), getProfileInfo: async () => ({}), @@ -45,12 +41,7 @@ function invoke(factory: AnyApiFactory, deps: Record) { describe('apis registry', () => { it('registers a factory for every required api ref (no silent drops)', () => { const ids = apis.map(f => f.api.id); - for (const ref of [ - scmIntegrationsApiRef, - visitsApiRef, - storageApiRef, - perchAgentApiRef, - ]) { + for (const ref of [scmIntegrationsApiRef, visitsApiRef, storageApiRef]) { expect(ids).toContain(ref.id); } }); @@ -61,15 +52,6 @@ describe('apis registry', () => { // permission and openchoreo-auth are also owned by the base plugin now }); - it('builds the PerchAgentClient via its factory', () => { - const f = findFactory(apis, perchAgentApiRef); - const instance = invoke(f, { - discoveryApi: stubDiscovery, - fetchApi: stubFetch, - }); - expect(instance).toBeInstanceOf(PerchAgentClient); - }); - it('builds the visits api', () => { const f = findFactory(apis, visitsApiRef); const instance = invoke(f, { diff --git a/packages/portal-app/src/apis.ts b/packages/portal-app/src/apis.ts index 774146055..6228dee10 100644 --- a/packages/portal-app/src/apis.ts +++ b/packages/portal-app/src/apis.ts @@ -15,15 +15,6 @@ import { } from '@backstage/core-plugin-api'; import { VisitsWebStorageApi, visitsApiRef } from '@backstage/plugin-home'; import { UserSettingsStorage } from '@backstage/plugin-user-settings'; -// NOTE: `perchAgentApiRef` is also declared on `openchoreoPerchPlugin.apis` -// in plugins/openchoreo-portal-assistant/src/plugin.ts. That declaration is -// NOT picked up at runtime because the plugin only exports plain React -// components. Removing this app-level factory causes NotImplementedError -// in AssistantDrawerProvider. -import { - perchAgentApiRef, - PerchAgentClient, -} from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; // OpenChoreo fetch/permission/auth factories are contributed by // `@openchoreo/backstage-plugin` (base) as ApiBlueprints — no manual @@ -54,13 +45,4 @@ export const apis: AnyApiFactory[] = [ }, factory: deps => UserSettingsStorage.create(deps), }), - createApiFactory({ - api: perchAgentApiRef, - deps: { - discoveryApi: discoveryApiRef, - fetchApi: fetchApiRef, - }, - factory: ({ discoveryApi, fetchApi }) => - new PerchAgentClient({ discoveryApi, fetchApi }), - }), ]; diff --git a/packages/portal-app/src/apis/customOverrides.test.tsx b/packages/portal-app/src/apis/customOverrides.test.tsx index 9103181f1..6c5271206 100644 --- a/packages/portal-app/src/apis/customOverrides.test.tsx +++ b/packages/portal-app/src/apis/customOverrides.test.tsx @@ -77,15 +77,17 @@ describe('customOverrides', () => { ).toBe('app'); }); - it('registers extensions on the customAppModule (SignInPage, Translation, LogRowAction, Progress swap)', () => { + it('registers extensions on the customAppModule (SignInPage, Translation, Progress swap)', () => { const extensions = ((customAppModule as any).extensions ?? []) as Array<{ id: string; }>; expect(Array.isArray(extensions)).toBe(true); - // SignInPage, Translation override (catalog-import), LogRowAction renderer, - // and the core-progress swappable-component override (PageLoader). - expect(extensions).toHaveLength(4); + // SignInPage, Translation override (catalog-import), and the + // core-progress swappable-component override (PageLoader). The + // assistant's LogRowAction renderer moved to the host app (packages/app) + // when the shell became publishable. + expect(extensions).toHaveLength(3); expect(extensions.map(e => e.id)).toContain('component:app/progress'); }); }); diff --git a/packages/portal-app/src/apis/customOverrides.tsx b/packages/portal-app/src/apis/customOverrides.tsx index e9d89166f..262c23040 100644 --- a/packages/portal-app/src/apis/customOverrides.tsx +++ b/packages/portal-app/src/apis/customOverrides.tsx @@ -75,8 +75,6 @@ import { } from '@openchoreo/backstage-plugin-common'; import { KIND_ICONS } from '../kindIcons'; import { openChoreoTokenDecorator } from '../scaffolder/openChoreoTokenDecorator'; -import { LogRowActionBlueprint } from '@openchoreo/backstage-plugin-openchoreo-observability/alpha'; -import { InvestigateLogButton } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; /** * Override `catalog-graph`'s default `api:catalog-graph` to include the @@ -371,20 +369,6 @@ export const customAppModule = createFrontendModule({ }), }, }), - // Host-injected per-row action renderer for the observability - // runtime-logs tables. Wires the portal-assistant's - // InvestigateLogButton into ObservabilityRuntimeLogs / - // ObservabilityProjectRuntimeLogs without coupling the - // observability plugin to portal-assistant. Mirrors upstream's - // FormDecoratorBlueprint registration pattern. - LogRowActionBlueprint.make({ - name: 'investigate-log', - params: { - renderer: (log, getLogsSnapshot) => ( - - ), - }, - }), // Swap Backstage's built-in `` bar (the Suspense fallback // `ExtensionBoundary` renders while a lazy page/extension chunk loads) for // our centered PageLoader, so route transitions match the rest of the app. diff --git a/packages/portal-app/src/appModule.tsx b/packages/portal-app/src/appModule.tsx index 4ff98d9ed..c1d2502b9 100644 --- a/packages/portal-app/src/appModule.tsx +++ b/packages/portal-app/src/appModule.tsx @@ -12,8 +12,9 @@ import { EntityContentBlueprint, EntityContentLayoutBlueprint, } from '@backstage/plugin-catalog-react/alpha'; -import { AssistantDrawerProvider } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; +import { Fragment, PropsWithChildren } from 'react'; import { OpenChoreoQueryProvider } from '@openchoreo/backstage-plugin-react'; +import { usePortalAssistant } from './assistant/PortalAssistantIntegrationApi'; import { apis } from './apis'; import { LEGACY_KIND_ICONS } from './kindIcons'; import { appThemes } from './themes'; @@ -55,9 +56,18 @@ const scaffolderPreselectionWrapper = AppRootWrapperBlueprint.make({ params: { component: ScaffolderPreselectionProvider }, }); +// Assistant drawer slot — the shell has no dependency on any assistant +// implementation; hosts register one via `portalAssistantIntegrationApiRef`. +// Falls back to a Fragment so the tree is identical when no assistant is +// installed. +const AssistantAppWrapper = ({ children }: PropsWithChildren<{}>) => { + const { AppWrapper = Fragment } = usePortalAssistant(); + return {children}; +}; + const assistantDrawerWrapper = AppRootWrapperBlueprint.make({ name: 'assistant-drawer', - params: { component: AssistantDrawerProvider }, + params: { component: AssistantAppWrapper }, }); // Portal-only. Adopters get vanilla upstream api-docs behavior on API pages. diff --git a/packages/portal-app/src/assistant/PortalAssistantIntegrationApi.test.tsx b/packages/portal-app/src/assistant/PortalAssistantIntegrationApi.test.tsx new file mode 100644 index 000000000..79fa66054 --- /dev/null +++ b/packages/portal-app/src/assistant/PortalAssistantIntegrationApi.test.tsx @@ -0,0 +1,60 @@ +import { PropsWithChildren } from 'react'; +import { render, screen } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { + portalAssistantIntegrationApiRef, + usePortalAssistant, +} from './PortalAssistantIntegrationApi'; + +// Exercises every slot the way the shell consumes it: AppWrapper with a +// Fragment default, and conditional rendering for the optional notifier. +function Probe() { + const { + AppWrapper = ({ children }: PropsWithChildren<{}>) => <>{children}, + BuildFailureNotifier, + } = usePortalAssistant(); + return ( + + {BuildFailureNotifier ? : null} + content + + ); +} + +describe('usePortalAssistant', () => { + it('returns empty slots when no integration is registered', () => { + render( + + + , + ); + expect(screen.getByText('content')).toBeInTheDocument(); + expect(screen.queryByTestId('wrapper')).not.toBeInTheDocument(); + expect(screen.queryByTestId('notifier')).not.toBeInTheDocument(); + }); + + it('exposes the registered integration slots', () => { + render( + ) => ( +
{children}
+ ), + BuildFailureNotifier: () =>
, + }, + ], + ]} + > + + , + ); + // The wrapper wraps the content (not rendered beside it). + expect(screen.getByTestId('wrapper')).toContainElement( + screen.getByText('content'), + ); + expect(screen.getByTestId('notifier')).toBeInTheDocument(); + }); +}); diff --git a/packages/portal-app/src/assistant/PortalAssistantIntegrationApi.ts b/packages/portal-app/src/assistant/PortalAssistantIntegrationApi.ts new file mode 100644 index 000000000..e686160e5 --- /dev/null +++ b/packages/portal-app/src/assistant/PortalAssistantIntegrationApi.ts @@ -0,0 +1,12 @@ +/** + * The assistant integration seam. The contract itself lives in + * `@openchoreo/backstage-plugin-react` so the OpenChoreo plugins can consume + * slots (build-failure notifier, deploy investigate action) without depending + * on this shell package; re-exported here because the shell documents this as + * its public integration point for host apps. + */ +export { + portalAssistantIntegrationApiRef, + usePortalAssistant, +} from '@openchoreo/backstage-plugin-react'; +export type { PortalAssistantIntegration } from '@openchoreo/backstage-plugin-react'; diff --git a/packages/portal-app/src/createPortalApp.test.tsx b/packages/portal-app/src/createPortalApp.test.tsx index 4288df25a..6da319593 100644 --- a/packages/portal-app/src/createPortalApp.test.tsx +++ b/packages/portal-app/src/createPortalApp.test.tsx @@ -1,6 +1,11 @@ +import { PropsWithChildren } from 'react'; import { render, waitFor } from '@testing-library/react'; -import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { + ApiBlueprint, + createFrontendModule, +} from '@backstage/frontend-plugin-api'; import { createPortalApp } from './createPortalApp'; +import { portalAssistantIntegrationApiRef } from './assistant/PortalAssistantIntegrationApi'; describe('createPortalApp', () => { beforeEach(() => { @@ -43,4 +48,36 @@ describe('createPortalApp', () => { expect(rendered.baseElement).toBeInTheDocument(); }); }); + + it('accepts an assistant integration registered through features', async () => { + // The seam contract a host app (packages/app, or a custom portal) relies + // on: an ApiBlueprint for portalAssistantIntegrationApiRef passed via + // `features` boots without conflicting with the shell's own factories. + const fakeAssistant = createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + name: 'assistant-integration', + params: defineParams => + defineParams({ + api: portalAssistantIntegrationApiRef, + deps: {}, + factory: () => ({ + AppWrapper: ({ children }: PropsWithChildren<{}>) => ( +
{children}
+ ), + }), + }), + }), + ], + }); + + const rendered = render( + createPortalApp({ features: [fakeAssistant] }).createRoot(), + ); + + await waitFor(() => { + expect(rendered.baseElement).toBeInTheDocument(); + }); + }); }); diff --git a/packages/portal-app/src/index.ts b/packages/portal-app/src/index.ts index a981ba932..4dee9ac6c 100644 --- a/packages/portal-app/src/index.ts +++ b/packages/portal-app/src/index.ts @@ -7,10 +7,6 @@ * code lives here (not in packages/app) so the custom-portal scaffold can * consume it — see the portal composition proposal. * - * The package stays private until the migration cleans up the legacy-bridged - * pieces; the PR that makes it publishable flips `private` and adds it to the - * changeset linked group. - * * @packageDocumentation */ @@ -18,3 +14,8 @@ export { createPortalApp } from './createPortalApp'; export type { PortalAppOptions } from './createPortalApp'; export { brandName, useBranding, DEFAULT_BRAND_NAME } from './branding'; export type { BrandingConfig } from './branding'; +export { + portalAssistantIntegrationApiRef, + usePortalAssistant, +} from './assistant/PortalAssistantIntegrationApi'; +export type { PortalAssistantIntegration } from './assistant/PortalAssistantIntegrationApi'; diff --git a/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx b/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx index 014856909..ff904395f 100644 --- a/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx +++ b/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx @@ -74,6 +74,8 @@ jest.mock('@openchoreo/backstage-plugin-react', () => ({ ForbiddenState: (props: any) => (
{props.message}
), + // Renders nothing, like the real slot with no assistant registered. + BuildFailureNotifierSlot: () => null, })); // Mock @openchoreo/backstage-plugin-common diff --git a/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx b/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx index 15028b00c..a7bc211f8 100644 --- a/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx +++ b/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx @@ -38,6 +38,7 @@ import { useBuildPermission, useOpenChoreoMutation, ForbiddenState, + BuildFailureNotifierSlot, } from '@openchoreo/backstage-plugin-react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; @@ -457,13 +458,18 @@ export const Workflows = () => { } return ( - setRunDetailsTab(tab)} - gitFieldMapping={gitFieldMapping} - /> + <> + {/* Assistant prompt for a failed run — the URL-run targeting in the + notifier needs it mounted on /run/ pages too. */} + + setRunDetailsTab(tab)} + gitFieldMapping={gitFieldMapping} + /> + ); } @@ -477,6 +483,7 @@ export const Workflows = () => { return ( + Workflows diff --git a/plugins/openchoreo-react/src/api/assistantIntegration.test.tsx b/plugins/openchoreo-react/src/api/assistantIntegration.test.tsx new file mode 100644 index 000000000..7c078b3ab --- /dev/null +++ b/plugins/openchoreo-react/src/api/assistantIntegration.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { + BuildFailureNotifierSlot, + portalAssistantIntegrationApiRef, +} from './assistantIntegration'; + +describe('BuildFailureNotifierSlot', () => { + it('renders nothing when no integration is registered', () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the integration fills no notifier slot', () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the registered notifier', () => { + render( +
}, + ], + ]} + > + + , + ); + expect(screen.getByTestId('notifier')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-react/src/api/assistantIntegration.tsx b/plugins/openchoreo-react/src/api/assistantIntegration.tsx new file mode 100644 index 000000000..e97a4566c --- /dev/null +++ b/plugins/openchoreo-react/src/api/assistantIntegration.tsx @@ -0,0 +1,89 @@ +import { createApiRef, useApiHolder } from '@backstage/core-plugin-api'; +import type { ComponentType, ReactNode } from 'react'; + +/** + * Scope handed to the host-app-supplied "investigate" slot when a + * deployment is in a problem state. The deploy panel owns the shape and + * decides *where* the affordance renders, while the host app injects the + * actual assistant button — so no OpenChoreo plugin keeps a dependency on + * an assistant implementation. + */ +export interface InvestigateScope { + /** Control-plane namespace of the component. */ + namespace?: string; + /** Project the component belongs to. */ + project?: string; + /** The component whose deployment is in trouble. */ + component: string; + /** Environment resource name the binding is in. */ + environment?: string; + /** + * Which assistant flow to launch: ``dependency_pending`` when the cause + * is an unresolved connection, otherwise ``runtime_debug``. + */ + caseType: 'dependency_pending' | 'runtime_debug'; + /** Human-readable deployment status, e.g. ``Pending`` / ``Failed``. */ + status: string; +} + +/** Render-prop slot for the deploy-panel investigate affordance. */ +export type RenderInvestigateAction = (scope: InvestigateScope) => ReactNode; + +/** + * Optional integration slots an AI-assistant feature can fill across the + * portal shell and the OpenChoreo plugins. This package owns only the + * contract — it has no dependency on any assistant implementation. When no + * implementation is registered every slot is absent and consumers render + * exactly nothing in its place. + * + * Register an implementation from a host app via an `ApiBlueprint` extension + * for {@link portalAssistantIntegrationApiRef}. + */ +export interface PortalAssistantIntegration { + /** + * Wraps the routed app content at the app root — the slot for a global + * assistant drawer/chrome provider. Consumers always pass children, so + * implementations may declare them required. + */ + AppWrapper?: ComponentType<{ children: ReactNode }>; + + /** + * Mounted on component entity Overview and Build tabs. Expected to render + * nothing unless it has something to prompt about (e.g. the latest build + * run failed). + */ + BuildFailureNotifier?: ComponentType<{}>; + + /** + * Injected into the deploy panel's `renderInvestigateAction` slot — a + * status-aware "investigate" action for a failing deployment. + */ + renderInvestigateAction?: RenderInvestigateAction; +} + +/** + * Point of registration for an assistant integration. Deliberately optional: + * `usePortalAssistant` yields `{}` when nothing is registered. + */ +export const portalAssistantIntegrationApiRef = + createApiRef({ + id: 'plugin.openchoreo-portal.assistant-integration', + }); + +/** + * Reads the registered assistant integration, or `{}` when none is installed + * — callers destructure slots and no-op on `undefined`. + */ +export function usePortalAssistant(): PortalAssistantIntegration { + return useApiHolder().get(portalAssistantIntegrationApiRef) ?? {}; +} + +/** + * Mounts the integration's {@link PortalAssistantIntegration.BuildFailureNotifier} + * slot, or nothing when no assistant is registered — plugins drop this in + * without any conditional logic of their own. + */ +export const BuildFailureNotifierSlot = () => { + const { BuildFailureNotifier } = usePortalAssistant(); + return BuildFailureNotifier ? : null; +}; diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index 7a02e8e8d..de4487966 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -707,3 +707,15 @@ export { type UseProjectEnvironmentsResult, type ProjectEnvironmentsStatus, } from './hooks/useProjectEnvironments'; + +// Assistant integration contract — the optional slots an AI assistant can +// fill across the portal shell and plugins. Contract only; implementations +// are registered by host apps. +export { + portalAssistantIntegrationApiRef, + usePortalAssistant, + BuildFailureNotifierSlot, + type PortalAssistantIntegration, + type InvestigateScope, + type RenderInvestigateAction, +} from './api/assistantIntegration'; diff --git a/plugins/openchoreo/src/components/Environments/Environments.test.tsx b/plugins/openchoreo/src/components/Environments/Environments.test.tsx index 3510bf7da..fd087e32f 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.test.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.test.tsx @@ -51,12 +51,14 @@ jest.mock('@backstage/core-components', () => ({ Progress: () =>
Loading...
, })); -// Mock permission hooks from @openchoreo/backstage-plugin-react +// Mock permission + assistant hooks from @openchoreo/backstage-plugin-react const mockUseEnvironmentReadPermission = jest.fn(); const mockUseReleaseBindingPermission = jest.fn(); +const mockUsePortalAssistant = jest.fn(); jest.mock('@openchoreo/backstage-plugin-react', () => ({ useEnvironmentReadPermission: () => mockUseEnvironmentReadPermission(), useReleaseBindingPermission: () => mockUseReleaseBindingPermission(), + usePortalAssistant: () => mockUsePortalAssistant(), ForbiddenState: (props: any) => (
{props.message} @@ -69,12 +71,29 @@ jest.mock('@openchoreo/backstage-plugin-react', () => ({ ), })); -// Mock the EnvironmentsRouter (renders child views) -jest.mock('./EnvironmentsRouter', () => ({ - EnvironmentsRouter: () => ( -
Environments Content
- ), -})); +// Mock the EnvironmentsRouter (renders child views). The probe also renders +// whatever investigate action reached the context, so tests can assert the +// prop/API precedence without the real detail panel. +jest.mock('./EnvironmentsRouter', () => { + const { useEnvironmentsContext } = jest.requireActual( + './EnvironmentsContext', + ); + return { + EnvironmentsRouter: () => { + const { renderInvestigateAction } = useEnvironmentsContext(); + return ( +
+ Environments Content + {renderInvestigateAction?.({ + component: 'checkout', + caseType: 'runtime_debug', + status: 'Failed', + }) ?? null} +
+ ); + }, + }; +}); // Mock NotificationBanner jest.mock('./components', () => ({ @@ -108,6 +127,7 @@ describe('Environments', () => { canViewBindings: true, loading: false, }); + mockUsePortalAssistant.mockReturnValue({}); }); it('mounts the router during initial load instead of a generic spinner', () => { @@ -152,6 +172,66 @@ describe('Environments', () => { expect(screen.getByText('Environments Content')).toBeInTheDocument(); }); + it('falls back to the assistant integration API for the investigate action', async () => { + mockUsePortalAssistant.mockReturnValue({ + renderInvestigateAction: () => , + }); + mockUseEnvironmentData.mockReturnValue({ + environments: [], + loading: false, + isRefetching: false, + isForbidden: false, + refetch: mockRefetch, + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('from-api')).toBeInTheDocument(); + }); + }); + + it('prefers an explicit investigate-action prop over the API slot', async () => { + mockUsePortalAssistant.mockReturnValue({ + renderInvestigateAction: () => , + }); + mockUseEnvironmentData.mockReturnValue({ + environments: [], + loading: false, + isRefetching: false, + isForbidden: false, + refetch: mockRefetch, + }); + + renderWithRouter( + } + />, + ); + + await waitFor(() => { + expect(screen.getByText('from-prop')).toBeInTheDocument(); + }); + expect(screen.queryByText('from-api')).not.toBeInTheDocument(); + }); + + it('provides no investigate action when no assistant is registered', async () => { + mockUseEnvironmentData.mockReturnValue({ + environments: [], + loading: false, + isRefetching: false, + isForbidden: false, + refetch: mockRefetch, + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByTestId('environments-router')).toBeInTheDocument(); + }); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + it('shows forbidden state when API returns forbidden', () => { mockUseEnvironmentData.mockReturnValue({ environments: [], diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx index b2ea2d258..17ee89cb7 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.tsx @@ -23,13 +23,15 @@ import { ForbiddenState, useReleaseBindingPermission, useEnvironmentReadPermission, + usePortalAssistant, } from '@openchoreo/backstage-plugin-react'; export interface EnvironmentsProps { /** - * Host-app slot for the deploy-panel "investigate" button. Injected by - * ``packages/app`` (which owns the portal-assistant dependency) and - * forwarded to the detail panel via context. See + * Host-app slot for the deploy-panel "investigate" button, forwarded to + * the detail panel via context. When omitted (the NFS deploy tab mounts + * this component propless) it falls back to the assistant integration + * API's ``renderInvestigateAction`` slot. See * ``EnvironmentsContextValue.renderInvestigateAction``. */ renderInvestigateAction?: RenderInvestigateAction; @@ -142,6 +144,13 @@ export const Environments = ({ [notification, refetch, navigateToList], ); + // Deploy-panel investigate action: an explicit prop wins; otherwise fall + // back to the assistant integration API (absent when no assistant is + // installed, so the detail panel renders no affordance). + const { renderInvestigateAction: apiInvestigateAction } = + usePortalAssistant(); + const investigateAction = renderInvestigateAction ?? apiInvestigateAction; + // Context value const contextValue = useMemo( () => ({ @@ -168,7 +177,7 @@ export const Environments = ({ beginAwaitingNewRelease, selection, setSelection, - renderInvestigateAction, + renderInvestigateAction: investigateAction, }), [ environments, @@ -193,7 +202,7 @@ export const Environments = ({ awaitingNewRelease, beginAwaitingNewRelease, selection, - renderInvestigateAction, + investigateAction, ], ); diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx index 23c097167..4b2b4e939 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx @@ -15,33 +15,17 @@ export type Selection = | null; /** - * Scope handed to the host-app-supplied "investigate" slot when a - * deployment is in a problem state. Mirrors the render-prop pattern used - * for runtime-log debugging (``RenderLogRowAction`` in the observability - * plugin): this plugin owns the shape and decides *where* the affordance - * renders, while ``packages/app`` injects the actual assistant button — - * so the openchoreo plugin keeps no dependency on portal-assistant. + * The investigate-slot shape now lives in the shared assistant-integration + * contract (`@openchoreo/backstage-plugin-react`) so the host app and this + * plugin agree on it without either depending on an assistant + * implementation; re-exported here for existing consumers. */ -export interface InvestigateScope { - /** Control-plane namespace of the component. */ - namespace?: string; - /** Project the component belongs to. */ - project?: string; - /** The component whose deployment is in trouble. */ - component: string; - /** Environment resource name the binding is in. */ - environment?: string; - /** - * Which assistant flow to launch: ``dependency_pending`` when the cause - * is an unresolved connection, otherwise ``runtime_debug``. - */ - caseType: 'dependency_pending' | 'runtime_debug'; - /** Human-readable deployment status, e.g. ``Pending`` / ``Failed``. */ - status: string; -} +import type { + InvestigateScope, + RenderInvestigateAction, +} from '@openchoreo/backstage-plugin-react'; -/** Render-prop slot for the deploy-panel investigate affordance. */ -export type RenderInvestigateAction = (scope: InvestigateScope) => ReactNode; +export type { InvestigateScope, RenderInvestigateAction }; interface EnvironmentsContextValue { /** All environments loaded from the API */ diff --git a/plugins/openchoreo/src/extensions/entityLayouts/ComponentOverviewLayout.tsx b/plugins/openchoreo/src/extensions/entityLayouts/ComponentOverviewLayout.tsx index 7d2b04039..190e13e7f 100644 --- a/plugins/openchoreo/src/extensions/entityLayouts/ComponentOverviewLayout.tsx +++ b/plugins/openchoreo/src/extensions/entityLayouts/ComponentOverviewLayout.tsx @@ -1,6 +1,9 @@ import Grid from '@material-ui/core/Grid'; import type { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; -import { FeatureGate } from '@openchoreo/backstage-plugin-react'; +import { + BuildFailureNotifierSlot, + FeatureGate, +} from '@openchoreo/backstage-plugin-react'; import { WorkflowsOverviewCard, DeploymentStatusCard, @@ -12,19 +15,17 @@ import { ContainedCatalogGraphCard } from '../../components/ContainedCatalogGrap import { ForeignCardsSection } from './foreignCards'; /** - * The Component-kind Overview layout. The portal separately overlays - * `FailedBuildSnackbar` (private `openchoreo-portal-assistant` plugin, - * not shipped to adopters) and `WorkflowsOrExternalCICard` (portal-only - * adapter over Jenkins/GitHub Actions/GitLab) at the app layer; adopters - * get the plain `WorkflowsOverviewCard` here. Both are opt-in - * customizations rather than portal defaults, applied via the portal's - * thin `page:catalog/entity` override. + * The Component-kind Overview layout. `BuildFailureNotifierSlot` renders the + * host app's assistant prompt for a failed build run (nothing when no + * assistant integration is registered — the stock portal's is private and + * not shipped to adopters). */ export default function ComponentOverviewLayout({ cards, }: EntityContentLayoutProps) { return ( + diff --git a/yarn.lock b/yarn.lock index 6cb15d7bb..404022f64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10375,7 +10375,6 @@ __metadata: "@openchoreo/backstage-plugin-common": "workspace:^" "@openchoreo/backstage-plugin-openchoreo-ci": "workspace:^" "@openchoreo/backstage-plugin-openchoreo-observability": "workspace:^" - "@openchoreo/backstage-plugin-openchoreo-portal-assistant": "workspace:^" "@openchoreo/backstage-plugin-openchoreo-workflows": "workspace:^" "@openchoreo/backstage-plugin-platform-engineer-core": "workspace:^" "@openchoreo/backstage-plugin-react": "workspace:^" @@ -10484,6 +10483,23 @@ __metadata: languageName: unknown linkType: soft +"@openchoreo/create-portal@workspace:packages/create-portal": + version: 0.0.0-use.local + resolution: "@openchoreo/create-portal@workspace:packages/create-portal" + dependencies: + "@backstage/cli": "npm:^0.36.2" + "@types/fs-extra": "npm:^11.0.4" + "@types/inquirer": "npm:^8.2.10" + chalk: "npm:^4.1.2" + commander: "npm:^12.1.0" + fs-extra: "npm:^11.2.0" + handlebars: "npm:^4.7.8" + inquirer: "npm:^8.2.6" + bin: + create-portal: bin/create-portal + languageName: unknown + linkType: soft + "@openchoreo/openapi-client-generator-node@workspace:^, @openchoreo/openapi-client-generator-node@workspace:packages/openapi-client-generator-node": version: 0.0.0-use.local resolution: "@openchoreo/openapi-client-generator-node@workspace:packages/openapi-client-generator-node" @@ -15405,6 +15421,16 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^11.0.4": + version: 11.0.4 + resolution: "@types/fs-extra@npm:11.0.4" + dependencies: + "@types/jsonfile": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/9e34f9b24ea464f3c0b18c3f8a82aefc36dc524cc720fc2b886e5465abc66486ff4e439ea3fb2c0acebf91f6d3f74e514f9983b1f02d4243706bdbb7511796ad + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -15475,6 +15501,16 @@ __metadata: languageName: node linkType: hard +"@types/inquirer@npm:^8.2.10": + version: 8.2.13 + resolution: "@types/inquirer@npm:8.2.13" + dependencies: + "@types/through": "npm:*" + rxjs: "npm:^7.2.0" + checksum: 10c0/bfa4fac8c126bce4708418c637f32e39bbf4125869a2351257c9fb3b76d13b96e6e9be279fbcbdf52e94a8dbf5b3dc773275e08b56ab396b6d0314b9cad00163 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" @@ -15556,6 +15592,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonfile@npm:*": + version: 6.1.4 + resolution: "@types/jsonfile@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/b12d068b021e4078f6ac4441353965769be87acf15326173e2aea9f3bf8ead41bd0ad29421df5bbeb0123ec3fc02eb0a734481d52903704a1454a1845896b9eb + languageName: node + linkType: hard + "@types/jsonwebtoken@npm:^9.0.0": version: 9.0.10 resolution: "@types/jsonwebtoken@npm:9.0.10" @@ -16072,6 +16117,15 @@ __metadata: languageName: node linkType: hard +"@types/through@npm:*": + version: 0.0.33 + resolution: "@types/through@npm:0.0.33" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/6a8edd7f40cd7e197318e86310a40e568cddd380609dde59b30d5cc6c5f8276ddc698905eac4b3b429eb39f2e8ee326bc20dc6e95a2cdc41c4d3fc9a1ebd4929 + languageName: node + linkType: hard + "@types/tough-cookie@npm:*": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" @@ -17075,7 +17129,11 @@ __metadata: dependencies: "@axe-core/playwright": "npm:^4.11.3" "@backstage/cli": "npm:^0.36.2" + "@backstage/core-plugin-api": "npm:^1.12.6" + "@backstage/frontend-plugin-api": "npm:^0.17.0" "@backstage/ui": "npm:^0.15.0" + "@openchoreo/backstage-plugin-openchoreo-observability": "workspace:^" + "@openchoreo/backstage-plugin-openchoreo-portal-assistant": "workspace:^" "@openchoreo/backstage-portal-app": "workspace:^" "@playwright/test": "npm:1.56.0" "@testing-library/dom": "npm:9.3.4" @@ -19161,7 +19219,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^12.0.0": +"commander@npm:^12.0.0, commander@npm:^12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" checksum: 10c0/6e1996680c083b3b897bfc1cfe1c58dfbcd9842fd43e1aaf8a795fbc237f65efcc860a3ef457b318e73f29a4f4a28f6403c3d653d021d960e4632dd45bde54a9 @@ -23939,6 +23997,24 @@ __metadata: languageName: node linkType: hard +"handlebars@npm:^4.7.8": + version: 4.7.9 + resolution: "handlebars@npm:4.7.9" + dependencies: + minimist: "npm:^1.2.5" + neo-async: "npm:^2.6.2" + source-map: "npm:^0.6.1" + uglify-js: "npm:^3.1.4" + wordwrap: "npm:^1.0.0" + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 10c0/22f8105a7e68e81aff2662bb434edf05f757d21d850731d71cec886d69c10cd33d3c43e34b2892968ec62de8241611851d3d0674c8ef324ea3e01dc66262faa9 + languageName: node + linkType: hard + "harmony-reflect@npm:^1.4.6": version: 1.6.2 resolution: "harmony-reflect@npm:1.6.2" @@ -24923,7 +24999,7 @@ __metadata: languageName: node linkType: hard -"inquirer@npm:^8.2.0": +"inquirer@npm:^8.2.0, inquirer@npm:^8.2.6": version: 8.2.7 resolution: "inquirer@npm:8.2.7" dependencies: @@ -34293,7 +34369,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.5.5": +"rxjs@npm:^7.2.0, rxjs@npm:^7.5.5": version: 7.8.2 resolution: "rxjs@npm:7.8.2" dependencies: