diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..87f57e732
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,12 @@
+{
+ "name": "Angular SDK Components",
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:24",
+ "postCreateCommand": "npm ci --ignore-scripts && npx playwright install --with-deps chromium",
+ "forwardPorts": [3500],
+ "portsAttributes": { "3500": { "label": "SDK dev server" } },
+ "customizations": {
+ "vscode": {
+ "extensions": ["angular.ng-template", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-playwright.playwright"]
+ }
+ }
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..a567d2bde
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+node_modules
+dist
+.angular
+.git
+**/*.tgz
+test-results
+tests/playwright-report
+packages/angular-sdk-overrides/lib
+keys
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..8c52ff937
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..48a43c47f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Copy to .env (git-ignored) and `source` it, or set these in your CI system.
+# `npm run configure` applies them to sdk-config.json. Empty values are ignored.
+# Full reference: docs/configuration.md
+
+SDK_INFINITY_REST_SERVER_URL=https://my-pega.example.com/prweb
+SDK_APP_ALIAS=MediaCo
+SDK_PORTAL_CLIENT_ID=
+SDK_MASHUP_CLIENT_ID=
+SDK_MASHUP_USER_IDENTIFIER=
+# Plain text here; the script Base64-encodes it into sdk-config.json
+SDK_MASHUP_PASSWORD=
+SDK_CONTENT_SERVER_URL=
+SDK_APP_PORTAL=
+SDK_APP_MASHUP_CASE_TYPE=
+SDK_SHOW_MODALS_IN_EMBEDDED_MODE=false
+SDK_THEME=dark
+
+# End-to-end tests
+SDK_E2E_BASE_URL=http://localhost:3500
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 0acc0c81f..e83a28ff3 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -28,6 +28,8 @@ jobs:
run: npm run build-angular-sdk-components
- name: Public API report is up to date
run: npm run api:check
+ - name: Tooling script tests
+ run: npm run test:scripts
- name: Component catalogue is up to date
run: npm run docs:components:check
- name: noImplicitAny ratchet
@@ -49,3 +51,22 @@ jobs:
cache: npm
- run: npm ci --ignore-scripts
- run: npm run test:unit
+
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build image
+ run: docker build -t sdk-ci .
+ - name: Smoke test (config is rendered from SDK_* variables at start)
+ run: |
+ docker run -d --name sdk -p 8080:8080 \
+ -e SDK_INFINITY_REST_SERVER_URL=https://pega.example.com/prweb \
+ -e SDK_APP_ALIAS=SmokeApp sdk-ci
+ for i in $(seq 1 30); do curl -fsS http://localhost:8080/healthz && break; sleep 1; done
+ curl -fsS http://localhost:8080/sdk-config.json | grep -q '"appAlias": "SmokeApp"'
+ curl -fsS -o /dev/null http://localhost:8080/portal
+ curl -fsS -o /dev/null http://localhost:8080/embedded
+ - name: Container logs
+ if: failure()
+ run: docker logs sdk
diff --git a/.gitignore b/.gitignore
index 9d9a0fd25..cd36d4e93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,7 @@ packages/angular-sdk-overrides/SECURITY.md
**/tsconfig.*.tsbuildinfo
temp/
+
+# local environment files
+.env
+.env.local
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..a45fd52cc
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/AGENTS.md b/AGENTS.md
index 95ace5258..d1fb9ac07 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,6 +85,8 @@ angular-sdk-components/
| `npm run check:any` / `check:any:update` | `noImplicitAny` per-file ratchet (do not add new implicit-any errors) |
| `npm run api:check` / `api:update` | Public API report guard (`etc/angular-sdk-components.api.md`) |
| `npm run check:overrides` | Type-check the generated overrides package against the built library |
+| `npm run doctor` / `npm run configure` | Pre-flight environment check / render `sdk-config.json` from `SDK_*` env vars (see docs/configuration.md) |
+| `npm run test:scripts` | Unit tests for the tooling scripts (`scripts/__tests__`) |
| `npm run docs:components` | Regenerate `docs/components.md` from the component map |
### Prerequisites
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..08cd8b279
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+# syntax=docker/dockerfile:1
+
+# ---- build: compile the Angular app with the production configuration ----
+FROM node:24-alpine AS build
+WORKDIR /app
+COPY package.json package-lock.json .npmrc ./
+RUN npm ci --ignore-scripts
+COPY . .
+# Optional build-time sdk-config values; prefer runtime SDK_* variables (see docs/configuration.md)
+RUN npm run prod-build-angularsdk
+
+# ---- runtime: static files behind nginx, sdk-config.json generated from SDK_* env vars at start ----
+FROM nginxinc/nginx-unprivileged:alpine AS runtime
+USER root
+RUN apk add --no-cache nodejs
+COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html
+COPY --chown=101:101 scripts/configure-sdk.js /opt/sdk/scripts/configure-sdk.js
+COPY --chown=101:101 scripts/lib /opt/sdk/scripts/lib
+COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --chmod=755 docker/entrypoint.sh /docker-entrypoint.d/40-sdk-config.sh
+USER 101
+EXPOSE 8080
+HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
diff --git a/README.md b/README.md
index 8a8630278..06b3f1710 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,27 @@ with the Angular SDK using the information (including pointers to online documen
[**Pega Community**](https://community.pega.com/marketplace/component/angular-sdk) and the Angular SDK code
on [**GitHub**](https://community.pega.com/marketplace/component/angular-sdk).
+## Quick start for developers
+
+Clone this repository to build and customize your own Constellation Angular app, then:
+
+```bash
+npm ci
+cp .env.example .env && $EDITOR .env # Infinity URL, OAuth client ID, ...
+set -a; source .env; set +a
+npm run configure # writes sdk-config.json from the environment
+npm run doctor # pre-flight check of your setup
+npm run start-dev # http://localhost:3500
+```
+
+| | |
+| --- | --- |
+| Setup walkthrough | [docs/getting-started.md](docs/getting-started.md) |
+| Configuration reference (`sdk-config.json`, environment variables) | [docs/configuration.md](docs/configuration.md) |
+| CI/CD (GitHub Actions, Azure DevOps, GitLab, Jenkins), Docker, E2E | [docs/ci-cd.md](docs/ci-cd.md) |
+| Customize, override or add components | [docs/customizing.md](docs/customizing.md) |
+| Troubleshooting | [docs/troubleshooting.md](docs/troubleshooting.md) |
+
## Packages in this repo
* [**angular-sdk-components**](https://www.npmjs.com/package/@pega/angular-sdk-components)
diff --git a/ci-templates/Jenkinsfile b/ci-templates/Jenkinsfile
new file mode 100644
index 000000000..ea6e04582
--- /dev/null
+++ b/ci-templates/Jenkinsfile
@@ -0,0 +1,39 @@
+// Required credentials/env: SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID (job env or folder properties),
+// 'sdk-mashup-password' (secret text) and optionally SDK_E2E_BASE_URL.
+pipeline {
+ agent { docker { image 'node:24' } }
+ options { timestamps() }
+ stages {
+ stage('Install') { steps { sh 'npm ci --ignore-scripts' } }
+ stage('Pre-flight') { steps { sh 'npm run doctor -- --offline' } }
+ stage('Lint') { steps { sh 'npm run lint' } }
+ stage('Configure') {
+ steps {
+ withCredentials([string(credentialsId: 'sdk-mashup-password', variable: 'SDK_MASHUP_PASSWORD')]) {
+ sh 'npm run configure'
+ }
+ }
+ }
+ stage('Build') {
+ steps {
+ sh 'npm run prod-build-angularsdk'
+ archiveArtifacts artifacts: 'dist/**', fingerprint: true
+ }
+ }
+ stage('E2E') {
+ when { expression { return env.SDK_E2E_BASE_URL?.trim() } }
+ agent { docker { image 'mcr.microsoft.com/playwright:v1.63.0-jammy' } }
+ environment { CI = 'true'; PW_SLOW_MO = '0' }
+ steps {
+ sh 'npm ci --ignore-scripts'
+ sh 'npm test'
+ }
+ post {
+ always {
+ junit 'test-results/junit.xml'
+ archiveArtifacts artifacts: 'tests/playwright-report/**', allowEmptyArchive: true
+ }
+ }
+ }
+ }
+}
diff --git a/ci-templates/azure-pipelines.yml b/ci-templates/azure-pipelines.yml
new file mode 100644
index 000000000..24c61ffba
--- /dev/null
+++ b/ci-templates/azure-pipelines.yml
@@ -0,0 +1,66 @@
+# Copy to azure-pipelines.yml
+# Define these variables on the pipeline (mark SDK_MASHUP_PASSWORD as secret):
+# SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID, SDK_MASHUP_PASSWORD, SDK_E2E_BASE_URL (optional)
+trigger:
+ branches:
+ include: [main, master]
+pr:
+ branches:
+ include: ['*']
+
+pool:
+ vmImage: ubuntu-latest
+
+stages:
+ - stage: Build
+ jobs:
+ - job: build
+ steps:
+ - task: NodeTool@0
+ inputs:
+ versionSource: fromFile
+ versionFilePath: .nvmrc
+ - script: npm ci --ignore-scripts
+ displayName: Install
+ - script: npm run doctor -- --offline
+ displayName: Pre-flight
+ - script: npm run lint
+ displayName: Lint
+ - script: npm run configure
+ displayName: Render sdk-config.json
+ env:
+ SDK_INFINITY_REST_SERVER_URL: $(SDK_INFINITY_REST_SERVER_URL)
+ SDK_APP_ALIAS: $(SDK_APP_ALIAS)
+ SDK_PORTAL_CLIENT_ID: $(SDK_PORTAL_CLIENT_ID)
+ SDK_MASHUP_PASSWORD: $(SDK_MASHUP_PASSWORD)
+ - script: npm run prod-build-angularsdk
+ displayName: Production build
+ - publish: dist
+ artifact: sdk-dist
+
+ - stage: E2E
+ dependsOn: Build
+ condition: and(succeeded(), ne(variables['SDK_E2E_BASE_URL'], ''))
+ jobs:
+ - job: playwright
+ steps:
+ - task: NodeTool@0
+ inputs:
+ versionSource: fromFile
+ versionFilePath: .nvmrc
+ - script: npm ci --ignore-scripts && npx playwright install --with-deps chromium
+ displayName: Install
+ - script: npm test
+ displayName: Playwright
+ env:
+ CI: 'true'
+ PW_SLOW_MO: '0'
+ SDK_E2E_BASE_URL: $(SDK_E2E_BASE_URL)
+ - task: PublishTestResults@2
+ condition: always()
+ inputs:
+ testResultsFormat: JUnit
+ testResultsFiles: test-results/junit.xml
+ - publish: tests/playwright-report
+ artifact: playwright-report
+ condition: always()
diff --git a/ci-templates/github-actions.yml b/ci-templates/github-actions.yml
new file mode 100644
index 000000000..7b8cd9791
--- /dev/null
+++ b/ci-templates/github-actions.yml
@@ -0,0 +1,76 @@
+# Copy to .github/workflows/sdk-ci.yml
+#
+# Required repository settings
+# Variables (Settings > Secrets and variables > Actions > Variables):
+# SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID, SDK_E2E_BASE_URL (optional, deployed env for E2E)
+# Secrets: SDK_MASHUP_PASSWORD (only if you use the embedded/mashup flow)
+name: sdk-ci
+
+on:
+ pull_request:
+ push:
+ branches: [main, master]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sdk-ci-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ SDK_INFINITY_REST_SERVER_URL: ${{ vars.SDK_INFINITY_REST_SERVER_URL }}
+ SDK_APP_ALIAS: ${{ vars.SDK_APP_ALIAS }}
+ SDK_PORTAL_CLIENT_ID: ${{ vars.SDK_PORTAL_CLIENT_ID }}
+ SDK_MASHUP_PASSWORD: ${{ secrets.SDK_MASHUP_PASSWORD }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - name: Pre-flight (no network)
+ run: npm run doctor -- --offline
+ - name: Lint
+ run: npm run lint
+ - name: Render sdk-config.json from environment
+ run: npm run configure
+ - name: Production build
+ run: npm run prod-build-angularsdk
+ - uses: actions/upload-artifact@v4
+ with:
+ name: sdk-dist
+ path: dist
+ retention-days: 7
+
+ e2e:
+ # Runs against an already deployed environment; remove this job if you have no E2E environment.
+ if: ${{ vars.SDK_E2E_BASE_URL != '' }}
+ needs: build
+ runs-on: ubuntu-latest
+ env:
+ CI: 'true'
+ SDK_E2E_BASE_URL: ${{ vars.SDK_E2E_BASE_URL }}
+ PW_SLOW_MO: '0'
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - run: npx playwright install --with-deps chromium
+ - run: npm test
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report
+ path: |
+ tests/playwright-report
+ test-results
diff --git a/ci-templates/gitlab-ci.yml b/ci-templates/gitlab-ci.yml
new file mode 100644
index 000000000..b8efb4e35
--- /dev/null
+++ b/ci-templates/gitlab-ci.yml
@@ -0,0 +1,39 @@
+# Copy to .gitlab-ci.yml
+# CI/CD variables: SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID, SDK_MASHUP_PASSWORD (masked), SDK_E2E_BASE_URL (optional)
+image: node:24
+
+stages: [build, e2e]
+
+cache:
+ key:
+ files: [package-lock.json]
+ paths: [.npm/]
+
+build:
+ stage: build
+ script:
+ - npm ci --ignore-scripts --cache .npm --prefer-offline
+ - npm run doctor -- --offline
+ - npm run lint
+ - npm run configure
+ - npm run prod-build-angularsdk
+ artifacts:
+ paths: [dist]
+ expire_in: 1 week
+
+e2e:
+ stage: e2e
+ image: mcr.microsoft.com/playwright:v1.63.0-jammy
+ rules:
+ - if: $SDK_E2E_BASE_URL
+ variables:
+ CI: 'true'
+ PW_SLOW_MO: '0'
+ script:
+ - npm ci --ignore-scripts --cache .npm --prefer-offline
+ - npm test
+ artifacts:
+ when: always
+ paths: [tests/playwright-report, test-results]
+ reports:
+ junit: test-results/junit.xml
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 000000000..607e1e110
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Runs before nginx starts (nginx image executes /docker-entrypoint.d/*.sh): renders sdk-config.json from SDK_* variables.
+set -eu
+
+WEB_ROOT="${SDK_WEB_ROOT:-/usr/share/nginx/html}"
+SCRIPT_DIR="${SDK_SCRIPT_DIR:-/opt/sdk/scripts}"
+
+node "$SCRIPT_DIR/configure-sdk.js" --file "$WEB_ROOT/sdk-config.json" --out "$WEB_ROOT/sdk-config.json"
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 000000000..59684a3f7
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,33 @@
+server {
+ listen 8080;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ gzip_static on;
+ gzip on;
+ gzip_types text/css application/javascript application/json image/svg+xml;
+
+ location = /healthz {
+ access_log off;
+ default_type text/plain;
+ return 200 "ok\n";
+ }
+
+ # Runtime configuration and entry pages must never be cached, otherwise config changes are not picked up.
+ location = /sdk-config.json {
+ add_header Cache-Control "no-store";
+ }
+
+ # Fingerprinted bundles are safe to cache for a long time.
+ location ~* \.(?:js|css|woff2?|svg|png|ico)$ {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
+ # portal.html, embedded.html, fullportal.html, simpleportal.html and mashup.html are generated by the build.
+ location / {
+ add_header Cache-Control "no-cache";
+ try_files $uri $uri.html /index.html;
+ }
+}
diff --git a/docs/ci-cd.md b/docs/ci-cd.md
new file mode 100644
index 000000000..6e5eb5f9b
--- /dev/null
+++ b/docs/ci-cd.md
@@ -0,0 +1,71 @@
+# CI/CD
+
+Everything a pipeline needs is exposed as an npm script and is configured through environment variables, so the same repository builds for every environment and no secret is committed.
+
+## The pipeline in five steps
+
+| Step | Command | Notes |
+| --- | --- | --- |
+| Install | `npm ci --ignore-scripts` | Deterministic; `--ignore-scripts` skips the git-hook installer, which is not needed in CI. |
+| Pre-flight | `npm run doctor -- --offline` | Fails fast on wrong Node version, missing dependencies or an invalid `sdk-config.json`. |
+| Quality | `npm run lint` and `npm run test:unit` | Unit tests need no Pega server. |
+| Configure | `npm run configure` | Applies `SDK_*` variables ([configuration.md](configuration.md)). |
+| Build | `npm run prod-build-angularsdk` | Output in `dist/` (brotli/gzip compressed). |
+
+Optional: `npm test` (Playwright) against a deployed environment, and `docker build .` for a container image.
+
+## Ready-made templates
+
+Copy the one for your platform from [`ci-templates/`](../ci-templates):
+
+| Platform | File |
+| --- | --- |
+| GitHub Actions | `github-actions.yml` |
+| Azure DevOps | `azure-pipelines.yml` |
+| GitLab CI | `gitlab-ci.yml` |
+| Jenkins | `Jenkinsfile` |
+
+Each template: caches dependencies, builds, publishes `dist/` as an artifact, and runs the E2E stage only when `SDK_E2E_BASE_URL` is defined, publishing JUnit results and the HTML report. Define `SDK_INFINITY_REST_SERVER_URL`, `SDK_APP_ALIAS` and `SDK_PORTAL_CLIENT_ID` as variables and `SDK_MASHUP_PASSWORD` as a secret.
+
+## Build once, deploy many
+
+```bash
+npm run prod-build-angularsdk # once
+SDK_INFINITY_REST_SERVER_URL=https://test.example.com/prweb \
+ npm run configure -- --out dist/sdk-config.json # per environment
+```
+
+Because `sdk-config.json` is read by the browser at runtime, you can promote the same `dist/` through test, staging and production.
+
+## Docker
+
+```bash
+docker build -t my-sdk .
+docker run -p 8080:8080 \
+ -e SDK_INFINITY_REST_SERVER_URL=https://my-pega.example.com/prweb \
+ -e SDK_PORTAL_CLIENT_ID=... -e SDK_APP_ALIAS=MediaCo my-sdk
+```
+
+- Multi-stage build; the runtime image is non-root nginx on port 8080.
+- `sdk-config.json` is rendered from `SDK_*` variables every time the container starts; no rebuild is needed to change environments.
+- `GET /healthz` is the liveness/readiness endpoint.
+- Entry pages: `/portal`, `/embedded`, `/fullportal`, `/simpleportal`.
+- Register `https:///` as an allowed redirect URI on the Pega OAuth client.
+
+## End-to-end tests in CI
+
+```bash
+CI=true SDK_E2E_BASE_URL=https://test.example.com PW_SLOW_MO=0 npm test
+```
+
+- Needs a Pega Infinity server with the MediaCo sample application (see [testing.md](testing.md)). To test your own application, adapt the specs under `projects/angular-test-app/tests`.
+- With `CI` set you get retries, a JUnit report (`test-results/junit.xml`), the HTML report (`tests/playwright-report`), and traces/videos for failures.
+- Install browsers on the agent first: `npx playwright install --with-deps chromium`.
+
+## Keeping your fork current
+
+This repository publishes `@pega/angular-sdk-components` and `@pega/angular-sdk-overrides`. When you track upstream, watch the public API report (`etc/angular-sdk-components.api.md`) and `CHANGELOG.md` for changes that affect your customizations; the `quality` workflow in this repository shows the checks worth keeping in your own pipeline (`api:check`, `check:overrides`, `check:any`).
+
+## Troubleshooting pipelines
+
+See [troubleshooting.md](troubleshooting.md).
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 000000000..4c8802a98
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,56 @@
+# Configuration
+
+Runtime settings live in `sdk-config.json` in the repository root. It is copied to the root of the build output (`dist/sdk-config.json`) and fetched by the browser at startup, so **it can be changed after the build without recompiling**.
+
+## Environment variables
+
+`npm run configure` (`node scripts/configure-sdk.js`) applies these variables to `sdk-config.json`. Unset or empty variables leave the file value untouched, so you can mix a committed base file with per-environment overrides.
+
+| Variable | `sdk-config.json` setting | Notes |
+| --- | --- | --- |
+| `SDK_INFINITY_REST_SERVER_URL` | `serverConfig.infinityRestServerUrl` | Required. Full URL of the Infinity REST server, for example `https://host/prweb` (no trailing slash). |
+| `SDK_PORTAL_CLIENT_ID` | `authConfig.portalClientId` | Required. OAuth 2.0 client ID for the portal use case. |
+| `SDK_APP_ALIAS` | `serverConfig.appAlias` | Application alias operators will use. |
+| `SDK_CONTENT_SERVER_URL` | `serverConfig.sdkContentServerUrl` | Blank means `window.location.origin`. |
+| `SDK_APP_PORTAL` | `serverConfig.appPortal` | Blank means the operator's default portal. |
+| `SDK_APP_MASHUP_CASE_TYPE` | `serverConfig.appMashupCaseType` | Case type for embedded/mashup. |
+| `SDK_SHOW_MODALS_IN_EMBEDDED_MODE` | `serverConfig.showModalsInEmbeddedMode` | `true` or `false`. |
+| `SDK_MASHUP_CLIENT_ID` | `authConfig.mashupClientId` | Mashup OAuth client ID. |
+| `SDK_MASHUP_USER_IDENTIFIER` | `authConfig.mashupUserIdentifier` | |
+| `SDK_MASHUP_PASSWORD` | `authConfig.mashupPassword` | **Provide plain text; it is Base64 encoded into the file.** Store it as a CI secret. |
+| `SDK_AUTH_SERVICE` | `authConfig.authService` | |
+| `SDK_THEME` | `theme` | For example `dark`. |
+
+Other settings in the file (for example `excludePortals`) are documented in the [official guide](https://docs.pega.com/bundle/constellation-sdk/page/constellation-sdks/sdks/configuring-sdk-config-json.html).
+
+## Commands
+
+```bash
+npm run configure # apply env vars in place
+npm run configure -- --out dist/sdk-config.json # write the result to another file (the source is untouched)
+npm run configure -- --print # also print the result (secrets masked)
+npm run configure:check # validate only; exit 1 on errors, nothing written
+```
+
+## Choosing where to apply configuration
+
+| Approach | When |
+| --- | --- |
+| `npm run configure` before the build | One build per environment (simple pipelines). |
+| `npm run configure -- --out dist/sdk-config.json` after the build | Build once, deploy many: promote the same `dist/` and render the config per environment. |
+| Container with `SDK_*` variables | Kubernetes/ECS: the image renders `sdk-config.json` at container start. See [ci-cd.md](ci-cd.md#docker). |
+
+## Secrets
+
+Never commit `mashupPassword` or any credential. `sdk-config.json` is a public file served to browsers: only put values there that you would be comfortable sending to every user. Use CI secrets for `SDK_MASHUP_PASSWORD` and rotate it if it was ever committed.
+
+## End-to-end test settings
+
+| Variable | Purpose |
+| --- | --- |
+| `SDK_E2E_BASE_URL` | Deployed app the Playwright suite targets (default `http://localhost:3500`). |
+| `PW_START_SERVER=1` | Let Playwright start `npm run start-prod` itself. `PW_SERVER_COMMAND` overrides the command. |
+| `PW_SLOW_MO` | Milliseconds between actions (default 200 locally; use `0` in CI). |
+| `PW_WORKERS` | Parallel workers (default 1 in CI). |
+| `PW_JUNIT_OUTPUT` | JUnit file path in CI (default `test-results/junit.xml`). |
+| `CI` | When set: JUnit + HTML + list reporters, retries, video on failure. |
diff --git a/docs/customizing.md b/docs/customizing.md
new file mode 100644
index 000000000..c82bc9504
--- /dev/null
+++ b/docs/customizing.md
@@ -0,0 +1,57 @@
+# Customizing
+
+Three levels, from least to most invasive. Pick the lowest one that does the job; it keeps upgrades easy.
+
+## 1. Configuration and theming
+
+- Settings: [configuration.md](configuration.md).
+- Look and feel: [theming.md](theming.md) (Material 3 tokens, dark theme).
+
+## Overriding a component
+
+Replace one Pega-provided component with your own implementation without editing the original.
+
+1. Create your component (copy the original from `packages/angular-sdk-components/src/lib/_components/` as a starting point, or scaffold one, see below).
+2. Register it in `packages/angular-sdk-components/src/sdk-local-component-map.ts`:
+
+ ```ts
+ import { MyTextComponent } from './lib/_components/field/my-text/my-text.component';
+
+ const localSdkComponentMap = {
+ Text: MyTextComponent
+ /* map end - DO NOT REMOVE */
+ };
+ ```
+
+ Entries in the local map win over the Pega-provided map, so `Text` now renders `MyTextComponent` everywhere.
+3. Rules for the replacement: field components extend `FieldBase`, receive `pConn$`/`formGroup$` inputs, and render children through `` (see [architecture.md](architecture.md)).
+
+The `@pega/angular-sdk-overrides` npm package contains ready-to-edit copies of every component for projects that consume the published packages instead of this repository.
+
+## Adding a component
+
+```bash
+npm run new:component --
+# example
+npm run new:component -- field star-rating StarRating
+```
+
+It creates the component (`.ts`, `.html`, `.scss`, `.spec.ts`) following the repository conventions and registers it in `public-api.ts` and `sdk-pega-component-map.ts`. Then:
+
+```bash
+npm run fix # format
+npm run build-angular-sdk-components && npm run api:update # refresh the public API report
+npm run docs:components # refresh the component catalogue
+```
+
+## Editing a component in place
+
+Allowed and supported. Keep these in mind:
+
+- Field value propagation, display-mode delegation and `` usage are described in `AGENTS.md` and `.github/instructions/components.instructions.md`.
+- Run `npm run test:unit` and the relevant parts of the Playwright suite.
+- `npm run check:any` guards against new implicit-`any` errors; `npm run api:check` flags public API changes.
+
+## Catalogue
+
+Every component name that Pega can render and the Angular class that implements it: [components.md](components.md).
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 000000000..a7ab3243c
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,74 @@
+# Getting started
+
+Goal: from a fresh checkout to a running SDK app against your Pega Infinity server.
+
+## 1. Prerequisites
+
+- Node.js 24+ (`nvm use` reads `.nvmrc`) and npm.
+- A Pega Infinity server with an OAuth 2.0 client registration for the SDK (see the [Constellation SDK docs](https://docs.pega.com/bundle/constellation-sdk/page/constellation-sdks/sdks/constellation-sdks.html)) and the application you want to render.
+- Prefer zero setup? Open the repo in GitHub Codespaces or a Dev Container (`.devcontainer/`); dependencies and Playwright browsers are installed for you.
+
+## 2. Install
+
+```bash
+git clone https://github.com/pegasystems/angular-sdk-components.git
+cd angular-sdk-components
+npm ci
+```
+
+## 3. Configure
+
+Edit `sdk-config.json`, **or** keep the file untouched and use environment variables (recommended for teams and CI; nothing environment-specific or secret gets committed):
+
+```bash
+cp .env.example .env # fill in the values
+set -a; source .env; set +a
+npm run configure # writes the values into sdk-config.json
+```
+
+At minimum set `SDK_INFINITY_REST_SERVER_URL` and `SDK_PORTAL_CLIENT_ID`. All settings are described in [configuration.md](configuration.md).
+
+## 4. Check your environment
+
+```bash
+npm run doctor
+```
+
+It verifies the Node version, installed dependencies, `sdk-config.json`, HTTPS keys, port 3500 and that your Infinity server is reachable, and tells you how to fix anything that is wrong. Use `npm run doctor -- --offline` to skip the network probe.
+
+## 5. Run
+
+```bash
+npm run start-dev # http://localhost:3500
+npm run start-dev-https # with the bundled dev certificate in keys/
+```
+
+Entry pages: `/portal` (full portal), `/embedded` (mashup / embedded), `/fullportal`, `/simpleportal`.
+
+## 6. Make it yours
+
+- Change a component: edit it under `packages/angular-sdk-components/src/lib/_components/`.
+- Add a component: `npm run new:component -- field star-rating StarRating` (see [customizing.md](customizing.md)).
+- Override a Pega-provided component without editing the originals: [customizing.md](customizing.md#overriding-a-component).
+
+## 7. Verify and ship
+
+```bash
+npm run lint
+npm run test:unit # no Pega server needed
+npm run prod-build-angularsdk
+```
+
+Automate this with the pipeline templates in [ci-cd.md](ci-cd.md), or build a container image (`docker build -t my-sdk .`).
+
+## Where to go next
+
+| I want to... | Read |
+| --- | --- |
+| understand how it works | [architecture.md](architecture.md) |
+| configure for dev/test/prod | [configuration.md](configuration.md) |
+| set up CI/CD, Docker, E2E | [ci-cd.md](ci-cd.md) |
+| customize or add components | [customizing.md](customizing.md) |
+| write tests | [testing.md](testing.md) |
+| theme the app | [theming.md](theming.md) |
+| fix a problem | [troubleshooting.md](troubleshooting.md) |
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 000000000..d6914a6a4
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,40 @@
+# Troubleshooting
+
+Start with `npm run doctor`; it detects most setup problems and prints how to fix them. Pega's [Troubleshooting Constellation SDKs](https://docs.pega.com/bundle/constellation-sdk/page/constellation-sdks/sdks/troubleshooting-constellation-sdks.html) page covers platform-side issues.
+
+## Setup
+
+| Symptom | Likely cause and fix |
+| --- | --- |
+| `engines` / syntax errors on install or build | Node older than 24. `nvm use` (reads `.nvmrc`). |
+| `doctor`: "node_modules is missing" | Run `npm ci`. |
+| `npm ci` cannot reach packages | `.npmrc` points at the public npm registry. Behind a corporate proxy/registry, override it in your user-level `.npmrc` or CI environment, and prefer `npm ci` over `npm install`. |
+| Port 3500 already in use | Stop the other process or run `npx ng serve --port 3501`. Update `SDK_E2E_BASE_URL` accordingly. |
+| Browser warns about the HTTPS certificate | Expected with the bundled dev certificate in `keys/`; trust it locally or use `start-dev` over HTTP. |
+
+## Configuration and login
+
+| Symptom | Likely cause and fix |
+| --- | --- |
+| `npm run configure` fails validation | The message names the setting and the environment variable that sets it. |
+| Blank page or network error on load | `serverConfig.infinityRestServerUrl` is wrong or unreachable (VPN, trailing slash, certificate). `npm run doctor` probes it. |
+| Login redirect loop / `redirect_uri` error | The URL you open (including port and path) must be registered as a redirect URI on the Pega OAuth 2.0 client. |
+| CORS errors | Add your app origin to the Infinity CORS configuration. |
+| Embedded/mashup login fails | `mashupClientId`, `mashupUserIdentifier` and the Base64 `mashupPassword` must be set (`SDK_MASHUP_PASSWORD` is encoded for you). |
+| Config changes have no effect in a container/CDN | `sdk-config.json` is cached. Ensure it is served with `Cache-Control: no-store` (the bundled nginx config does this). |
+
+## Build and CI
+
+| Symptom | Likely cause and fix |
+| --- | --- |
+| `check:overrides` fails with "Cannot find module '@pega/angular-sdk-components'" | The library has not been built (`npm run build-angular-sdk-components`), or `npm run build` replaced `dist/`. |
+| `api:check` fails | The public API changed. If intended: `npm run build-angular-sdk-components && npm run api:update` and commit `etc/angular-sdk-components.api.md`. |
+| `check:any` fails | A file got more implicit-`any` errors than its baseline. Add types; if you fixed errors, `npm run check:any:update`. |
+| `docs:components:check` fails | `npm run docs:components` and commit. |
+| Production build exceeds a style budget | See the budgets in `angular.json`; keep component styles small. |
+| Playwright can't find browsers on the agent | `npx playwright install --with-deps chromium`, or use the Playwright Docker image (see the templates). |
+| E2E tests time out in CI | Check `SDK_E2E_BASE_URL` is reachable from the agent and the test users exist in the target app. |
+
+## Still stuck?
+
+Open an issue with the output of `npm run doctor`, your Node/npm versions and the failing command's log (remove secrets first).
diff --git a/package.json b/package.json
index 154597a16..e121059bc 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,11 @@
"new:component": "node scripts/new-component.js",
"check:overrides": "ngc -p tsconfig.overrides-check.json",
"docs:components": "node scripts/generate-component-catalog.js && prettier -w docs/components.md",
- "docs:components:check": "node scripts/generate-component-catalog.js --check"
+ "docs:components:check": "node scripts/generate-component-catalog.js --check",
+ "configure": "node scripts/configure-sdk.js",
+ "configure:check": "node scripts/configure-sdk.js --check",
+ "doctor": "node scripts/doctor.js",
+ "test:scripts": "node --test scripts/__tests__"
},
"dependencies": {
"@angular/animations": "^21.2.4",
diff --git a/playwright.config.js b/playwright.config.js
index 97a484764..df7635ce9 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -27,21 +27,32 @@ const config = {
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
- workers: process.env.CI ? 1 : undefined,
+ workers: process.env.PW_WORKERS ? Number(process.env.PW_WORKERS) : process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: [['html', { outputFolder: 'tests/playwright-report' }]],
+ // CI gets machine-readable output (JUnit for Azure DevOps/Jenkins/GitLab, annotations on GitHub) next to the HTML report.
+ reporter: process.env.CI
+ ? [
+ ['list'],
+ ['html', { outputFolder: 'tests/playwright-report', open: 'never' }],
+ ['junit', { outputFile: process.env.PW_JUNIT_OUTPUT || 'test-results/junit.xml' }],
+ ...(process.env.GITHUB_ACTIONS ? [['github']] : [])
+ ]
+ : [['html', { outputFolder: 'tests/playwright-report' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 50000,
/* Base URL to use in actions like `await page.goto('/')`. */
- // baseURL: 'http://localhost:3000',
+ baseURL: process.env.SDK_E2E_BASE_URL || 'http://localhost:3500',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: process.env.CI ? 'retain-on-failure' : 'off',
ignoreHTTPSErrors: true,
launchOptions: {
- slowMo: 200
+ // Override with PW_SLOW_MO=0 for faster CI runs
+ slowMo: Number(process.env.PW_SLOW_MO ?? 200)
}
},
testIgnore: ['e2e/DigV2/ComplexFields/ManyToMany.spec.js', 'e2e/DigV2/Localization/Localization.spec.js'],
@@ -95,16 +106,22 @@ const config = {
// channel: 'chrome',
// },
// },
- ]
+ ],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
- // webServer: {
- // command: 'npm run start',
- // port: 3000,
- // },
+ // Set PW_START_SERVER=1 to have Playwright build-serve the app itself (needs sdk-config.json to be configured).
+ webServer: process.env.PW_START_SERVER
+ ? {
+ command: process.env.PW_SERVER_COMMAND || 'npm run start-prod',
+ url: process.env.SDK_E2E_BASE_URL || 'http://localhost:3500',
+ reuseExistingServer: !process.env.CI,
+ timeout: 10 * 60 * 1000,
+ ignoreHTTPSErrors: true
+ }
+ : undefined
};
module.exports = config;
diff --git a/projects/angular-test-app/tests/config.js b/projects/angular-test-app/tests/config.js
index 84a4eface..e0100ec68 100644
--- a/projects/angular-test-app/tests/config.js
+++ b/projects/angular-test-app/tests/config.js
@@ -1,6 +1,10 @@
+// Point the suite at any deployment: SDK_E2E_BASE_URL=https://my-env.example.com npx playwright test
+const origin = (process.env.SDK_E2E_BASE_URL || 'http://localhost:3500').replace(/\/$/, '');
+
const config = {
- baseUrl: 'http://localhost:3500/portal',
- baseEmbedUrl: 'http://localhost:3500/embedded',
+ origin,
+ baseUrl: `${origin}/portal`,
+ baseEmbedUrl: `${origin}/embedded`,
apps: {
mediaCo: {
rep: {
diff --git a/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js b/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js
index 294f0203e..13b102a0e 100644
--- a/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js
+++ b/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js
@@ -4,7 +4,7 @@ const common = require('../../../common');
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
- await page.goto('http://localhost:3500/portal?portal=DigV2SelfService');
+ await page.goto(`${config.config.baseUrl}?portal=DigV2SelfService`);
});
test.describe('E2E test', () => {
diff --git a/scripts/__tests__/sdk-config.test.js b/scripts/__tests__/sdk-config.test.js
new file mode 100644
index 000000000..a08fd98cb
--- /dev/null
+++ b/scripts/__tests__/sdk-config.test.js
@@ -0,0 +1,65 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { applyEnv, validate, redact } = require('../lib/sdk-config');
+
+const base = () => ({
+ theme: 'dark',
+ authConfig: { portalClientId: 'abc', mashupClientId: '', mashupPassword: '' },
+ serverConfig: { infinityRestServerUrl: 'https://pega.example.com/prweb', appAlias: '', showModalsInEmbeddedMode: false }
+});
+
+test('applyEnv overrides only the variables that are set', () => {
+ const { config, applied } = applyEnv(base(), { SDK_APP_ALIAS: 'MediaCo', SDK_THEME: '' });
+ assert.equal(config.serverConfig.appAlias, 'MediaCo');
+ assert.equal(config.theme, 'dark');
+ assert.deepEqual(applied, ['SDK_APP_ALIAS']);
+});
+
+test('applyEnv does not mutate its input', () => {
+ const input = base();
+ applyEnv(input, { SDK_APP_ALIAS: 'X' });
+ assert.equal(input.serverConfig.appAlias, '');
+});
+
+test('applyEnv Base64 encodes the mashup password', () => {
+ const { config } = applyEnv(base(), { SDK_MASHUP_PASSWORD: 'p@ss' });
+ assert.equal(config.authConfig.mashupPassword, Buffer.from('p@ss').toString('base64'));
+});
+
+test('applyEnv parses booleans and rejects invalid values', () => {
+ assert.equal(applyEnv(base(), { SDK_SHOW_MODALS_IN_EMBEDDED_MODE: 'TRUE' }).config.serverConfig.showModalsInEmbeddedMode, true);
+ assert.throws(() => applyEnv(base(), { SDK_SHOW_MODALS_IN_EMBEDDED_MODE: 'yes' }), /must be "true" or "false"/);
+});
+
+test('validate accepts a complete config', () => {
+ assert.deepEqual(validate(base()).errors, []);
+});
+
+test('validate reports missing and malformed values', () => {
+ const cfg = base();
+ cfg.serverConfig.infinityRestServerUrl = 'not a url';
+ cfg.authConfig.portalClientId = '';
+ const { errors } = validate(cfg);
+ assert.equal(errors.length, 2);
+
+ cfg.serverConfig.infinityRestServerUrl = '';
+ assert.match(validate(cfg).errors[0], /empty/);
+});
+
+test('validate warns about trailing slash and mashup without password', () => {
+ const cfg = base();
+ cfg.serverConfig.infinityRestServerUrl = 'https://pega.example.com/prweb/';
+ cfg.authConfig.mashupClientId = 'm1';
+ const { warnings } = validate(cfg);
+ assert.ok(warnings.some(w => /slash/.test(w)));
+ assert.ok(warnings.some(w => /mashupPassword/.test(w)));
+});
+
+test('redact masks secrets without touching the original', () => {
+ const cfg = base();
+ cfg.authConfig.mashupPassword = 'c2VjcmV0';
+ assert.equal(redact(cfg).authConfig.mashupPassword, '********');
+ assert.equal(cfg.authConfig.mashupPassword, 'c2VjcmV0');
+});
diff --git a/scripts/configure-sdk.js b/scripts/configure-sdk.js
new file mode 100644
index 000000000..e5e20a3f6
--- /dev/null
+++ b/scripts/configure-sdk.js
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+'use strict';
+
+/*
+ * Configures sdk-config.json from environment variables so the same checkout can be built for any environment in CI
+ * without committing environment-specific values or secrets.
+ *
+ * node scripts/configure-sdk.js apply SDK_* env vars to ./sdk-config.json in place
+ * node scripts/configure-sdk.js --out dist/sdk-config.json
+ * node scripts/configure-sdk.js --check validate only (exit 1 on errors), nothing is written
+ * node scripts/configure-sdk.js --print print the resulting config with secrets masked
+ *
+ * See docs/configuration.md for the variable list.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { applyEnv, validate, redact } = require('./lib/sdk-config');
+
+function parseArgs(argv) {
+ const args = { file: 'sdk-config.json', out: undefined, check: false, print: false };
+ for (let i = 0; i < argv.length; i += 1) {
+ const a = argv[i];
+ if (a === '--check') args.check = true;
+ else if (a === '--print') args.print = true;
+ else if (a === '--file') args.file = argv[++i];
+ else if (a === '--out') args.out = argv[++i];
+ else throw new Error(`Unknown argument: ${a}`);
+ }
+ return args;
+}
+
+function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const file = path.resolve(args.file);
+ const original = JSON.parse(fs.readFileSync(file, 'utf8'));
+ const { config, applied } = applyEnv(original, process.env);
+ const { errors, warnings } = validate(config);
+
+ warnings.forEach(w => console.warn(`warning: ${w}`));
+ errors.forEach(e => console.error(`error: ${e}`));
+
+ if (args.print) console.log(JSON.stringify(redact(config), null, 2));
+ if (errors.length) process.exit(1);
+ if (args.check) {
+ console.log(`${path.relative(process.cwd(), file)} is valid${applied.length ? ` (with overrides: ${applied.join(', ')})` : ''}`);
+ return;
+ }
+
+ const target = path.resolve(args.out || args.file);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`);
+ console.log(`Wrote ${path.relative(process.cwd(), target)}${applied.length ? ` (applied: ${applied.join(', ')})` : ' (no SDK_* variables set)'}`);
+}
+
+try {
+ main();
+} catch (e) {
+ console.error(`error: ${e.message}`);
+ process.exit(1);
+}
diff --git a/scripts/doctor.js b/scripts/doctor.js
new file mode 100644
index 000000000..4206e2cf5
--- /dev/null
+++ b/scripts/doctor.js
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+'use strict';
+
+/*
+ * Pre-flight checks for a new checkout or a CI agent: `npm run doctor` (add `-- --offline` to skip the network probe).
+ * Exits 1 when a blocking problem is found so it can gate a pipeline.
+ */
+const fs = require('node:fs');
+const net = require('node:net');
+const path = require('node:path');
+const { applyEnv, validate } = require('./lib/sdk-config');
+
+const root = path.resolve(__dirname, '..');
+const offline = process.argv.includes('--offline');
+const results = [];
+const add = (level, name, detail) => results.push({ level, name, detail });
+
+function checkNode() {
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
+ const wanted = (pkg.engines && pkg.engines.node) || '';
+ const min = Number((/(\d+)/.exec(wanted) || [])[1] || 0);
+ const major = Number(process.versions.node.split('.')[0]);
+ if (major >= min) add('ok', 'Node.js', `v${process.versions.node} (requires ${wanted || 'any'})`);
+ else add('error', 'Node.js', `v${process.versions.node} is too old; requires ${wanted}. Use "nvm use" (see .nvmrc).`);
+}
+
+function checkDependencies() {
+ const ok = ['@angular/core', '@pega/auth', '@pega/constellationjs'].every(p => fs.existsSync(path.join(root, 'node_modules', p)));
+ if (ok) add('ok', 'Dependencies', 'node_modules present');
+ else add('error', 'Dependencies', 'node_modules is missing or incomplete; run "npm ci" (or "npm install")');
+}
+
+function loadConfig() {
+ const file = path.join(root, 'sdk-config.json');
+ if (!fs.existsSync(file)) {
+ add('error', 'sdk-config.json', 'not found in the repository root');
+ return undefined;
+ }
+ try {
+ const { config, applied } = applyEnv(JSON.parse(fs.readFileSync(file, 'utf8')), process.env);
+ const { errors, warnings } = validate(config);
+ errors.forEach(e => add('error', 'sdk-config.json', e));
+ warnings.forEach(w => add('warn', 'sdk-config.json', w));
+ if (!errors.length) add('ok', 'sdk-config.json', applied.length ? `valid (env overrides: ${applied.join(', ')})` : 'valid');
+ return config;
+ } catch (e) {
+ add('error', 'sdk-config.json', `cannot be parsed: ${e.message}`);
+ return undefined;
+ }
+}
+
+function checkHttpsKeys() {
+ const ok = ['sdk-a.key', 'sdk-a.crt'].every(f => fs.existsSync(path.join(root, 'keys', f)));
+ add(
+ ok ? 'ok' : 'warn',
+ 'HTTPS keys',
+ ok ? 'keys/sdk-a.key and keys/sdk-a.crt found (npm run start-dev-https)' : 'keys/ not found; only needed for start-dev-https'
+ );
+}
+
+function portFree(port) {
+ return new Promise(resolve => {
+ const srv = net.createServer();
+ srv.once('error', () => resolve(false));
+ srv.once('listening', () => srv.close(() => resolve(true)));
+ srv.listen(port, '127.0.0.1');
+ });
+}
+
+async function checkPort() {
+ const free = await portFree(3500);
+ add(free ? 'ok' : 'warn', 'Port 3500', free ? 'available for the dev server' : 'in use; stop the other process or pass --port to ng serve');
+}
+
+async function checkInfinity(config) {
+ if (offline || !config) return;
+ const url = config.serverConfig && config.serverConfig.infinityRestServerUrl;
+ if (!url) return;
+ try {
+ const res = await fetch(url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(8000) });
+ add('ok', 'Pega Infinity', `${url} responded (HTTP ${res.status})`);
+ } catch (e) {
+ const cause = (e.cause && e.cause.code) || e.name;
+ add('warn', 'Pega Infinity', `${url} not reachable (${cause}). Check VPN/URL, CORS and certificates. Use --offline to skip.`);
+ }
+}
+
+(async () => {
+ checkNode();
+ checkDependencies();
+ const config = loadConfig();
+ checkHttpsKeys();
+ await checkPort();
+ await checkInfinity(config);
+
+ const icon = { ok: 'PASS', warn: 'WARN', error: 'FAIL' };
+ results.forEach(r => console.log(`${icon[r.level]} ${r.name}: ${r.detail}`));
+ const failed = results.filter(r => r.level === 'error').length;
+ console.log(failed ? `\n${failed} blocking problem(s) found.` : '\nEnvironment looks good.');
+ process.exit(failed ? 1 : 0);
+})();
diff --git a/scripts/lib/sdk-config.js b/scripts/lib/sdk-config.js
new file mode 100644
index 000000000..865a31c00
--- /dev/null
+++ b/scripts/lib/sdk-config.js
@@ -0,0 +1,112 @@
+'use strict';
+
+/** Environment variable -> sdk-config.json path (dot notation). */
+const ENV_MAP = {
+ SDK_THEME: 'theme',
+ SDK_AUTH_SERVICE: 'authConfig.authService',
+ SDK_PORTAL_CLIENT_ID: 'authConfig.portalClientId',
+ SDK_MASHUP_CLIENT_ID: 'authConfig.mashupClientId',
+ SDK_MASHUP_USER_IDENTIFIER: 'authConfig.mashupUserIdentifier',
+ SDK_INFINITY_REST_SERVER_URL: 'serverConfig.infinityRestServerUrl',
+ SDK_APP_ALIAS: 'serverConfig.appAlias',
+ SDK_CONTENT_SERVER_URL: 'serverConfig.sdkContentServerUrl',
+ SDK_APP_PORTAL: 'serverConfig.appPortal',
+ SDK_APP_MASHUP_CASE_TYPE: 'serverConfig.appMashupCaseType'
+};
+
+const BOOLEAN_ENV_MAP = {
+ SDK_SHOW_MODALS_IN_EMBEDDED_MODE: 'serverConfig.showModalsInEmbeddedMode'
+};
+
+/** Secrets that must be Base64 encoded in sdk-config.json; supplied in plain text through the environment. */
+const BASE64_ENV_MAP = {
+ SDK_MASHUP_PASSWORD: 'authConfig.mashupPassword'
+};
+
+const SECRET_PATHS = ['authConfig.mashupPassword'];
+
+function setPath(obj, dotted, value) {
+ const keys = dotted.split('.');
+ let cur = obj;
+ for (const k of keys.slice(0, -1)) {
+ if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {};
+ cur = cur[k];
+ }
+ cur[keys[keys.length - 1]] = value;
+}
+
+function getPath(obj, dotted) {
+ return dotted.split('.').reduce((cur, k) => (cur == null ? undefined : cur[k]), obj);
+}
+
+/**
+ * Returns a copy of `config` with values from `env` applied. Unset or empty variables leave the file value untouched.
+ * @returns {{ config: object, applied: string[] }} the new config and the env variable names that were applied
+ */
+function applyEnv(config, env) {
+ const next = JSON.parse(JSON.stringify(config));
+ const applied = [];
+ const present = name => env[name] !== undefined && env[name] !== '';
+
+ for (const [name, target] of Object.entries(ENV_MAP)) {
+ if (present(name)) {
+ setPath(next, target, env[name]);
+ applied.push(name);
+ }
+ }
+ for (const [name, target] of Object.entries(BOOLEAN_ENV_MAP)) {
+ if (present(name)) {
+ const v = String(env[name]).toLowerCase();
+ if (!['true', 'false'].includes(v)) throw new Error(`${name} must be "true" or "false" (got "${env[name]}")`);
+ setPath(next, target, v === 'true');
+ applied.push(name);
+ }
+ }
+ for (const [name, target] of Object.entries(BASE64_ENV_MAP)) {
+ if (present(name)) {
+ setPath(next, target, Buffer.from(env[name], 'utf8').toString('base64'));
+ applied.push(name);
+ }
+ }
+ return { config: next, applied };
+}
+
+/**
+ * Validates the settings every deployment needs.
+ * @returns {{ errors: string[], warnings: string[] }}
+ */
+function validate(config) {
+ const errors = [];
+ const warnings = [];
+
+ const url = getPath(config, 'serverConfig.infinityRestServerUrl');
+ if (!url) {
+ errors.push('serverConfig.infinityRestServerUrl is empty (env: SDK_INFINITY_REST_SERVER_URL)');
+ } else {
+ try {
+ const u = new URL(url);
+ if (!['http:', 'https:'].includes(u.protocol)) errors.push(`serverConfig.infinityRestServerUrl must be http(s): ${url}`);
+ if (/\/$/.test(url)) warnings.push('serverConfig.infinityRestServerUrl should not end with a slash');
+ } catch {
+ errors.push(`serverConfig.infinityRestServerUrl is not a valid URL: ${url}`);
+ }
+ }
+
+ if (!getPath(config, 'authConfig.portalClientId')) errors.push('authConfig.portalClientId is empty (env: SDK_PORTAL_CLIENT_ID)');
+ if (!getPath(config, 'serverConfig.appAlias'))
+ warnings.push("serverConfig.appAlias is empty; the operator's default application is used (env: SDK_APP_ALIAS)");
+
+ if (getPath(config, 'authConfig.mashupClientId') && !getPath(config, 'authConfig.mashupPassword')) {
+ warnings.push('authConfig.mashupClientId is set but mashupPassword is empty; embedded/mashup login will fail unless auth is handled elsewhere');
+ }
+ return { errors, warnings };
+}
+
+/** Copy of config that is safe to print. */
+function redact(config) {
+ const copy = JSON.parse(JSON.stringify(config));
+ for (const p of SECRET_PATHS) if (getPath(copy, p)) setPath(copy, p, '********');
+ return copy;
+}
+
+module.exports = { ENV_MAP, BOOLEAN_ENV_MAP, BASE64_ENV_MAP, applyEnv, validate, redact, getPath };