diff --git a/.github/workflows/power-pages-alm-lint.yml b/.github/workflows/power-pages-alm-lint.yml new file mode 100644 index 000000000..e8ccbbbf8 --- /dev/null +++ b/.github/workflows/power-pages-alm-lint.yml @@ -0,0 +1,29 @@ +name: power-pages-alm-lint + +on: + pull_request: + branches: + - main + paths: + - "plugins/power-pages/**" + +jobs: + alm-lint: + name: alm-lint + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup-node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: run-alm-lint + # Fails the check when any SKILL.md, script, or new powerpagecomponenttype + # violates the ALM-aware-by-default rules documented in + # plugins/power-pages/AGENTS.md. See PLUGIN_DEVELOPMENT_GUIDE.md for the + # full checklist and the allowlist mechanism (.almlintignore / inline + # `alm-lint-ignore:` comments). + run: node plugins/power-pages/scripts/lint-skills-alm.js diff --git a/plugins/power-pages/.claude-plugin/plugin.json b/plugins/power-pages/.claude-plugin/plugin.json index 2e3a10a68..06fd5d811 100644 --- a/plugins/power-pages/.claude-plugin/plugin.json +++ b/plugins/power-pages/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "power-pages", - "version": "1.3.0", - "description": "Create and deploy Power Pages sites using modern development approaches. Supports code sites (SPAs) with React, Angular, Vue, or Astro, with more site types coming soon.", + "version": "2.0.0", + "description": "Create and deploy Power Pages sites using modern development approaches. Supports code sites (SPAs) with React, Angular, Vue, or Astro. Includes ALM orchestration (plan-alm) with a solution-splitting decision tree, per-solution pipelines, Azure Blob asset advisory, manifest schema v2 for multi-solution deployments, and force-link remediation for cross-host pipeline migrations.", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" @@ -23,6 +23,17 @@ "odata", "web-api", "tables", - "schema" + "schema", + "solution", + "alm", + "plan-alm", + "ci-cd", + "pipeline", + "pipelines-host", + "force-link", + "diagnostics", + "shared-lib", + "solution-splitting", + "asset-advisory" ] } diff --git a/plugins/power-pages/AGENTS.md b/plugins/power-pages/AGENTS.md index 86047fe0b..7a3baa427 100644 --- a/plugins/power-pages/AGENTS.md +++ b/plugins/power-pages/AGENTS.md @@ -8,7 +8,7 @@ Read `PLUGIN_DEVELOPMENT_GUIDE.md` for UX and reliability standards when creatin ## Key Conventions -- **DRY** — Never duplicate logic. Shared scripts live in `scripts/` (e.g., `generate-uuid.js`, `scripts/lib/validation-helpers.js`). Shared reference docs live in `references/`. Always check for existing helpers before writing new code. +- **DRY** — Never duplicate logic. Shared scripts live in `scripts/` (e.g., `generate-uuid.js`, `scripts/lib/validation-helpers.js`, `scripts/lib/discover-site-components.js`). Shared reference docs live in `references/`. Always check for existing helpers before writing new code. - **Validation scripts** must import from `scripts/lib/validation-helpers.js` for boilerplate, path finders, auth helpers, and constants. - **UUID generation** must use the shared `scripts/generate-uuid.js` — never copy it into skill-specific directories. - **Power Pages config loading** must reuse `scripts/lib/powerpages-config.js` anywhere a script reads `.powerpages-site` table-permission or site-setting YAML. Keep that module focused on loading/parsing code-site config only; put validation or business rules in separate validator modules. @@ -18,10 +18,290 @@ Read `PLUGIN_DEVELOPMENT_GUIDE.md` for UX and reliability standards when creatin - **Reference docs** shared across skills live in `references/` — reference via `${CLAUDE_PLUGIN_ROOT}/references/` paths, don't duplicate. - **Templates** use `__PLACEHOLDER__` tokens (e.g., `__SITE_NAME__`) replaced during scaffolding. The `gitignore` file is stored without the dot prefix and renamed to `.gitignore` during scaffolding. - **Hooks** are defined centrally in `hooks/hooks.json`, using `PostToolUse` with matcher `Skill` so validation runs when a tracked Power Pages skill completes. +- **ALM split-decision thresholds** are intentionally tighter than the platform hard caps. `scripts/lib/alm-thresholds.js` recommends a split at 75 MB / 4000 components (vs platform caps of 95 MB / 6000), reserving ~20 MB / ~2000-component growth headroom in each split child. Override per-project via `.alm-config.json` if you have a justified reason to push closer to the caps. +- **OAuth credential-style site settings** (ConsumerKey / ClientId / ClientSecret / etc.) are NOT excluded from solutions. `setup-solution` Phase 5 prompts per credential to choose between (a) Secret-typed env var (Key Vault per stage), (b) String-typed env var (plain text per stage), or (c) skip. The site-setting record is added to the solution and routed to an env var so secret values never ship in the solution zip. Plans generated before 2026-05-08 use the older `excluded` bucket — setup-solution's preloadedSettings handler treats those as `credentialNeedsDecision` for backward compatibility. +- **MCP Learn grounding for ALM skills** — solution and pipeline skills (`setup-solution`, `export-solution`, `import-solution`, `diagnose-deployment`, `setup-pipeline`, `deploy-pipeline`, `ensure-pipelines-host`, `force-link-environment`) include a Phase 1.5 step that grounds the agent in current Microsoft Learn ALM docs before proceeding. The shared discovery pattern lives in `references/alm-docs-grounding.md`. Add the same Phase 1.5 + the two `mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search/fetch` tools to `allowed-tools` when introducing a new ALM skill. +- **ALM artifacts live under `docs/alm/`** — every ALM-only state file (5 plan/decision JSONs and 9 `last-*.json` skill-run markers, including `last-export.json` written by `export-solution` Phase 7.1) writes to `/docs/alm/`, not the project root. Always resolve paths through `scripts/lib/alm-paths.js` (`almPath(root, 'lastDeploy')`, `almPath(root, 'planContext')`, etc.) and call `ensureAlmDir(root)` once before the first write. Never inline a raw path string. Files that intentionally stay at the project root: `.solution-manifest.json` (referenced by non-ALM skills too), `.datamodel-manifest.json` (owned by `setup-datamodel`, not ALM), `.alm-config.json` (user-authored override), `.alm-deferred` (opt-out marker), `deployment-settings.json` (Microsoft-standard schema). When you add a new ALM artifact, add the key + filename to `FILE_NAMES` in `alm-paths.js`, then write through the helper. ## Skill Development Conventions -All skills follow these patterns. See existing skills for examples. +``` +.claude-plugin/plugin.json ← Plugin metadata (name, version, keywords) +.mcp.json ← MCP server config (Playwright for browser automation) +agents/ + data-model-architect.md ← Agent: proposes Dataverse data models (read-only) + webapi-integration.md ← Agent: implements Web API integration in frontend code + webapi-permissions.md ← Agent: proposes Web API permissions plan (read-only) +scripts/ + generate-uuid.js ← Shared UUID v4 generator (used by multiple skills) + check-activation-status.js ← Checks if site is already activated (used by deploy-site, activate-site) + poll-async-operation.js ← Polls Dataverse asyncoperations until terminal state (used by export-solution, import-solution) + encode-solution-file.js ← Base64-encodes a solution zip for OData request bodies (used by import-solution) + parse-deployment-errors.js ← Parses PAC CLI stderr + OData errors into structured findings (used by diagnose-deployment) +references/ ← Shared reference docs used by multiple skills + odata-common.md ← Auth headers, token refresh, error handling, retry patterns + dataverse-prerequisites.md ← PAC CLI check, Azure CLI token, API access verification + framework-conventions.md ← Framework detection, paths, route discovery + datamodel-manifest-schema.md ← .datamodel-manifest.json format spec + solution-api-patterns.md ← OData body templates for publisher/solution CRUD, export/import async actions, manifest format + deployment-error-catalog.md ← Known deployment failure patterns with root cause, severity, and fix procedures + cicd-pipeline-patterns.md ← PAC CLI SP auth syntax, ADO YAML stage structure, GitHub Actions env job structure +skills/ + create-site/ + SKILL.md ← Skill definition with frontmatter (model, allowed-tools) + assets/{react,vue,angular,astro}/ ← Framework templates with __PLACEHOLDER__ tokens + references/design-aesthetics.md ← Design principles, font/color/motion guidance for inline design step + scripts/validate-site.js ← Node script validating generated sites + deploy-site/ + SKILL.md ← Deployment skill definition + setup-datamodel/ + SKILL.md ← Dataverse data model creation skill definition + references/odata-api-patterns.md ← OData API body templates for table/column/relationship creation + scripts/validate-datamodel.js ← Node script validating Dataverse data model creation + add-sample-data/ + SKILL.md ← Sample data insertion skill definition + references/odata-record-patterns.md ← OData API patterns for record creation and lookups + add-seo/ + SKILL.md ← SEO essentials skill definition (robots.txt, sitemap.xml, meta tags) + scripts/validate-seo.js ← Node script validating SEO assets (robots.txt, sitemap.xml, meta tags) + activate-site/ + SKILL.md ← Site activation/provisioning skill definition + scripts/activate-site.js ← Activates a site via PP API + polls status + scripts/generate-subdomain.js ← Random subdomain suggestion generator + scripts/validate-activation.js ← Validates site was provisioned via PP API + create-webroles/ + SKILL.md ← Web roles creation skill definition + scripts/validate-webroles.js ← Node script validating web role YAML files were created + integrate-webapi/ + SKILL.md ← Web API integration skill definition + scripts/validate-webapi-integration.js ← Node script validating Web API integration code + setup-auth/ + SKILL.md ← Authentication & authorization skill definition + references/authentication-reference.md ← Login/logout flow, auth service, framework patterns + references/authorization-reference.md ← Role-based access control, guards, directives + scripts/validate-auth.js ← Node script validating auth service and authorization code + setup-solution/ + SKILL.md ← Solution creation skill definition + scripts/validate-solution.js ← Validates .solution-manifest.json and queries Dataverse to confirm solution exists + export-solution/ + SKILL.md ← Solution export skill definition + scripts/validate-export.js ← Validates solution zip exists, non-empty, contains Solution.xml + import-solution/ + SKILL.md ← Solution import skill definition + scripts/validate-import.js ← Validates docs/alm/last-import.json marker and checks for component failures + diagnose-deployment/ + SKILL.md ← Deployment diagnostics skill definition (no validator — no artifacts created) + setup-pipeline/ + SKILL.md ← CI/CD pipeline setup skill (Power Platform Pipelines — full implementation; GitHub/ADO coming soon) + scripts/validate-pipeline.js ← Validates docs/alm/last-pipeline.json marker (PP Pipelines) or pipeline YAML (GitHub/ADO) + deploy-pipeline/ + SKILL.md ← Deployment run skill — creates stage runs, validates package, deploys via PP Pipelines API + scripts/validate-deploy-pipeline.js ← Validates docs/alm/last-deploy.json marker for required fields; blocks on Failed status + plan-alm/ + SKILL.md ← ALM orchestrator skill definition (8-phase: detect, gather, plan, approve, execute skills in sequence) + assets/alm-plan-template.html ← HTML template with __PLACEHOLDER__ tokens for the ALM plan document + scripts/render-alm-plan.js ← Renders alm-plan-template.html from planData JSON (stages diagram, checklist, risks) + scripts/validate-plan-alm.js ← Validates docs/alm-plan.html exists and is > 500 bytes; gracefully exits 0 if not a plan-alm session +``` + +## ALM intent routing — `plan-alm` is the front door + +When the user expresses an **ALM intent** in natural language — *promote this site to {env}, ship to staging, deploy to production, set up CI/CD, move to next environment, push out a release, run the pipeline, export and import to staging* — invoke **`/power-pages:plan-alm` first**, before any individual ALM skill. The orchestrator detects the project state, runs the pre-plan completeness check, asks about promotion strategy, and dispatches to the right skills (`setup-solution`, `setup-pipeline`, `deploy-pipeline`, `activate-site`, `test-site`) in the right order. + +**Do not** jump straight to `/power-pages:setup-pipeline`, `/power-pages:deploy-pipeline`, `/power-pages:export-solution`, or `/power-pages:import-solution` in response to an ALM intent. Those are individual building blocks; running them out of order misses the orchestrator's gates (completeness check, host resolution, deployment-strategy selection, post-deploy validation, rendered HTML plan). + +**Skip `plan-alm` only when the user is explicit about the individual skill.** Phrases like *"just run setup-pipeline"*, *"skip planning, just deploy"*, *"I only need to export the solution zip"* are direct invocations — honor them. Anything ambiguous about deployment intent → `plan-alm` first. + +`setup-pipeline` and `deploy-pipeline` enforce this with a Phase 0 ALM-plan gate. If a user invokes them directly without a plan, those skills surface the recommendation to run `plan-alm` first (with an "I know what I'm doing" escape hatch). The Phase 0 gate is meant to fail closed — don't bypass it on the user's behalf. + +## Plugin Components + +### Agents + +Auto-triggered by the main conversation when relevant: + +- `data-model-architect`: Read-only agent that analyzes site requirements, discovers existing Dataverse tables via OData API, and proposes a data model (new/modified/reused tables + Mermaid ER diagram). Uses `pac env who` + Azure CLI auth to query Dataverse. Renders the ER diagram visually in the browser via Playwright (writes a temp HTML file with Mermaid.js CDN, navigates to it, takes a screenshot) before entering plan mode. Does NOT create, modify, or delete any tables — purely advisory. The main conversation uses its output to create tables. +- `webapi-integration`: Implementation agent that creates production-ready Web API integration code for a single Dataverse table in a Power Pages code site. Detects the frontend framework (React/Vue/Angular/Astro), creates a shared `powerPagesApi.ts` client (token management, retry logic, OData URL builder) if one doesn't exist, then generates TypeScript entity types, a domain mapper, and a CRUD service layer for the target table. Also creates framework-specific hooks (React), composables (Vue), or injectable services (Angular). Follows Power Pages Web API best practices: `/_api/` endpoints, dual token headers, `@odata.bind` for lookups, explicit `$select` (never `*`), formatted value annotations, exponential backoff retry, and 8-minute token TTL. Handles one table per invocation — invoke separately for multiple tables. +- `webapi-permissions`: Read-only agent that analyzes site code, discovers existing web roles and table permissions, queries Dataverse for table columns, and proposes a complete Web API permissions plan (table permissions + site settings). Checks for `.powerpages-site` folder to verify site deployment. Renders a Mermaid flowchart showing web roles → table permissions → tables visually in the browser via Playwright. Never uses `*` for Web API field settings — always lists specific columns. Does NOT create any YAML files — purely advisory. The main conversation uses its output to create table permission and site setting files. + +### Skills + +User-invocable via `/power-pages:`: + +- `create-site`: 6-step workflow — gather requirements (including design direction), plan (with explicit scaffold prerequisites), scaffold from template, build pages/components/routing with design applied from the start using `skills/create-site/references/design-aesthetics.md` and live Playwright preview, review, deploy +- `deploy-site`: 6-step workflow — verify PAC CLI, authenticate, confirm environment, upload via `pac pages upload-code-site`, verify deployment (confirm `.powerpages-site` folder, commit, offer activation), handle blocked JS attachments +- `setup-datamodel`: 7-step workflow — verify prerequisites, invoke data-model-architect agent, review proposal, pre-creation checks, create tables & columns via OData API, create relationships, publish & verify. Writes `.datamodel-manifest.json` for hook validation. +- `add-sample-data`: 6-step workflow — verify prerequisites, discover tables (from `.datamodel-manifest.json` or OData API), select tables & configure record count, generate & review sample data plan, insert records via OData API with relationship handling, verify & summarize. +- `activate-site`: 5-step workflow — verify prerequisites (PAC CLI auth + Azure CLI token + cloud-aware API URL resolution + activation status check via shared script), gather parameters (site name, subdomain, website record ID), confirm with user, activate & poll via `skills/activate-site/scripts/activate-site.js`, present summary with site URL. +- `add-seo`: 7-step workflow — verify site exists, gather SEO config (production URL, exclusions, meta description), plan & approve, create robots.txt, generate sitemap.xml from discovered routes, add meta tags (title, description, viewport, Open Graph, Twitter Card, favicon) to index.html, verify via Playwright & commit. +- `create-webroles`: 6-step workflow — verify `.powerpages-site/web-roles/` exists (redirect to deploy-site if missing), discover existing roles, determine new roles needed, create web role YAML files with UUIDs from shared `scripts/generate-uuid.js`, verify web roles (validate files, UUIDs, uniqueness constraints), review & prompt deployment via deploy-site skill. +- `integrate-webapi`: 7-step workflow — verify site exists, use Explore agent to analyze code and identify tables needing Web API integration, review plan with user, invoke `webapi-integration` agent per table to create API client/types/services/hooks, verify integrations (validate all files exist, project builds), invoke `webapi-permissions` agent to configure table permissions and site settings, review & deploy via `deploy-site` skill. +- `setup-auth`: 8-step workflow — verify prerequisites (site deployed + web roles), gather auth requirements and plan, create auth service with Entra ID login/logout (anti-forgery token + form POST), create authorization utilities (role checking), create auth UI (AuthButton component), apply role-based access control to components, verify auth setup (validate files, build, auth UI renders), create `ProfileRedirectEnabled` site setting and deploy. +- `setup-solution`: 7-step workflow — verify prerequisites, gather publisher/solution configuration (publisher prefix is irreversible — requires explicit confirmation), check existing publishers/solutions to avoid duplicates, create publisher + solution via OData API, add Power Pages website and web role components via `AddSolutionComponent`, verify components and write `.solution-manifest.json`, present summary. Reuses `references/solution-api-patterns.md`. +- `export-solution`: 7-step workflow — verify prerequisites, identify solution (from `.solution-manifest.json` or user input), confirm managed vs unmanaged export (irreversible choice), trigger `ExportSolutionAsync`, poll via `scripts/poll-async-operation.js`, download and decode solution zip via `DownloadSolutionExportData`, verify zip contains `Solution.xml`. Reuses `scripts/poll-async-operation.js` and `references/solution-api-patterns.md`. +- `import-solution`: 7-step workflow — verify prerequisites and confirm target environment, locate and validate solution zip, configure import (staged vs direct, overwrite options), optionally stage via `StageSolution` to check missing dependencies, import via `ImportSolutionAsync` and poll, verify solution exists in target and write `docs/alm/last-import.json` marker, present component results. Reuses `scripts/poll-async-operation.js`, `scripts/encode-solution-file.js`, and `references/solution-api-patterns.md`. +- `diagnose-deployment`: 7-step workflow — verify prerequisites and locate project, collect artifacts (config, manifests, build output), surface upload errors by re-running `pac pages upload-code-site` in capture mode and parsing via `scripts/parse-deployment-errors.js`, query recent Dataverse async operation failures, pattern-match against `references/deployment-error-catalog.md`, offer auto-fixes for fixable errors with explicit per-fix user confirmation, present findings table (severity/type/status). Never auto-applies any fix without user permission. +- `setup-pipeline`: 7-phase workflow — detect project context (`powerpages.config.json`, `.solution-manifest.json`, `pac env who`, `pac env list`, `RetrieveSetting('DefaultCustomPipelinesHostEnvForTenant')` on dev env to auto-discover host environment), select platform (Power Platform Pipelines = full; GitHub/ADO = coming soon), confirm pipeline configuration with auto-filled values (pipeline name, host env URL, target environments), run preflight checks (Pipelines installed, solution exists, no name conflict), create `deploymentenvironments` records for source + each target (poll `validationstatus` until Succeeded), create `deploymentpipelines` record + `$ref` associate source env (relative path + `@odata.context`) + create `deploymentstages` per target, verify and write `docs/alm/last-pipeline.json` + `docs/pipeline-setup.md` + commit. Uses `references/cicd-pipeline-patterns.md` for all HAR-confirmed API patterns. +- `deploy-pipeline`: 8-phase workflow — verify prerequisites (`docs/alm/last-pipeline.json`, az login, host env token), select target stage (from stages in `docs/alm/last-pipeline.json`; warn if last deploy failed), **pre-flight check on the target env's `blockedattachments` setting** via `fix-blocked-attachments.js --dry-run` (Phase 2.5, Power Pages projects only — prompts the user to unblock `.js`/`.css` proactively when they're on the env's blocklist, saving the ~50-75 min wasted import for sites with thousands of bundle chunks; complementary to the reactive Phase 7.6 handler), resolve pipeline info via `RetrieveDeploymentPipelineInfo` (v9.1) to get `SourceDeploymentEnvironmentId` and available artifacts, create `deploymentstageruns` record + call `ValidatePackageAsync` (204) + poll `operation` field until not `200000201` (surface `validationresults` issues), optionally PATCH `deploymentsettingsjson` for env var / connection reference overrides, **final deploy consent gate at Phase 6.0** (explicit `Deploy now / Cancel` `AskUserQuestion` before either `DeployPackageAsync` or the `pac pipeline deploy` fallback — closes a gap where Phase 5 → Phase 6.1 could fire without a final confirmation when validation passes cleanly), call `DeployPackageAsync` + poll `stagerunstatus` until terminal (handle approval gates with user pause), write `docs/alm/last-deploy.json` + present deployment summary. +- `force-link-environment`: 6-phase workflow — verify prerequisites (Azure CLI token for the target host, PAC CLI auth) and ground in Microsoft Learn (`custom-host-pipelines#using-force-link…`), identify host env URL (from `docs/alm/last-host-check.json`, `docs/alm/last-pipeline.json`, or user input) and source dev env's BAP env GUID, resolve or create the `deploymentenvironments` record on the new host (re-querying by `environmentid` to recover the record ID when `create-deployment-environment.js` throws on the "already associated" validation failure), require explicit `AskUserQuestion` consent for the destructive cross-host stamp move (makers in the previous host lose pipeline access for this env; previous host's record is left with stale `validationstatus`; reversible by re-running from the previous host), call `scripts/lib/force-link-environment.js` to POST `ManageEnvironmentStamp` + re-poll `validationstatus` until Succeeded, write `docs/alm/last-force-link.json` marker. Auto-fix entry point for Pattern 15 in `references/deployment-error-catalog.md`. +- `plan-alm`: 8-phase orchestrator workflow — detect project state (powerpages.config.json, existing manifests, pac env who), gather ALM strategy via branched question flow (PP Pipelines or Manual export/import path), generate HTML ALM plan (docs/alm-plan.html with pipeline diagram and execution checklist), get user approval, then execute: setup-solution (conditional), setup-pipeline or export-solution (path-dependent), deploy-pipeline or import-solution per stage, finalize with HTML status update and git commit. + +For small mid-cycle changes (one file, one snippet, one site setting) that previously used a separate hotfix solution: instead, run `setup-solution` in sync mode to adopt the modified components into the existing base solution, bump the solution version, and use `deploy-pipeline` to ship. This keeps a single solution lineage (cleaner audit trail, simpler dependency management) and avoids solution sprawl. Power Platform Pipelines computes incremental imports internally, so re-deploying the base after a small fix is fast. + +Skills are defined in `SKILL.md` files with YAML frontmatter (name, description, allowed-tools, model). Skill-specific `hooks:` blocks are not used — hook registration is centralized. + +### Hooks + +Hook registration is centralized in `hooks/hooks.json` — a single PostToolUse hook (matcher `Skill`) runs `hooks/run-skill-posttool-validation.js` after every Skill tool call. The runner consults the `TRACKED_SKILLS` map in `scripts/lib/powerpages-hook-utils.js`, looks up the validator for the skill that just completed, and invokes it with the current cwd. + +To wire a new skill into validation: + +1. Write the validator at `skills//scripts/validate-.js` using the `runValidation((cwd) => { ... })` pattern from `scripts/lib/validation-helpers.js`. +2. Register the skill in `TRACKED_SKILLS` (in `scripts/lib/powerpages-hook-utils.js`) with its `validatorScript` path. +3. Add test coverage in `scripts/tests/powerpages-hook-utils.test.js` so an unregistered skill is caught in CI. + +Skills currently registered with command-backed validators: `activate-site`, `add-cloud-flow`, `add-seo`, `add-server-logic`, `audit-permissions`, `configure-env-variables`, `create-site`, `create-webroles`, `deploy-pipeline`, `ensure-pipelines-host`, `export-solution`, `force-link-environment`, `import-solution`, `integrate-webapi`, `plan-alm`, `setup-auth`, `setup-datamodel`, `setup-pipeline`, `setup-solution`. `add-sample-data` and `test-site` are tracked without command validators (no artifacts to verify). `diagnose-deployment` is intentionally not tracked — it's read-only and produces no artifacts to verify. + +**Anti-patterns** (see `PLUGIN_DEVELOPMENT_GUIDE.md` for the rationale): do not add `hooks: Stop:` blocks to individual SKILL.md frontmatter — they duplicate the centralized PostToolUse hook and fire too often. Do not use `type: prompt` Stop hooks for skill-completion checks — they create runaway forced-continuation loops. + +### Shared Scripts + +Shared utility scripts live at `scripts/` and are referenced by multiple skills and agents via `${CLAUDE_PLUGIN_ROOT}/scripts/`. + +- `generate-uuid.js`: Generates a random UUID v4. Self-contained, no dependencies. Used by `create-webroles` and the main agent when creating table permission / site setting files from the `webapi-permissions` agent plan. +- `update-skill-tracking.js`: Updates skill usage tracking site settings. Takes `--projectRoot`, `--skillName`, and `--authoringTool` args. The agent passes its own name as `--authoringTool` (e.g., `ClaudeCode`, `GitHubCopilot`). Creates/increments a per-skill counter (`Site-AI-.sitesetting.yml`) and records the authoring tool (`Site-AI-AuthoringTool.sitesetting.yml`). Exits silently if `.powerpages-site/site-settings/` does not exist. Used by every user-invocable skill (each skill calls it in its final phase per the skill-tracking convention). +- `check-activation-status.js`: Checks whether a Power Pages site is already activated (provisioned) in the environment. Takes `--projectRoot` arg. Reads `siteName` from `powerpages.config.json`, looks up `websiteRecordId` via `pac pages list`, queries the Power Platform GET websites API, and matches by both `websiteRecordId` and `name`. Outputs JSON: `{ activated: true/false, siteName, websiteRecordId, websiteUrl }` or `{ error }`. Used by `deploy-site` and `activate-site`. +- `poll-async-operation.js`: Polls a Dataverse `asyncoperations` record until it reaches a terminal state (Succeeded/Failed/Canceled) or times out. Args: `--asyncJobId`, `--envUrl`, `--token` (optional, refreshed via Azure CLI if omitted), `--intervalMs` (default 5000), `--maxAttempts` (default 60). Outputs JSON status. Used by `export-solution` and `import-solution`. +- `encode-solution-file.js`: Base64-encodes a solution zip file for use in Dataverse OData request bodies (`ImportSolutionAsync`, `StageSolution`). Args: `--zipPath`. Outputs `{ encoded, fileSizeBytes, fileName }`. Used by `import-solution`. +- `parse-deployment-errors.js`: Parses PAC CLI stderr output or OData error JSON into structured findings array. Each finding has `{ patternId, type, severity, message, rawMatch, autoFixAvailable, suggestedFix }`. Reads from `--input`, `--file`, or stdin. Used by `diagnose-deployment`. + +Shared lib modules live at `scripts/lib/` and are imported by other scripts via `require('./validation-helpers')` or sibling requires. Never inline their logic in skill scripts — always require from `scripts/lib/`. + +#### ALM Prerequisites & Context + +- `scripts/lib/verify-alm-prerequisites.js`: Verifies all prerequisites for ALM skills — PAC CLI installed + authenticated (`pac env who`), Azure CLI installed + logged in, Dataverse API reachable (`WhoAmI`). Args: `--envUrl` (opt, overrides env from PAC CLI), `--require-manifest` (fails if `.solution-manifest.json` not found). Output: `{ envUrl, token, userId, organizationId, tenantId }`. Exit 0 on success, exit 1 on any failure. Used by `setup-solution`, `export-solution`, `import-solution`, `setup-pipeline`, `deploy-pipeline`, `plan-alm`. +- `scripts/lib/detect-project-context.js`: Reads Power Pages project context files from the project root — `powerpages.config.json`, `.solution-manifest.json`, and `.datamodel-manifest.json`. Args: `--projectRoot` (opt, auto-discovered from cwd if omitted). Output: `{ projectRoot, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 if `powerpages.config.json` not found. +- `scripts/lib/alm-paths.js`: Single source of truth for ALM artifact paths. Exports `ALM_DIR` (always `docs/alm`), `FILE_NAMES` (frozen object mapping logical key → filename for all 14 ALM artifacts), `almDir(projectRoot) → path`, `almPath(projectRoot, key) → path`, `ensureAlmDir(projectRoot) → path` (mkdir -p idempotent). Every ALM-only state file (5 plan/decision JSONs + 9 `last-*.json` skill-run markers including `last-export.json`) writes under `/docs/alm/`. **Always resolve through this helper** — never inline a raw `docs/alm/...` path in a script. Files intentionally NOT moved here (and not in `FILE_NAMES`): `.solution-manifest.json`, `.datamodel-manifest.json`, `.alm-config.json`, `.alm-deferred`, `deployment-settings.json`. Adding a new ALM marker means adding its key + filename to `FILE_NAMES` first; `almPath` throws on unknown keys to catch typos at call-site. +- `scripts/lib/check-alm-plan.js`: Phase 0 gate helper used by every ALM skill to detect (a) whether an ALM plan exists for this project, (b) whether the user has explicitly deferred ALM via the `.alm-deferred` marker, and (c) whether an existing plan is stale (the source solution was modified after the plan was generated). Args: `--projectRoot`, `--envUrl` (opt — required for staleness check), `--token` (opt), `--solutionId` (opt — required for staleness check). Output: `{ exists, deferred, deferral, planPath, htmlPath, stale, staleness: { reason, detail }, generatedAt, planStatus, solution: {...} }`. Without env/solution context the helper does an existence-only check; with them it queries Dataverse for `solutions(solutionId)?$select=modifiedon` and compares against `planData.generatedAt`. Used by `setup-solution`, `setup-pipeline`, `deploy-pipeline`, `export-solution`, `import-solution`, `configure-env-variables`, `ensure-pipelines-host`, `force-link-environment` Phase 0 gates — the "fail closed when no plan" pattern. +- `scripts/lib/resolve-target-solution.js`: Resolves "which solution should this new Dataverse record land in?" Implements the strict 3-step order from the ALM-aware-by-default principle: (1) explicit `--solutionUniqueName` (or equivalent caller arg) wins; (2) `.solution-manifest.json` in the project root; (3) neither → throw `NoSolutionConfiguredError`. **The module NEVER auto-picks from Dataverse** — interactive prompt UX is the caller's responsibility (catch the error, present an `AskUserQuestion` list, re-invoke with `explicit` populated). Callers that need to confirm the solution still exists in Dataverse can pass `verifyExists: true`; the module then enriches the result with `{ solutionId, version, ismanaged }`. Component-creation scripts must require this helper and pass through `--solutionUniqueName` so records land in the user's solution instead of `Default`. + +#### Solution Splitting Decision Tree (v1.3.0+) + +- `scripts/lib/alm-thresholds.js`: Central default threshold constants for the split decision tree. Loads optional `.alm-config.json` from project root and merges over defaults. Exports `DEFAULTS`, `DEFAULT_CONFIG`, `loadConfig(projectRoot)`, `classifyTier(value, greenUpperExclusive, yellowUpperExclusive)`, `deepMerge(target, source)`. Used by `estimate-solution-size.js` and `compute-split-plan.js`. +- `scripts/lib/estimate-solution-size.js`: Estimates solution size + component counts by querying Dataverse. Args: `--envUrl`, `--websiteRecordId`, `--token` (opt), `--publisherPrefix` (opt), `--siteName` (opt), `--solutionId` (opt — scopes env var count to the target solution; without it falls back to a publisher-prefix tenant-wide query that overcounts when prefix is shared), `--datamodelManifest` (opt), `--projectRoot` (opt — enables disk cross-check: walks the local build-output directory (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces the byte total). Output: `{ totalSizeMB, componentCountSiteTotal, componentCountSiteActionable, componentCountInSolution, orphansOnSite, tableCount, schemaAttrCount, webFilesAggregateMB, webFilesIndividual[], webFileCount, webFileSampleSize, webFilesDiskMeasuredMB, webFilesDiskMeasuredPath, webFilesDiskFileCount, cloudFlowCount, botCount, envVarCount, envVarCountScope, envVarCountTenantWide, mediaRatio, siteType, tables[], breakdown, estimationMethod, estimationAccuracyPct, truncationSuspected, truncationWarnings, ppcGroundTruthCount }`. Metadata-based estimation with ±15% caveat. Web-file size is measured via stratified sample (first 50 + middle 50 + last 50, cap 150) scaled to full count. Disk fields are null unless `--projectRoot` was passed AND a build-output directory was found. Truncation canaries fire when Dataverse pagination disagrees with `@odata.count`, when ppcs land on a page-size boundary, when sampled average bytes/file < 1 KB at scale, or when the disk total exceeds the Dataverse total by >2× — any signal flips `truncationSuspected: true` with a per-cause `truncationWarnings[]` entry. Used by `plan-alm` Phase 1 Step 10. +- `scripts/lib/compute-split-plan.js`: Runs the split decision tree against a size-estimate blob. Args: `--estimate `, `--projectRoot` (opt — for `.alm-config.json` overrides), `--siteName` (opt), `--publisherPrefix` (opt). Output: `{ sizeAnalysis, assetAdvisory, splitStrategy, appliedStrategies, compositeSubPartitioned, proposedSolutions[], recommendations[], truncationSuspected, truncationWarnings }`. Evaluates strategies in priority order: Strategy 3 (Schema Segmentation) → Strategy 1 (Layer Split) → Strategy 2 (Change-Frequency) → Strategy 4 (Config Isolation). Strategy 4 stacks additively. Strategy 1 also runs a composite sub-partition pass: when Core still exceeds the size OR component-count cap after Web Assets are peeled off, Core is replaced with change-frequency-shaped sub-children (`_Foundation`/`_Config`/`_Content`, plus `_Integration` whenever the parent had any flows or bots — coverage takes priority over the `changeFreqMinFlows` heuristic, which governs only the TOP-LEVEL strategy choice). When additive Strategy 4 is firing concurrently (a top-level `_EnvVars` solution), `_Config` drops `Environment Variable` from its componentTypes to avoid double-claim; when it isn't, `_Config` absorbs env vars so they have an owner. Sub-partitioning sets `compositeSubPartitioned: true` and appends `composite-sub-partition` to `appliedStrategies`. `validateSplits` checks BOTH the size AND component-count cap per split (skipping `isFutureBuffer` solutions). Supports `.alm-config.json` overrides including `strategyOverride` to bypass the tree. See `solution-splitting-logic.md` spec in design docs for full logic. + +#### Solution Management + +- `scripts/lib/verify-solution-exists.js`: Checks whether a Dataverse solution exists by unique name via OData `solutions?$filter=uniquename eq '...'`. Args: `--envUrl`, `--uniqueName`, `--token` (opt). Output: found → `{ found: true, solutionId, uniqueName, version, isManaged }`, not found → `{ found: false, uniqueName }`. Exit 0 regardless of found/not-found; exit 1 on API error. +- `scripts/lib/create-solution.js`: Creates a Dataverse solution via OData POST to `/solutions`. Handles 409 (already exists) by re-querying and returning the existing record's ID. Args: `--envUrl`, `--token`, `--uniqueName`, `--friendlyName`, `--version`, `--publisherId`, `--description` (opt). Output: `{ solutionId, uniqueName, created }` where `created: false` means it already existed. +- `scripts/lib/bump-solution-version.js`: Bumps the patch segment (4th segment) of a Dataverse solution's version and PATCHes it back. **Single source of truth for the bump rule** — pads missing trailing segments with `0` (so `1.0` → `1.0.0.1`), uses integer arithmetic (`1.0.0.9` → `1.0.0.10`, not lexical), rejects non-numeric or negative segments, rejects more than 4 segments. Used by `setup-solution` Phase 4 sync-mode bump AND `export-solution` Phase 4 Step 4.0 (always-on pre-export bump so every produced zip carries a strictly-increasing version label for the manual export/import path — eliminates the "exported zip carries same version as previous export" failure for managed-solution upgrades). Args: `--envUrl`, one of (`--uniqueName` OR `--solutionId`), `--token` (opt — refreshed via `getAuthToken` if omitted), `--dryRun` (opt — computes the next version without PATCHing). Output: `{ solutionId, uniqueName, previous, next, bumped }`. Exit 0 on success, exit 1 on missing args / solution not found / PATCH rejected. **Also exports `compareVersions(a, b) → -1|0|1` and `parseVersionToSegments(v) → number[4]`** as programmatic helpers — `compareVersions` is the canonical way for any caller to compare two Dataverse version strings (`import-solution` Phase 3.0 uses it for the version-skew advisory). Same segment-wise integer rules — `compareVersions('1.0.0.9', '1.0.0.10')` correctly returns `-1`, where raw string `>` would say `1.0.0.9 > 1.0.0.10` is true and label the 10th deploy of the day as a downgrade. Never compare version strings with raw `>`/`<`/`===` in SKILL.md prose — always shell out to `node -e "console.log(require('.../bump-solution-version').compareVersions(...))"`. **Do not inline the bump or comparison rule in any SKILL.md** — both setup-solution and export-solution must call this helper so divergent semantics cannot happen. +- `scripts/lib/create-solutions-batch.js`: Parallel bulk creation of Dataverse solutions sharing one publisher. Used by `setup-solution` Phase 4 Step 2 in `MULTI_SOLUTION_MODE` when the split plan recommends N solutions. Fans out via `Promise.allSettled` so independent failures don't poison the batch — typical 5-6 solution splits complete in ~2s vs ~10s for a serial agent loop. Args: `--envUrl`, `--publisherId`, `--solutionsFile ` (JSON array of `{ uniqueName, friendlyName, version, description, isFutureBuffer? }`), `--token` (opt; refreshed once at batch start via `getAuthToken` if omitted). Skips entries with `isFutureBuffer: true` (reserved 0/0 slots that exist as data only). Output: `{ total, success, skipped, failed, results: [{ uniqueName, solutionId, created } | { uniqueName, skipped: true, reason: "futureBuffer" } | { uniqueName, error }] }`. Exit 0 always (caller inspects `failed` + per-entry `error`); exit 1 only on fatal setup errors (missing required args, unparseable JSON). +- `scripts/lib/discover-component-types.js`: Resolves Dataverse solution component type integers at runtime by querying `solutioncomponents` for known object IDs — never hardcodes component types. Args: `--envUrl`, `--token`, `--websiteRecordId`, `--powerpageComponentId` (opt), `--siteLanguageId` (opt), `--objectIds` (opt, comma-separated for generic lookup). Output: `{ websiteComponentType, subComponentType, siteLanguageComponentType, resolved[] }`. +- `scripts/lib/add-components-to-solution.js`: Bulk-adds solution components via `AddSolutionComponent` OData action. Refreshes the Azure CLI token every `--batchSize` calls (default 20). Treats "already in solution" as success (idempotent). Args: `--envUrl`, `--componentsFile` (path to JSON array of `{ componentId, componentType, addRequired?, description? }`), `--solutionUniqueName`, `--batchSize` (opt), `--token` (opt). **Input shape validated upfront** — keys must be camelCase; PascalCase entries (`ComponentId`/`ComponentType`) are rejected with a targeted error before any Dataverse call (closes a silent-failure mode where the destructure returned `undefined` and produced a stream of HTTP 400 "missing parameters" responses). Per-entry validation surfaces the first malformed row with its array index. Output: `{ total, success, skipped, failed, failures[] }`. Progress goes to stderr; exits 0 always (caller inspects `failures`); exits 1 on fatal setup errors (missing required args, malformed input). +- `scripts/lib/classify-site-settings.js`: Single source of truth for the credential regex + tier classification used by `plan-alm` Phase 1 Step 7 and `setup-solution` Phase 5. Exports `classify`, `bulkClassify`, `autoClassifyCredential`, plus the four named regexes (`CREDENTIAL_REGEX`, `AUTH_PREFIX_REGEX`, `CREDENTIAL_SECRET_REGEX`, `CREDENTIAL_STRING_REGEX`). CLI mode reads JSON array from stdin, emits the four-bucket `{ keepAsIs, authNoValue, promoteToEnvVar, credentialNeedsDecision }` shape. **Do not inline the regex** in any SKILL.md — both skills must require this module so a regex change propagates to plan time AND execution time. +- `scripts/lib/generate-env-var-schema-name.js`: Single source of truth for the canonical env var schema name rule `{prefix}_{settingName.replace(/[^A-Za-z0-9]+/g,'_').toLowerCase()}`. Used by `setup-solution` (creates definitions) and `configure-env-variables` (references them). Inlining the rule risks divergent schema names across skills — call the helper. Args: `--publisherPrefix`, `--settingName`. Output: `{ schemaName, sanitized }`. +- `scripts/lib/create-env-var-definition.js`: Creates an `environmentvariabledefinition` record in Dataverse. Handles 409 (duplicate) by returning the existing definition's ID. Args: `--envUrl`, `--token`, `--schemaName`, `--displayName`, `--type` (opt — canonical Dataverse option-set codes: `100000000`=String, `100000001`=Number, `100000002`=Boolean, `100000003`=JSON, `100000004`=DataSource, `100000005`=Secret), `--defaultValue` (opt). Output: `{ definitionId, schemaName, created }`. **Note**: earlier revisions of this helper and `discover-env-var-definitions.js` had Secret/JSON swapped (Secret=100000003); both are now correct per the canonical mapping verified against live tenant data. +- `scripts/lib/link-site-setting-to-env-var.js`: Links an `mspp_sitesetting` record to an `environmentvariabledefinition` via OData PATCH on the v9.0 API (not v9.2). HAR-confirmed: navigation property is `EnvironmentValue@odata.bind`; headers `if-match: *` and `clienthost: Browser` are required (omitting causes 400). Args: `--envUrl`, `--token`, `--siteSettingId`, `--definitionId`, `--schemaName`. Output: `{ ok, verified, siteSettingId, definitionId }`. +- `scripts/lib/install-pipelines-app.js`: Installs the Power Platform Pipelines application package on an existing Dataverse env (replaces ensure-pipelines-host Phase 4.B's manual PPAC click-through). Resolution: BAP `applicationPackages` LIST + `/install` POST → 200 sync / 202 + Location poll, with PAC CLI fallback (`pac application install --environment-id ... --application-list msdyn_AppDeploymentAnchor`) on 401/403/5xx. 409 on install POST treated as idempotent (already-installed). Args: `--bapToken`, `--envId`, `--instanceApiUrl` (opt — for verification probe), `--hostToken` (opt), `--no-pac-fallback` (opt), `--correlationId`, `--timeoutSec`, `--apiVersion`, `--bapBase`. Output: `{ status, alreadyInstalled, installPath: 'bap'\|'pac'\|'cached', packageUniqueName, pipelinesSolutionVersion, durationSec, correlationId, pollAttempts, locationHeader, pacFallbackReason }`. +- `scripts/lib/discover-env-var-definitions.js`: Enumerates env var definitions matching a publisher prefix and joins each with its bound `mspp_sitesetting` (if any). Used by `plan-alm` Phase 1 Step 10b to populate `planData.envVars[]` with row-level metadata so the rendered plan's Env Variables tab shows schema name, type, default value, and bound site setting per definition (instead of just a count). Args: `--envUrl`, `--publisherPrefix`, `--websiteRecordId`, `--token` (opt). Output: `{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count }`. Degrades gracefully (empty array, exit 0) on auth failure or query errors so the renderer's count-summary fallback can take over. +- `scripts/lib/refresh-alm-plan-data.js`: Updates `docs/.alm-plan-data.json` with post-run state from the marker files written by setup-pipeline / deploy-pipeline / ensure-pipelines-host / test-site / import-solution / activate-site / configure-env-variables / setup-solution / export-solution, then optionally re-renders `docs/alm-plan.html`. Used by plan-alm Phases 6 / 7 / 8 so the rendered Pipelines tab, Validation tab, hostResolution card, env var values matrix, checklist, and risks list reflect actual run state instead of frozen pre-run intent. Args: `--projectRoot`, `--phase` (`setup-solution`/`setup-pipeline`/`configure-env-variables`/`deploy-pipeline`/`export-solution`/`import-solution`/`activate-site`/`test-site`/`finalize`), `--render` (also invoke renderer), `--stageName` (required for `test-site`; preferred for `import-solution`/`activate-site` though both can resolve via marker URL match). Output: `{ ok, phase, dataPath, htmlPath, rendered }`. Returns `ok:false` (soft no-op) when `docs/.alm-plan-data.json` is missing — caller should preserve that file across phases for the helper to work. Plan-alm Phase 3 must NOT delete the file after the initial render — it's read by `check-alm-plan.js` for downstream Phase 0 ALM-plan gates and by this helper for post-run refreshes. **Cross-cutting behaviors**: (a) `setStepStatus` flips the matching entry in `planData.steps[]` to `completed` (or `failed` when the phase's marker indicates failure) — case-insensitive keyword match + stage filter, respects `skip: true`, never regresses completed→pending; (b) `deploy-pipeline` AND `configure-env-variables` both backfill `planData.envVars[i].values{}` from the project root's `deployment-settings.json` so the rendered plan's "Values by Environment" matrix auto-populates (accepts both top-level-stage and nested-`stages` shapes; `SchemaName`/`Value` and camelCase variants; never overwrites a populated cell — manual override wins); (c) `configure-env-variables` and `setup-solution` both re-ingest `docs/alm/last-env-vars.json` (when present) so freshly-created definitions appear in `planData.envVars[]` and `plannedEnvVarCount` zeros out; (d) `export-solution` ingests `docs/alm/last-export.json` into `planData.manualMeta.lastExport` (all 10 marker fields: solutionUniqueName/solutionId/previousVersion/version/managed/sourceEnvironmentUrl/zipPath/fileSizeBytes/asyncOperationId/exportedAt) so the Manual-path tab can show the most recent export. Marker absence is a silent step-sync-only no-op (no `manualMeta.lastExport: null` row in the rendered plan); (e) `deploy-pipeline` ingests the `batchValidation` block from `last-deploy.json` into `planData.pipelineMeta.lastDeploy.batchValidation` (totalSolutions/succeeded/failed/pendingApproval/timedOut/elapsedSeconds/perSolutionStageRunIds) so the rendered plan can show the Phase 3.6 parallel-validation outcome distinct from the serial deploy outcome. Explicitly set to `null` for single-solution / legacy v2 deploys so renderers can branch on it; legacy `elapsedSecondsApprox` field name is accepted and normalized to `elapsedSeconds` on ingest. + +#### PP Pipelines + +- `scripts/lib/discover-pipelines-host.js`: Discovers the tenant-level default Power Platform Pipelines host environment URL by calling `RetrieveSetting('DefaultCustomPipelinesHostEnvForTenant')` on the dev/source environment. Args: `--envUrl`, `--token`, `--userId`. Output: `{ found, hostEnvUrl }`. Exit 0 (including when not found); exit 1 on error. +- `scripts/lib/create-deployment-environment.js`: Creates a `deploymentenvironments` record in the Pipelines host environment using the **unprefixed** field schema (`name`, `environmentid`, `environmenttype`), then polls `validationstatus` until Succeeded (`200000001`) or Failed (`200000002`). Args: `--hostEnvUrl`, `--token`, `--name`, `--bapEnvId`, `--environmentType` (`200000000` Dev / `200000001` Target), `--environmentUrl` (opt, only echoed in output marker). Idempotent: if a record already exists for the same `environmentid`, returns it with `reused: true`. Output: `{ deploymentEnvironmentId, name, bapEnvId, environmentUrl, environmentType, validationStatus, reused }`. +- `scripts/lib/create-deployment-pipeline.js`: Creates a `deploymentpipelines` record, associates the source environment via `$ref` (relative path + `@odata.context`), and creates `deploymentstages` records for each target environment. Args: `--hostEnvUrl`, `--token`, `--pipelineName`, `--description`, `--sourceDeploymentEnvironmentId`, `--stagesJson` (JSON array of `{ name, targetDeploymentEnvironmentId, order }`). Output: `{ pipelineId, pipelineName, stages[] }`. +- `scripts/lib/create-stage-run.js`: Creates a `deploymentstageruns` record to initiate a pipeline deployment stage. Args: `--hostEnvUrl`, `--token`, `--pipelineId` (opt), `--stageId`, `--sourceDeploymentEnvironmentId`, `--solutionId` (GUID), `--artifactName` (unique name). Output: `{ stageRunId }`. +- `scripts/lib/poll-validation-status.js`: Polls `stagerunstatus` on a `deploymentstageruns` record until Validation Succeeded (200000007) or Failed (200000003). Args: `--hostEnvUrl`, `--token`, `--stageRunId`, `--intervalMs` (opt, default 5000), `--maxAttempts` (opt, default 36). Output: `{ stageRunId, validationResults, stageRunStatus }`. +- `scripts/lib/validate-stage-runs-batch.js`: Parallel batch validation of N stage runs against the same stage. Used by `deploy-pipeline` Phase 3.6 in `MULTI_RUN_MODE` (multi-solution v3 manifest) to compress validation from `N × ~120s` to roughly the slowest single validation. For each solution, runs `create-stage-run` + `POST ValidatePackageAsync` + `poll-validation-status` concurrently via `Promise.all` — wrap-errors-into-result-object pattern (helper never rejects per-solution; errors land on the result object's `status` + `error` fields). Deploy (`DeployPackageAsync`) is NOT parallelized — Dataverse takes an env-level import lock so parallel deploys queue on the host anyway. Args: `--hostEnvUrl`, `--stageId`, `--sourceDeploymentEnvironmentId`, `--solutionsFile ` (JSON array of `{ solutionUniqueName, solutionId }`), `--token` (opt), `--pipelineId` (opt), `--intervalMs` / `--maxAttempts` (opt, forwarded to poll). Output: `{ total, succeeded, failed, pendingApproval, timedOut, allPassed, elapsedSeconds, results: [{ solutionUniqueName, solutionId, stageRunId, status: "Succeeded"|"Failed"|"PendingApproval"|"Timeout"|"Error", validationResults?, error? }] }`. PendingApproval is detected via post-timeout probe (the underlying poll helper doesn't know about `200000005`, so a poll timeout triggers a single `?$select=stagerunstatus` re-query to distinguish "still validating" from "awaiting approval"). `elapsedSeconds` is wall-clock measured around the fan-out (excludes token-acquire prelude) so deploy-pipeline Phase 3.6.6 can persist it into `last-deploy.json`'s `batchValidation` block without out-of-band timing. **Also supports `--rePoll` mode**: solutionsFile entries must include `stageRunId` (carried from the original batch's results); the helper skips create-stage-run + ValidatePackageAsync and only runs the poll-and-probe pattern. Used by `deploy-pipeline` Phase 3.6.4 after the user approves PendingApproval validations in PPAC — re-poll the existing stage runs without re-creating them. In rePoll mode `--stageId` / `--sourceDeploymentEnvironmentId` are not required. Exit 0 always (caller inspects `allPassed`); exit 1 only on fatal setup errors. +- `scripts/lib/poll-deployment-status.js`: Polls `stagerunstatus` on a `deploymentstageruns` record until a terminal state. Returns `{ status: 'Awaiting' }` (exit 0, non-throwing) for approval gates (200000005 PendingApproval, 200000008 AwaitingPreDeployApproval) — caller must pause for user. Args: `--hostEnvUrl`, `--token`, `--stageRunId`, `--intervalMs` (opt, default 8000), `--maxAttempts` (opt, default 75). Output: `{ stageRunId, status, errorDetails }`. +- `scripts/lib/ensure-pipelines-host-detect.js`: Detection-only wrapper around the ensure-pipelines-host workflow. Runs Phases 1.0 (cache fast-path) + 2 (resolution order: org-setting → BAP env GET → tenant default custom → tenant-wide enumeration) + 5 (verify if host found). **Never enters Phase 3 (decision tree) or Phase 4 (provisioning)** — always exits with `actionTaken: "none"`. Used by `plan-alm` Phase 1 step 12 and other orchestrators that want to inspect host state without inviting user prompts. Resolution order mirrors `ProjectHostProvider.tsx` from the AppDeploymentConfiguration UI. Args: `--devEnvUrl`, `--token` (opt), `--projectRoot` (opt, for cache file). Output: `{ resolutionStatus, finalHostEnvUrl, finalHostEnvId, finalHostEnvName, hostType, pipelinesSolutionVersion, candidates: { existingCustomHosts[], ... }, actionTaken: "none" }`. +- `scripts/lib/provision-platform-host.js`: Provisions a Power Platform Pipelines Platform Host (PE) via the BAP `getOrCreate` endpoint. **Idempotent**: a tenant that already has a PE gets the existing one back (200 + `provisioningState=Succeeded`); a tenant without one gets it provisioned (202 + lifecycle op poll). Same call `make.powerapps.com → Pipelines → Get started` makes. Args: `--bapToken`, `--tenantId`, `--correlationId` (opt — defaults to a fresh UUID), `--bapBase` (opt — defaults to `https://api.bap.microsoft.com`), `--timeoutSec` (opt). Output: `{ status, alreadyExisted, envId, envUrl, envName, region, provisioningState, lifecycleOpId, durationSec, correlationId }`. Used by `ensure-pipelines-host` Phase 4.0. +- `scripts/lib/provision-custom-host.js`: Provisions a new Power Platform Pipelines Custom Host via the BAP env-create API with the `D365_ProjectHost` organization template (template pre-installs the Pipelines app so the env is immediately host-capable). Same template PPAC's `New custom host` button uses. Args: `--bapToken`, `--tenantId`, `--displayName`, `--region` (opt), `--sku` (opt — `Sandbox` / `Trial` / `Production`), `--correlationId`, `--bapBase`, `--timeoutSec`. On 409 capacity errors the helper surfaces `errorCode` so the caller can offer a SKU fallback (e.g. Sandbox → Trial → Production). Output: `{ status, envId, envUrl, envName, sku, lifecycleOpId, durationSec, correlationId }`. Used by `ensure-pipelines-host` Phase 4.A. +- `scripts/lib/force-link-environment.js`: Force-links an existing `deploymentenvironments` record (in a Pipelines host env) to take over the source environment's host association. API behind PPAC's "Force Link" button — the documented remediation when creating an environment record fails with *"this environment is already associated with another pipelines host"*. Args: `--hostEnvUrl`, `--token` (host-scoped), `--deploymentEnvironmentId` (the record on the new host). POSTs to `/api/data/v9.0/ManageEnvironmentStamp` with the GUID in upper-case-in-braces format (HAR-confirmed against `supplierportalpipelineshostch.crm17`, 2026-05-11). Idempotent: re-running on an already-stamped env is a 204 no-op. Output: `{ ok, deploymentEnvironmentId, hostEnvUrl, validationStatus, errorCode? }`. Used by `force-link-environment` skill (Pattern 15 auto-fix in `deployment-error-catalog.md`). +- `scripts/lib/pac-bap-shim.js`: PAC-CLI shim for BAP env-list / env-GET. Provides the same data shape that `resolve-env-by-id.js` and `list-tenant-envs.js` consume from BAP, but sourced from `pac admin list --json` instead. **Why this exists**: the BAP API at `api.bap.microsoft.com` rejects Az-CLI-acquired tokens in some tenants (verified 2026-04-28: D365DemoTSCE53051106 returns 401 InvalidAuthenticationToken even though the token claims show the right user/tenant/audience). PAC CLI succeeds because it uses a different first-party client ID with implicit BAP grants. The shim is the read-side fallback — enables detection scripts to work in tenants where Az→BAP fails. Exports `listTenantEnvs()`, `resolveEnvById(envId)` with the same return shape as the BAP-backed callers. +- `scripts/lib/verify-env-var-values.js`: Verifies that `environmentvariablevalues` records actually landed on a target environment after deploy / import / configure. Read-only — no Dataverse writes. **Why this exists**: `deploy-pipeline` Phase 5.2 PATCHes `deploymentsettingsjson` onto the stage run; the Pipelines handler writes value records as part of the import, BUT it does NOT always write values for every definition — definitions not bound to an `mspp_sitesetting` (or another consumer the platform recognizes) can land as zero-value on the target even when the stage run reports success. This helper closes the gap. Args: `--envUrl`, one of (`--schemaNames` comma-separated OR `--settingsFile ` to derive from `deployment-settings.json`), `--stageLabel` (required when reading the settings file), `--token` (opt). Output: `{ summary: { landed, missing, mismatched, error }, results: [{ schemaName, status: "landed"|"missing-value-record"|"missing-definition"|"value-mismatch"|"query-error", expected?, value? }] }`. Used by `deploy-pipeline` Phase 7.6.5, `import-solution` Phase 6b.verify, `configure-env-variables` Phase 7. +- `scripts/lib/validate-deployment-settings.js`: Pre-deploy validator for `deployment-settings.json`. Classifies each `EnvironmentVariables[]` entry by `valueFormat` (`kv-uri` / `kv-resource-id` / `kv-placeholder` / `empty` / `plain-text` / `invalid-uri`) and `status` (`valid` / `invalid` / `unknown-type` / `skipped`). When `--envUrl` is provided, Secret-type entries are validated against canonical Azure Key Vault reference formats. **Why this exists**: the Power Platform Pipelines handler validates the PATCH at import time, AFTER the stage run has been queued — a bad Secret reference fails the import with *"ImportAsHolding failed: The value provided as a secret reference does not match a valid secret reference format"* after a potentially-hours-long queue wait. This helper catches the bad reference in sub-second time. Args: `--settingsFile`, `--envUrl` (opt — enables Dataverse type lookups), `--stageLabel` (opt — narrows to a single stage), `--token` (opt). Output: `{ summary: { valid, invalid, "unknown-type", skipped }, findings: [{ schemaName, valueFormat, status, value, message, type? }] }`. Used by `deploy-pipeline` Phase 5.1b (pre-PATCH gate). The catalog of canonical Secret formats lives in this helper — do NOT duplicate the regex elsewhere. + +#### Solution Export + +- `scripts/lib/export-solution-async.js`: Triggers async Dataverse solution export via `ExportSolutionAsync` and polls `asyncoperations` until complete (statecode 3 = Succeeded). Args: `--envUrl`, `--solutionName`, `--managed` (`true`/`false`), `--token` (opt). Output: `{ asyncOperationId, solutionName, managed }`. +- `scripts/lib/download-export-data.js`: Downloads the solution zip after a successful async export via `DownloadSolutionExportData`. Decodes the base64 response and writes the zip file to disk. Args: `--envUrl`, `--asyncOperationId`, `--outputPath`, `--token` (opt). Output: `{ zipPath, fileSizeBytes }`. + +### Shared References + +Shared reference documents live at `references/` and are referenced by multiple skills via relative paths (e.g., `../../references/odata-common.md`). This avoids duplicating common patterns across skill-specific reference docs and SKILL.md files. + +- `odata-common.md`: Auth headers, PowerShell token helper, token refresh cadence, HTTP status codes, Dataverse error codes, retry pattern. Used by `setup-datamodel` and `add-sample-data`. +- `dataverse-prerequisites.md`: PAC CLI auth check (`pac env who`), Azure CLI token acquisition, API access verification (`WhoAmI`). Used by `setup-datamodel`, `add-sample-data`, `setup-solution`, `export-solution`, and `import-solution`. +- `framework-conventions.md`: Supported frameworks, framework → build tool / router / build output / public dir / index HTML mapping, framework detection via `package.json`, route discovery patterns. Used by `create-site` and `add-seo`. +- `datamodel-manifest-schema.md`: Schema spec for `.datamodel-manifest.json` (fields, types, usage). Written by `setup-datamodel`, read by `add-sample-data`, validated by `validate-datamodel.js`. +- `skill-tracking-reference.md`: Skill usage tracking instructions — script invocation syntax, skill name mapping table, and YAML format. Referenced by all skills to record usage via `update-skill-tracking.js`. +- `solution-api-patterns.md`: OData body templates for publisher POST, solution POST, `AddSolutionComponent`, `ExportSolutionAsync`, `DownloadSolutionExportData`, `ImportSolutionAsync`, `StageSolution`. Also documents `.solution-manifest.json` format. Used by `setup-solution`, `export-solution`, and `import-solution`. +- `deployment-error-catalog.md`: Catalog of 10 known deployment failure patterns (stale manifest, blocked JS, missing websiteRecordId, auth expiry, empty build output, solution missing dependencies, solution timeout, PAC CLI not installed, environment mismatch, duplicate component). Each entry includes root cause, severity, auto-fix availability, and fix procedure. Used by `diagnose-deployment`. +- `cicd-pipeline-patterns.md`: PAC CLI service principal auth syntax; complete ADO `azure-pipelines.yml` template; complete GitHub Actions `deploy.yml` template; commented solution export/import blocks; secrets/variables setup tables; manual steps that cannot be automated; **Power Platform Pipelines API patterns** (HAR-confirmed): host env discovery via `RetrieveSetting`, `deploymentenvironments` create + `validationstatus` poll, `deploymentpipelines` create, `$ref` associate source (relative path format), `deploymentstages` create, `RetrieveDeploymentPipelineInfo`, stage run create + `ValidatePackageAsync` (204) + `operation` poll, `deploymentsettingsjson` PATCH, `DeployPackageAsync`, `stagerunstatus` terminal values, `docs/alm/last-pipeline.json` and `docs/alm/last-deploy.json` formats. Used by `setup-pipeline` and `deploy-pipeline`. +- `approval-gates.md`: Canonical terminology, marker syntax, and catalog of every user-confirmation point ("Approval Gate") across the ALM skill family. Defines six categories (`intent` / `plan` / `progress` / `consent` / `final` / `pause`), an explicit-pairing marker (`` + human `> 🚦 Gate (...)` block), the `cancel-leaves` vocabulary, and proposed lint rules (`GATE-must-have-marker`, `GATE-id-must-be-unique`, `GATE-must-be-in-catalog`, `GATE-intent-must-call-helper`, `GATE-cancel-leaves-known-vocab`). Currently scoped to the 12 ALM skills (`plan-alm`, `setup-solution`, `setup-pipeline`, `deploy-pipeline`, `export-solution`, `import-solution`, `configure-env-variables`, `ensure-pipelines-host`, `force-link-environment`, `activate-site`, `test-site`, `diagnose-deployment`). **The catalog will be extended to non-ALM skills in a follow-up.** New skills authoring any `AskUserQuestion` block should follow §3 (categories), §4 (marker syntax), and add their gates to §6 (catalog). + +Skill-specific reference docs (e.g., `skills/setup-datamodel/references/odata-api-patterns.md`) contain only patterns unique to that skill and point to the shared docs via `${CLAUDE_PLUGIN_ROOT}/references/` paths for common content. + +### MCP Integration + +Playwright MCP server for browser automation and live site previews during development. + +## Template System + +Framework templates use `__PLACEHOLDER__` tokens (e.g., `__SITE_NAME__`, `__PRIMARY_COLOR__`, `__BG_COLOR__`) that get replaced during site scaffolding. The `gitignore` file is stored without the dot prefix to avoid git interference in the plugin repo — it gets renamed to `.gitignore` during scaffolding. + +## Validation Scripts + +### `create-site/scripts/validate-site.js` + +Checks generated sites for: required files (`package.json`, `.gitignore`, `powerpages.config.json`), config schema fields (`$schema`, `compiledPath`, `siteName`, `defaultLandingPage`), build/dev scripts in package.json, unreplaced `__PLACEHOLDER__` tokens, git initialization, and `src/` directory existence. + +### `setup-datamodel/scripts/validate-datamodel.js` + +Checks created Dataverse data models by reading `.datamodel-manifest.json` (written by the `setup-datamodel` skill during table creation). Queries the Dataverse OData API to verify each table and column in the manifest actually exists in the environment. Gracefully exits 0 on auth errors (doesn't block if token expired) or when no manifest is found (not a data model session). + +### `add-seo/scripts/validate-seo.js` + +Checks SEO assets added to Power Pages sites: verifies `robots.txt` exists in `public/` with proper `User-agent` and `Sitemap` directives, `sitemap.xml` exists with `` and `` entries (no unreplaced placeholders), and `index.html` has `meta description` and `viewport` tags. Only runs validation when at least one SEO file (robots.txt or sitemap.xml) is detected — gracefully exits 0 otherwise to avoid blocking non-SEO sessions. + +### `create-webroles/scripts/validate-webroles.js` + +Checks that web role YAML files were created in `.powerpages-site/web-roles/`. Validates each file has required `id` and `name` fields and that the `id` field contains a valid UUID v4 format. Gracefully exits 0 when no `.powerpages-site/web-roles/` directory is found (not a web roles session). + +### `integrate-webapi/scripts/validate-webapi-integration.js` + +Checks that Web API integration code was created for a Power Pages code site: verifies the shared API client (`src/shared/powerPagesApi.ts` or equivalent) exists, at least one service file exists in `src/shared/services/` or `src/services/` with `/_api/` endpoint references, and corresponding type definition files exist in `src/types/`. Gracefully exits 0 when no integration files are detected (not an integration session). + +### `setup-auth/scripts/validate-auth.js` + +Checks that authentication and authorization code was created: verifies auth service (`src/services/authService.ts` or equivalent) exists with login/logout/getCurrentUser functions and anti-forgery token handling, Power Pages type declarations (`src/types/powerPages.d.ts`) exist, authorization utilities (`src/utils/authorization.ts`) exist, and an auth UI component (AuthButton or equivalent) exists. Gracefully exits 0 when no auth files are detected (not an auth session). + +### `setup-solution/scripts/validate-solution.js` + +Checks that `.solution-manifest.json` was written with required fields (`solution.uniqueName`, `solution.solutionId`, `publisher.publisherId`, at least one component of type 61). Queries Dataverse OData to confirm the solution actually exists in the environment. Gracefully exits 0 on auth errors or when no manifest is found. + +### `export-solution/scripts/validate-export.js` + +Checks that a solution zip file was written (`*_managed.zip` or `*_unmanaged.zip` pattern). Verifies file size > 1000 bytes and that `Solution.xml` is present inside the zip (via `unzip -l`). Gracefully exits 0 when no solution zip is found. + +### `import-solution/scripts/validate-import.js` + +Checks `docs/alm/last-import.json` marker for required fields (`solutionName`, `targetEnvironment`, `importedAt`). Blocks if all components failed to import (0 success + N failures). Gracefully exits 0 when no import marker is found. + +### `setup-pipeline/scripts/validate-pipeline.js` + +Checks for `docs/alm/last-pipeline.json` (Power Platform Pipelines path) — validates required fields: `pipelineId`, `hostEnvUrl`, `sourceDeploymentEnvironmentId`, non-empty `stages[]`, and each stage has `stageId` + `targetDeploymentEnvironmentId`. Also confirms `docs/pipeline-setup.md` was created. Falls back to checking `azure-pipelines.yml` or `.github/workflows/deploy.yml` for YAML keys and `docs/ci-cd-setup.md` (GitHub/ADO future path). Gracefully exits 0 when no pipeline artifacts are found. + +### `deploy-pipeline/scripts/validate-deploy-pipeline.js` + +Checks `docs/alm/last-deploy.json` marker for required fields (`pipelineId`, `stageRunId`, `solutionName`, `status`, `deployedAt`). Blocks if `status === "Failed"` — a failed deployment requires investigation before retrying. Gracefully exits 0 when no deploy marker is found (not a deploy-pipeline session). + +## Skill Development Guide + +All skills in this plugin follow a consistent set of patterns. When creating a new skill, follow every convention below to maintain consistency across the plugin. ### Phase-Wise Workflow @@ -59,7 +339,7 @@ This runs a lightweight check comparing the local plugin version against `origin ### Key Patterns -- **User confirmation** — Pause with `AskUserQuestion` after gathering requirements, after presenting a plan, after implementation, and before deployment. +- **Approval Gates** — Every load-bearing `AskUserQuestion` is an **Approval Gate**. Pause at minimum after gathering requirements, after presenting a plan, after implementation, and before deployment (Three-Point Approval Pattern). For ALM skills, every gate must (a) be catalogued in `references/approval-gates.md` §6 with a stable `gate-id`, and (b) be marked in SKILL.md with the explicit-pairing comment `` followed by a human-readable `> 🚦 **Gate (...)**` block. New ALM skills must extend the catalog in the same PR that introduces the skill. Non-ALM skills should follow the same convention as the catalog is extended in a follow-up; lint runs warn-only on non-ALM skills until then. Do not coin alternative terms ("review gate", "approval checkpoint", "manual step" etc.) — the canonical term is **Approval Gate**. - **Deployment prompt** — Skills that modify site artifacts should end by asking "Ready to deploy?" and invoke `/deploy-site` if yes. - **Lifecycle hooks** — If a skill needs command validation or checklist enforcement, update `hooks/hooks.json` and `scripts/lib/powerpages-hook-utils.js`. Do not define hook registration in individual `SKILL.md` files. - **Graceful failure** — Track API call results, never auto-rollback, report failures clearly, continue with remaining items. @@ -69,6 +349,33 @@ This runs a lightweight check comparing the local plugin version against `origin - **Skill tracking** — Every skill must record usage in its final phase via `> Reference: ${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` (pointer pattern, not hardcoded command). When adding a new skill, also add its entry to the skill name mapping table in `references/skill-tracking-reference.md`. - **Shell-agnostic docs** — SKILL.md, agent, and reference files must not embed shell-specific syntax inside shell commands or code blocks. Use ` ```bash ` fences (or plain ` ``` `) only for cross-platform commands like `pac`, `az`, `dotnet`, and `node`. Do not use PowerShell cmdlets (`Get-ChildItem`, `Test-Path`, `New-Item`, `Get-Content`, `Remove-Item`, `ConvertFrom-Json`, `Invoke-RestMethod`, etc.) or PowerShell-only variable syntax inside shell commands/code blocks (e.g., `$var = command`, `$env:...`) — prefer `` angle-bracket style there (e.g., ``). Repo runtime placeholders used in prose/templates (such as `**Initial request:** $ARGUMENTS`) are allowed. For filesystem and JSON operations the agent already has first-class tools (`Glob`, `Read`, `Write`, `Edit`) — describe the intent in prose rather than prescribing a shell command. - **Dataverse API calls** — Use deterministic Node.js scripts (in the skill's `scripts/` directory) for Dataverse API queries. Scripts should import `getAuthToken` and `makeRequest` from `scripts/lib/validation-helpers.js`. Never use inline PowerShell `Invoke-RestMethod` for API calls — scripts are more reliable, testable, and cross-platform. +- **ALM-aware by default** — Any skill that creates, modifies, or depends on Dataverse records that belong in a Power Pages site's solution (site components, env var definitions, web roles, site settings, server logic, cloud flow bindings, bot consumers, custom tables/columns, etc.) MUST ensure those records land in the user's solution when `.solution-manifest.json` exists. Concrete rules: + - **Solution selection — strict resolution order.** When a skill or script needs "which solution?" for an `AddSolutionComponent` call, resolve in this order and stop at the first match: + 1. **Explicit `--solutionUniqueName` CLI arg** (or `solutionName=…` skill argument). Always wins. Used by advanced flows and CI. + 2. **`.solution-manifest.json` in the project root** — read `solution.uniqueName`. This is the default path for nearly every invocation. + 3. **No manifest AND no explicit arg**: + - **Interactive skill**: query Dataverse for unmanaged solutions whose publisher prefix matches the site publisher, present them via `AskUserQuestion` alongside the option **"Run `/power-pages:setup-solution` first (recommended)"** and **"Leave in Default (not recommended)"**. Proceed only after explicit selection. + - **Non-interactive script**: exit with a clear error — `--solutionUniqueName not provided and no .solution-manifest.json found. Run /power-pages:setup-solution first, or pass --solutionUniqueName.` Never silently fall back to `Default`. + Skills must never auto-pick "the first solution that looks relevant" — auto-selection masks misconfigurations (wrong env, wrong branch, wrong project). + - **Component-creation scripts** must accept a `--solutionUniqueName` argument and, when provided, add the created record to that solution via `AddSolutionComponent`. Test that `solutionUniqueName` flows through end to end. + - **Skill workflows** must read `.solution-manifest.json` during prerequisite checks and pass the solution's `uniqueName` to any component-creation script they call. When no manifest is present, the skill should surface that gap to the user (per the resolution order above) rather than silently creating records in `Default`. + - **Skills that can leave Dataverse artifacts uncovered** (e.g. `setup-auth` writing OAuth secrets as env vars) must end by prompting the user to run `/power-pages:setup-solution` in sync mode so the discovery pass picks up any newly-created records. + - **New component types** added to Power Pages must be reflected in `scripts/lib/discover-site-components.js` (the single source of truth for site inventory) and, if applicable, in the `PPC_TYPE_LABELS` enum. Discovery should never silently skip a type. + +## Planned Skills (Not Yet Implemented) + +The following skills are planned but require POC validation before implementation: + +### Sprint 2 — Needs POC First + +- `setup-environments`: Blocked by BAP API auth scope (`https://service.powerapps.com/`) differing from Dataverse token scope — needs POC in personal tenant. Managed env flag + admin assignment also need validation. +- `setup-git-versioning`: Blocked pending determination of whether `pac pages` has a git-config subcommand, or if git integration is portal-only. If no CLI surface exists, this reduces to a guidance doc. +- `configure-secrets`: Blocked pending mapping of full API path for Key Vault-backed environment variables (`environmentvariablevalues` with `keyVaultReference` JSON) and validation of `az keyvault set-policy` assignment in same session. + +### Sprint 3 — Future / Complex + +- `setup-approvals`: Blocked by the fact that ADO environment approval gates have no create/trigger API — the approval workflow setup requires human interaction in the ADO UI. Power Platform Pipelines approval status (`UpdateApprovalStatus`) schema is undocumented. +- `setup-pipeline` GitHub/ADO paths: Currently "coming soon" stubs. Full implementation spec is at `C:\Users\nityagi\OneDrive - Microsoft\Design Documents\Plans\ALM skills for plugin\ado-cicd-skills-guide.md`. ## Common Review Pitfalls diff --git a/plugins/power-pages/PLUGIN_DEVELOPMENT_GUIDE.md b/plugins/power-pages/PLUGIN_DEVELOPMENT_GUIDE.md index 99e545a2c..f158998d3 100644 --- a/plugins/power-pages/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/plugins/power-pages/PLUGIN_DEVELOPMENT_GUIDE.md @@ -270,6 +270,8 @@ Every skill pauses for user approval at three junctures: Between checkpoints, skills work **autonomously** — no mid-analysis questions. +> **Approval Gates — canonical catalog.** Every individual `AskUserQuestion` that meets the gate test (would Cancel leave partial or complete-but-wrong state behind?) is an **Approval Gate**. See `references/approval-gates.md` for the canonical terminology, the six categories (`intent` / `plan` / `progress` / `consent` / `final` / `pause`), the marker syntax (`` + human-readable `> 🚦 **Gate (...)**` block), the per-skill catalog, and the lint rules (`GATE-must-have-marker`, `GATE-id-must-be-unique`, `GATE-must-be-in-catalog`, `GATE-intent-must-call-helper`, `GATE-cancel-leaves-known-vocab`). ALM skills enforce these rules with `severity: 'error'`; non-ALM skills currently warn-only until the catalog extends. + ### Approval in Practice | Skill | Checkpoint 1 (Discovery) | Checkpoint 2 (Plan) | Checkpoint 3 (Deploy) | @@ -302,3 +304,149 @@ their own contacts and create new ones, but cannot modify or delete existing rec > **Acceptance criterion:** Every skill must implement the three-point approval pattern. No approval-gated action may proceed without explicit user confirmation via `AskUserQuestion`. Skills must work autonomously between checkpoints — no mid-analysis questions. --- + +## ALM Checklist for New Skills + +Any skill that creates, modifies, or depends on Dataverse records that belong in a Power Pages site's solution (site components, env var definitions, web roles, site settings, server logic, cloud flow bindings, bot consumers, custom tables, etc.) **must** comply with the ALM-aware-by-default principle documented in `AGENTS.md`. Concretely, before merging: + +- [ ] **SKILL.md Phase 1** reads `.solution-manifest.json` if present; stores `solution.uniqueName` for downstream phases +- [ ] Any `scripts/*.js` that writes to Dataverse accepts a `--solutionUniqueName` argument and imports `./lib/resolve-target-solution` to honor the [strict resolution order](AGENTS.md#alm-aware-by-default) +- [ ] Records created by the skill are added to the resolved solution via `AddSolutionComponent` (never silently left in `Default`) +- [ ] Any new `powerpagecomponenttype` values used in the skill are reflected in `scripts/lib/discover-site-components.js` (`PPC_TYPE_LABELS`). Discovery must never skip a type +- [ ] Skills that create Dataverse artifacts but might not know the target solution (e.g. utility skills, skills that can run before `setup-solution`) end by prompting the user to run `/power-pages:setup-solution` in sync mode +- [ ] `node scripts/lint-skills-alm.js` reports **zero findings** on the changed skill + scripts +- [ ] A `node:test` suite covers the new component-creation script, including an assertion that `--solutionUniqueName` flows through to `AddSolutionComponent` + +### Solution Resolution Order + +When a skill or script needs "which solution?", resolve in this order and stop at the first match: + +1. **Explicit `--solutionUniqueName` CLI argument / skill argument** — always wins +2. **`.solution-manifest.json` in project root** — the default path +3. **Neither present** — interactive skill: prompt via `AskUserQuestion` with a list of candidate user solutions (publisher-prefix matches) + option to run `/power-pages:setup-solution` first. Non-interactive script: exit with `NoSolutionConfiguredError` and a clear hint. **Never silently fall back to `Default`.** + +### Lint Command + +Run locally before submitting a PR: + +```powershell +node plugins/power-pages/scripts/lint-skills-alm.js +``` + +Exits 0 with `alm-lint: 0 findings` when clean; exits 1 and prints file/rule/message for each violation otherwise. Waive individual findings with an `alm-lint-ignore: ` comment at the relevant line (prefer `` in Markdown, `// …` in JS). + +### Related Helpers + +- `scripts/lib/resolve-target-solution.js` — implements the 3-step resolution order; use from every component-creation script +- `scripts/lib/discover-site-components.js` — one-call site inventory (powerpagecomponents, flows, env vars, custom tables) + diff against an existing solution; use in Inventory / pre-export phases + +> **Acceptance criterion:** No component-creation skill may ship that leaves a Dataverse record orphaned in `Default`. The lint, the resolver, and the discovery module together make this the path of least resistance — please use them. + +--- + +## Hook design for skill validation + +Hook validators run after a skill executes (or, badly, every time the assistant pauses) to surface incomplete state to the agent. **Get this wrong and you cost users real money.** A real incident on this plugin (BYOC supplier portal, 2026-05-04) had three Stop hooks LLM-evaluating skill-completion every turn, all returning `{ ok: false }` with multi-paragraph reasons because the user had explicitly deferred ALM. The agent kept acknowledging, the hooks kept refiring on each acknowledgement, and the transcript grew quadratically until the cost was visible. + +### Anti-patterns — do not use these + +| Pattern | Why it's wrong | +|---|---| +| **`type: prompt` Stop hooks for skill-completion** | Stop fires on every assistant pause, including user-input waits. LLM-evaluation can't reliably tell "this skill wasn't supposed to run" from "this skill failed", so it returns `ok: false` whenever artifacts are missing — forcing continuation. Combine with multiple skills' hooks all firing per turn, and you have a runaway loop. The plugin removed all of these in commit `e670581`. **Do not re-introduce.** | +| **`process.exit(2)` (block) for soft "did this complete?" checks** | Blocking exit forces continuation. For "did the skill complete cleanly?" you almost never want forced continuation — you want a one-time advisory the agent acknowledges and moves on. Reserve `block()` for **hard correctness gates** only: malformed marker files, `docs/alm/last-deploy.json.status === "Failed"`, lint failures, secrets in diffs. | +| **Re-deriving completion from ephemeral artifacts** | If a validator checks `docs/foo.html` exists and the user legitimately deletes that file (cleanup, project move), the hook fails forever. Validation should reflect *intent*, not *artifact presence*. Use marker files the skill writes deliberately. | +| **Stop hooks that duplicate PostToolUse hooks** | If a skill is already validated by `hooks/hooks.json` PostToolUse on the `Skill` tool (which fires once per skill invocation), a Stop hook running the same validator just adds noise — fires too often. Pick one. **Prefer PostToolUse.** | + +### Recommended patterns + +#### 1. Deterministic command validators with marker-file gates + +Each skill writes a marker file at completion (`docs/alm/last-pipeline.json`, `docs/alm/last-deploy.json`, `docs/alm-plan.html`, `.solution-manifest.json`, etc.). The validator: + +```js +const { runValidation, findProjectRoot, approve, block, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); + +runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd) || cwd; + + // 1. Honor explicit deferral first — silent-approve regardless of state. + if (readDeferralMarker(projectRoot)) return approve(); + + // 2. No marker -> not a foo session -> silent-approve. + const markerPath = path.join(projectRoot, '.last-foo.json'); + if (!fs.existsSync(markerPath)) return approve(); + + // 3. Marker exists -> validate its shape. Block ONLY on hard failures. + let marker; + try { marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); } + catch { return block('.last-foo.json could not be parsed as JSON.'); } + + if (marker.status === 'Failed') { + return block('Last foo run failed (id: ' + marker.runId + '). Investigate before retrying.'); + } + if (!marker.requiredField) { + return block('.last-foo.json is missing required field: requiredField'); + } + + return approve(); +}); +``` + +This pattern doesn't loop: silent-approve produces no output, no forced continuation. Block fires only when the marker exists AND is genuinely broken. + +#### 2. Honor deferral markers before any other check + +If a user explicitly defers a skill family (e.g. ALM for a project handled by infra team's pipeline), they drop a marker file (`.alm-deferred` for ALM). All related validators short-circuit on this marker as their FIRST check. + +`scripts/lib/validation-helpers.js` exports `readDeferralMarker(projectRoot)` — every ALM validator on this plugin calls it first. The marker is recognized in three formats: empty (touch file), plain text (one-line reason), or JSON (`{ deferredAt, deferredBy, reason, scope }`). + +User-facing usage: +```bash +# At the project root: +echo '{"reason":"ni-dev — ALM handled by infra"}' > .alm-deferred +``` + +#### 3. Prefer PostToolUse over Stop for skill-completion + +PostToolUse on the `Skill` tool fires **once per skill invocation**. Stop fires on **every assistant pause** (including user-input waits — every "Continue?" prompt fires it). + +This plugin uses PostToolUse via `hooks/hooks.json` → `run-skill-posttool-validation.js` → per-skill validator. Skill frontmatter must NOT declare its own `hooks: Stop:` block — those duplicate the centralized PostToolUse hook and fire too often. To wire validation for a new skill, register it in the `TRACKED_SKILLS` map in `scripts/lib/powerpages-hook-utils.js` (see `AGENTS.md` → "Hooks" for the registration steps). + +#### 4. Skills write explicit status, not just artifact presence + +Marker files include a `status` field: + +```json +{ + "status": "Completed", // or "Draft" | "Approved" | "In Execution" | "Deferred" | "Failed" + ... +} +``` + +Validators check `status` when present rather than re-deriving completion from secondary artifacts. A `"Deferred"` status in the marker file is also a valid signal — silent-approve regardless of other field presence. + +### When you genuinely need a hard gate + +Some checks DO warrant blocking — they're not "did the skill complete?" checks but "did something go wrong that requires the agent to retry?" Examples that justify `block()`: + +- Marker file exists but is malformed JSON. +- Marker file's `status === "Failed"` and the agent should investigate before continuing. +- Required field is missing from a present marker (e.g. `docs/alm/last-pipeline.json` without `pipelineId`). +- Lint failure on a file the skill just wrote. +- Secrets detected in a diff the skill is about to commit. + +These all share a property: the artifact is present, not absent. Absent artifacts always silent-approve. + +### Acceptance criterion + +A new skill's validator must: + +1. Silent-approve when its marker file is absent. +2. Silent-approve when `.alm-deferred` (or skill-specific deferral marker) is present. +3. Block ONLY when the marker is present AND in a state that requires retry. +4. Not register a `type: prompt` Stop hook under any circumstances. +5. Use `runValidation()` from `validation-helpers.js` so it inherits the standard try/catch and silent-approve-on-error fallback. + +If your validator can return `block()` when no skill-related work happened in the session, fix it before merging. The cost of getting this wrong is real-money runaway loops, not just noisy errors. + +--- diff --git a/plugins/power-pages/README.md b/plugins/power-pages/README.md index 2d155e3e3..6301b71c2 100644 --- a/plugins/power-pages/README.md +++ b/plugins/power-pages/README.md @@ -290,6 +290,10 @@ The plugin invokes multiple tools during a session. To reduce approval prompts: claude --dangerously-skip-permissions ``` +## ALM prompts you may see + +Several skills now ask about solution identity, orphan components, and pre-export completeness. These prompts catch a real class of bugs where Dataverse records silently stay behind in the `Default` solution. See **[`references/alm-prompts.md`](references/alm-prompts.md)** for a user-facing walkthrough of each prompt and how to respond. + ## Documentation - [Power Pages AI Plugin Documentation](https://learn.microsoft.com/power-pages/configure/create-code-site-using-claude-code) @@ -297,6 +301,7 @@ claude --dangerously-skip-permissions - [PAC CLI Reference](https://learn.microsoft.com/power-platform/developer/cli/reference/pages) - [Power Pages REST API](https://learn.microsoft.com/rest/api/power-platform/powerpages/websites) - [Dataverse Web API](https://learn.microsoft.com/power-apps/developer/data-platform/webapi/overview) +- [ALM prompts — user guide](references/alm-prompts.md) ## Testing validator scripts diff --git a/plugins/power-pages/references/alm-docs-grounding.md b/plugins/power-pages/references/alm-docs-grounding.md new file mode 100644 index 000000000..5ee9a2ab1 --- /dev/null +++ b/plugins/power-pages/references/alm-docs-grounding.md @@ -0,0 +1,81 @@ +# ALM Documentation Grounding (Phase 1.5) + +Power Platform ALM (solutions, pipelines, host environments) is documented on Microsoft Learn and the docs evolve — new component types, changed API signatures, expanded splitting guidance. **Never rely on hardcoded URLs or stale schema knowledge.** Always search Microsoft Learn dynamically at the start of each ALM skill run so the agent grounds itself in what's currently true. + +This reference is shared by `setup-solution`, `export-solution`, `import-solution`, `diagnose-deployment`, `setup-pipeline`, `deploy-pipeline`, and `ensure-pipelines-host`. Each SKILL.md invokes a Phase 1.5 step that points here. + +## Anchor docs + +| Domain | Canonical Microsoft Learn entry point | +|---|---| +| Solutions (concepts, lifecycle, components, managed vs unmanaged) | `https://learn.microsoft.com/en-us/power-platform/alm/solution-concepts-alm` | +| Power Platform Pipelines (host setup, stages, deployments, approvals) | `https://learn.microsoft.com/en-us/power-platform/alm/pipelines` | + +These pages each link out to a constellation of sister pages — pick whichever sister pages match the current skill's scope. + +## Discovery strategy (Phase 1.5) + +Cap the grounding step at ~30 seconds total. Don't let it block the rest of the skill run on Microsoft Learn outages — if the search or fetch errors out, log a one-line note and continue. + +### Step 1: Search + +Call `mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search` once with a skill-specific query (see the per-skill table below). Capture the top 5 results. + +### Step 2: Collect unique URLs + +Extract `contentUrl` values from results. Keep pages that match: + +- `learn.microsoft.com/.../power-platform/alm/*` +- `learn.microsoft.com/.../power-pages/configure/*-alm` or `*solution*` or `*pipeline*` +- `learn.microsoft.com/.../power-apps/maker/data-platform/solution*` +- `learn.microsoft.com/.../power-apps/developer/data-platform/*solution*` + +Discard release-plan announcements, blog posts, and unrelated configuration pages. + +### Step 3: Fetch the canonical page(s) + +Fetch the anchor doc for the skill's domain (table below) plus up to 1 sister page that matches the current scope. Use parallel `mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch` calls so the wall-clock cost stays low. + +### Step 4: Summarize for the agent + +Output a one-paragraph summary noting: +- Anything that changed since the last skill run (breaking field renames, new component types, deprecated actions) +- Any new pages discovered that aren't in the per-skill known-pages table +- Whether the current skill's hardcoded patterns (e.g., HAR-confirmed payloads in `cicd-pipeline-patterns.md` / `solution-api-patterns.md`) are still in line with what the docs say + +If the search results reveal a new pattern the skill should adopt, surface it as a soft suggestion in the agent's next prompt — don't change behavior silently. + +## Per-skill query templates + +| Skill | Phase 1.5 query (passed to `microsoft_docs_search`) | Anchor doc to fetch | +|---|---|---| +| `setup-solution` | `Power Pages solution publisher creation Dataverse component types ALM` | `solution-concepts-alm` | +| `export-solution` | `Power Pages solution export managed unmanaged ExportSolutionAsync ALM` | `solution-concepts-alm` | +| `import-solution` | `Power Pages solution import staging missing dependencies ImportSolutionAsync ALM` | `solution-concepts-alm` | +| `diagnose-deployment` | `Power Pages deployment errors solution import troubleshooting` | `solution-concepts-alm` | +| `setup-pipeline` | `Power Platform Pipelines setup OData API host environment deploymentenvironments` | `pipelines` | +| `deploy-pipeline` | `Power Platform Pipelines stage run validation ValidatePackageAsync DeployPackageAsync approval` | `pipelines` | +| `ensure-pipelines-host` | `Power Platform Pipelines host environment Platform Host Custom Host` | `pipelines` | + +## What this is NOT + +- **Not a replacement for the HAR-confirmed reference docs.** `references/cicd-pipeline-patterns.md` and `references/solution-api-patterns.md` capture exact request bodies that have been verified against live Dataverse + BAP responses. Those stay authoritative for *what to send*. Microsoft Learn grounding is for *what's currently documented* — the two should agree, but when they diverge the HAR-confirmed pattern wins until the divergence is investigated. +- **Not a per-component fetch.** Don't fetch a page for each component the skill creates. One search + one anchor fetch + at most one sister page per skill run. +- **Not blocking.** If MCP server is down or the search returns nothing relevant, log a note and proceed. ALM skills must remain runnable offline. + +## Phase 1.5 block to paste into a skill + +Each skill's SKILL.md should embed a phase block like this (substitute the per-skill query and anchor doc): + +```markdown +### Phase 1.5 — Ground in current ALM documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: ``. +2. Fetch the canonical anchor page (``) and at most one sister page that matches the current scope, in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what the docs say today — flag any breaking changes vs. the HAR-confirmed patterns in `${CLAUDE_PLUGIN_ROOT}/references/.md`. +4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning. +``` diff --git a/plugins/power-pages/references/alm-prompts.md b/plugins/power-pages/references/alm-prompts.md new file mode 100644 index 000000000..ded8ffc66 --- /dev/null +++ b/plugins/power-pages/references/alm-prompts.md @@ -0,0 +1,127 @@ +# ALM prompts — what they mean and how to respond + +As of the 2026-04 plugin release, several Power Pages skills ask ALM-related questions that weren't in earlier versions. These prompts prevent a recurring class of bugs where Dataverse records created by one skill (an env var, a server logic record, a cloud flow binding) silently landed in the `Default` solution and were never promoted to staging or production. + +This page explains each prompt, why it exists, and what to pick. + +--- + +## 1. "Existing solution manifest found. Sync mode?" — `/power-pages:setup-solution` + +**What you'll see** + +``` +Found existing solution "ContosoSite" v1.0.0.2. Running in sync mode — I'll +discover the current site inventory, diff against what's already in the +solution, and only add missing components. +``` + +**What happened** — the skill noticed a `.solution-manifest.json` in your project root, so it's skipping the "create publisher + solution" flow. Sync mode adopts any components added to the site after the solution was first created — for example, a server logic added via `/power-pages:add-server-logic` after your initial setup. + +**What to do** — nothing. Sync mode is the recommended path when you've added components since your last setup run. If you actually meant to start fresh (different publisher, different solution), rename or delete the existing manifest first. + +**When you'd see this most often** — your second, third, …, Nth run of `setup-solution` on the same project. First-time users get the full fresh setup. + +--- + +## 2. "Adopt orphaned env var definitions?" — `/power-pages:setup-solution` Step 5.4b + +**What you'll see** + +``` +We found env var definitions with your publisher prefix (crd50_) that aren't +in ContosoSite yet. Select the ones you want to include. + +1. crd50_auth_openauth_microsoft_clientsecret (Microsoft OAuth Client Secret) + — type Secret, currently in: DEFAULT-ONLY +2. crd50_FeatureFlag (Feature Flag) + — type String, currently in: IN OTHER SOLUTION: AuthConfig +``` + +**What happened** — the skill ran a publisher-scoped search and found env vars that exist in Dataverse but live only in the `Default` solution (or in a different user solution). `DEFAULT-ONLY` orphans are almost always a side effect of another skill (like `setup-auth` generating an OAuth secret) — they should usually be adopted so they travel with your deployment. + +**How to decide** + +| Tag on entry | What it means | Usual choice | +|---|---|---| +| `DEFAULT-ONLY` | Created by some skill, never added to any user solution | **Include** — otherwise it won't travel to staging/prod | +| `IN OTHER SOLUTION: ` | Already owned by a different user solution you created | **Skip** — adding it here duplicates ownership | + +**What if I skip?** — secrets still exist in the current environment, but they won't export with your solution. Target environments will show "environment variable definition missing" errors for any code that references the var. + +--- + +## 3. "The solution is missing N components — proceed anyway?" — `/power-pages:export-solution` Phase 2.5 and `/power-pages:deploy-pipeline` Phase 3.5 + +**What you'll see** + +``` +The source solution appears incomplete relative to the live site. What +would you like to do? + + 1. Run /power-pages:setup-solution now (sync mode) — adopts missing + components and bumps the version, then resume this export (Recommended) + 2. Export as-is — the missing components will not reach the target + 3. Cancel — I'll investigate first +``` + +**What happened** — before shipping, the skill compared your site's actual components to what's in the solution and found drift. The most common causes: + +- A cloud flow was added via Power Automate UI and never registered to the solution. +- A server logic was added with `/power-pages:add-server-logic` but setup-solution hasn't been re-run since. +- A bot was published to the site and the bot consumer record wasn't added to the solution. +- An env var definition was created in isolation and the solution import step missed it. + +**How to decide** + +| Option | Pick it when | Result | +|---|---|---| +| **Run sync mode now (Recommended)** | You expected this component to travel. Almost always. | Sync runs, version bumps, your export/deploy resumes. | +| **Export as-is** | You have a specific reason (staging-only test, known-deferred component) | The gap is recorded in `docs/alm/last-deploy.json` under `knownGaps` for audit. | +| **Cancel** | You're not sure what's happening | Nothing changes. Investigate, then re-run. | + +**What if I pick "Export as-is"?** — the component stays in your source environment but is never added to the export zip. Target environments won't have it until a later deploy brings it along. + +--- + +## 4. "env var created but no target solution resolved" — background warning from `create-environment-variable.js` + +**What you'll see (on stderr)** + +``` +Warning: env var "crd50_ApiSecret" was created but no target solution was resolved. +It currently lives only in the Default solution. Pass --solutionUniqueName +or run /power-pages:setup-solution to capture it. +``` + +**What happened** — a skill (or you directly) invoked `create-environment-variable.js` without a `--solutionUniqueName` argument AND there was no `.solution-manifest.json` in the working directory. The env var definition still succeeded in Dataverse, but it's orphaned. + +**What to do** + +1. Run `/power-pages:setup-solution` in the affected project — sync mode will find and adopt the new env var. +2. Or re-invoke the creating skill from a directory that has a `.solution-manifest.json`. +3. Or, for a one-off, pass `--solutionUniqueName ContosoSite` explicitly. + +This is a warning, not an error — the skill that called the script still succeeds. The warning exists so you notice the gap before it bites you during promotion. + +--- + +## FAQ + +**Q: Can I disable these prompts?** +Not globally — they gate against a real class of production bugs. But individual skills that are intentionally exempt (e.g. a read-only diagnostic skill) can be allowlisted in `plugins/power-pages/.almlintignore`. Ask in your team review if a blanket exemption is needed. + +**Q: Will I see these on first-time setup?** +No. Fresh projects skip the sync-mode prompt, and the orphan-adoption step only triggers when it finds orphans. Completeness checks in export/deploy only ask when there's actual drift. + +**Q: How do I test that a component will travel correctly?** +Run `/power-pages:export-solution` in a scratch output directory and inspect the zip. The solution.xml manifest lists everything included — if a component you expected to see is missing, sync mode is the fix. + +**Q: The pre-deploy completeness check caught something — should I always run sync mode?** +Yes, unless you have a specific reason to defer. Sync mode is the idempotent "bring solution into alignment with the site" operation. + +--- + +## Skill developers: see PLUGIN_DEVELOPMENT_GUIDE.md + +If you're building a new Power Pages skill, these prompts come from rules enforced by `scripts/lint-skills-alm.js`. See `PLUGIN_DEVELOPMENT_GUIDE.md` → "ALM Checklist for New Skills" and `AGENTS.md` → "ALM-aware by default" for the developer-facing rules. diff --git a/plugins/power-pages/references/approval-gates.md b/plugins/power-pages/references/approval-gates.md new file mode 100644 index 000000000..c33d141f0 --- /dev/null +++ b/plugins/power-pages/references/approval-gates.md @@ -0,0 +1,538 @@ +# Approval Gates — ALM Skill Catalog (Draft v2) + +> **Status: DRAFT v2.** Addresses review feedback on v1. +> +> **Scope: ALM skills only.** §6 enumerates every `AskUserQuestion` in the 12 ALM skills (`plan-alm`, `setup-solution`, `setup-pipeline`, `deploy-pipeline`, `export-solution`, `import-solution`, `configure-env-variables`, `ensure-pipelines-host`, `force-link-environment`, `activate-site`, `test-site`, `diagnose-deployment`). Non-ALM skills (`create-site`, `deploy-site`, `add-cloud-flow`, `add-server-logic`, `add-seo`, `add-sample-data`, `audit-permissions`, `create-webroles`, `integrate-backend`, `integrate-webapi`, `setup-auth`, `setup-datamodel`) are intentionally **deferred** — see §10. Catalog completeness is asserted only for ALM. +> +> **Not yet applied to SKILL.md files.** This document defines terminology + marker + lint design. The follow-up PR will add the markers to each ALM SKILL.md and ship the lint rule. Run the decisions in §9 first. + +--- + +## 1. Terminology — is "approval gate" / "review gate" standard? + +Short answer: **"gate" has strong industry precedent. "review gate" specifically does not. "Approval gate" is the closest match to widely-used vocabulary.** + +| Term | Source | Match for our pattern | +|---|---|---| +| **Approval gate** | Azure DevOps Release Pipelines ("Pre/post-deployment approvals and gates"). Spinnaker "Manual Judgment" stages. GitHub Environments "Required reviewers". | ✅ Closest to our usage. | +| **Deployment gate** | Same CI/CD heritage; often paired with "approval gate". | ✅ Narrower — fits final-deploy consent specifically. | +| **Stage gate** | Robert Cooper's Stage-Gate process (product development, 1986). | ⚠️ Conceptually similar but rooted in NPD, not software. | +| **Phase gate** | Same as stage-gate. Used loosely in PM. | ⚠️ Imprecise. | +| **Human-in-the-loop (HITL) checkpoint** | AI agent / ML ops vocabulary. | ✅ Captures the philosophy but verbose. | +| **Manual approval / approval step** | GitHub Actions, ADO Classic, Spinnaker. | ✅ Common synonym. | +| **Review gate** | Not a recognized industry term. Some internal change-management usage but no canonical reference. | ❌ Project-specific construction. | + +In **Claude Code / Anthropic skills** specifically, there is **no formal name** for this pattern. The mechanism is `AskUserQuestion`. The closest official framing in `PLUGIN_DEVELOPMENT_GUIDE.md` is the **Three-Point Approval Pattern** (after discovery, after planning, before deployment) — that's our internal convention, not an Anthropic one. + +### Recommendation + +Adopt **"Approval Gate"** (capitalized as a proper noun) as the canonical term. Drop "review gate" if it's in informal use. Rationale: + +- Strong CI/CD heritage that maps cleanly to ALM skills. +- Concrete: makes clear *someone has to approve*. +- Already the most common existing word in our SKILL.md files (`Phase 0 — ALM plan gate`, `Final deploy consent gate`, `Post-sync approval gate`). +- Composes well with category prefixes (see §3 below). +- Distinct from "checkpoint" (no enforcement implication) and "review" (passive — gates are active blockers). + +--- + +## 2. What an Approval Gate is + +An **Approval Gate** is a point in a skill workflow where: + +1. The skill **stops** and asks the user a question via `AskUserQuestion`. +2. The skill **cannot proceed past the gate** without an explicit user answer. +3. The blast radius of skipping the gate is **non-trivial**. + +**The test:** *"would any state — partial or complete-but-wrong — be left behind if the user answered Cancel at this point, and is that state expensive to undo?"* If yes, it's a gate. + +Things that are **not** Approval Gates (and shouldn't be marked as such): + +- **Informational sub-prompts** that just shape an upcoming gate's options without changing what gets created. Example: `plan-alm` Phase 2 "Help me decide" expanding to a comparison table is not a gate; the gate is the strategy choice that follows. +- **Free-text fallback prompts** that fill in a missing required field (e.g., "I couldn't auto-detect your site URL — paste it") — these are data-gathering, not approval. +- **Discovery-stage confirmations** that simply confirm what was found, with no side effect to undo. +- **Validation polls** (the user isn't being asked anything). +- **Sync-mode `TaskUpdate` checkpoints**. + +When in doubt, apply the test above. Borderline cases get the marker; lint complains only if the marker is missing. + +--- + +## 3. Six gate categories + +Each gate fits one of six categories. Each gets a one-word prefix in the marker syntax (§4) so readers and lint can tell them apart at a glance. **The defining attribute** for each category is what distinguishes its blast radius — not just when it fires. + +### 3.1 `intent` — Entry / orchestration gate +**Defining attribute:** Helper-script-backed; reads deterministic state from a real script, branches on JSON. Not LLM reasoning. + +**Question the user answers:** *"Should this skill even run, given current project state?"* + +**Mechanism:** Phase 0 calls a helper (`check-alm-plan.js`); the JSON return value (`{ exists, deferred, stale, ... }`) determines whether to surface the gate or pass through silently. The gate itself is an `AskUserQuestion` *only* when the helper returns a "no plan / stale plan" state. + +**Lint implication:** The `intent` marker requires the SKILL.md to invoke a known helper script (one of: `check-alm-plan.js`, `verify-alm-prerequisites.js`, `check-activation-status.js`). Inline LLM-evaluated entry conditions don't qualify. + +### 3.2 `plan` — Plan-approval gate +**Defining attribute:** User signs off on a rendered artifact (HTML plan, manifest, parameter table, permissions matrix) *before* the skill writes anything Dataverse-side. + +**Question the user answers:** *"Does this match what you wanted to do?"* + +**Mechanism:** Skill presents a rendered artifact and a 2–4 option `AskUserQuestion`. Cancel exits without any Dataverse / filesystem write. + +### 3.3 `progress` — Mid-flow re-confirmation gate +**Defining attribute:** A condition emerged mid-run that wasn't visible at planning time; the user re-confirms before the skill continues with the delta. + +**Question the user answers:** *"The situation changed — proceed with the new state?"* + +**Mechanism:** Triggered by a detected condition (sync mode happened; new components were adopted; pre-flight found a gap). Skill pauses and re-prompts with the delta surfaced inline. + +### 3.4 `consent` — Destructive / irreversible-action gate +**Defining attribute:** The action being approved changes **shared or irreversible state** — a tenant-wide security setting, a permanent naming choice, a cross-host stamp move, a managed-vs-unmanaged export choice. Distinguishing factor is **what kind of state changes**, not when in the flow. + +**Question the user answers:** *"This is destructive / irreversible — really proceed?"* + +**Mechanism:** Mandatory `AskUserQuestion` with consequences spelled out. Often non-skippable even when other flags pre-confirmed upstream. The "no `--yes` flag" rule applies. + +> **Note:** Both proactive (pre-flight) and reactive (after-failure) modifications of the same shared state are `consent` gates. Trigger timing doesn't change the category — `deploy-pipeline:2.5` (pre-flight unblock of `blockedattachments`) and `deploy-pipeline:7.6.2` (reactive unblock after `AttachmentBlocked` failure) both modify a tenant-wide setting and are therefore both `consent`. + +### 3.5 `final` — Last-call gate +**Defining attribute:** Immediately before the destructive API call. No work happens between the gate and the call except the call itself. + +**Question the user answers:** *"Ready to ship?"* + +**Mechanism:** Distinct from `consent` in that the destructive action has already been agreed in principle (often by upstream `plan` and `progress` gates) — this gate's job is only to convert that principle-level approval into "fire now" approval. Separates *validation passed* from *user wants to ship*. + +### 3.6 `pause` — External-system wait gate +**Defining attribute:** Nothing the *skill* is asking the user about. The *external platform* is requesting a human action (e.g., PPAC approval) and the skill is surfacing that wait through `AskUserQuestion`. + +**Question the user answers:** *"Have you done the thing the external system wants?"* + +**Mechanism:** Skill polls until external state changes. When the external state is `PendingApproval` / `AwaitingPreDeployApproval`, the skill surfaces it via `AskUserQuestion` and waits. Tooling must never auto-respond to a `pause` gate. + +--- + +### 3.7 Loop semantics — when a gate sits inside a loop + +> **The single biggest runtime failure mode of this strategy:** the LLM interprets the user's answer at the top of a loop as covering the *entire loop*, then proceeds through subsequent iterations without re-prompting. Documented runtime example: `deploy-pipeline` Phase 6.0 was skipped for iterations 2 and 3 of a 3-solution `MULTI_RUN_MODE` deploy after the user answered "staging" once at the top. The gate marker was present; the lint passed; the agent simply did not call `AskUserQuestion` again. + +The default behavior **per category** when a gate is inside a loop: + +| Category | Default when inside a loop | Override? | +|---|---|---| +| `intent` | **Once per skill invocation, before the loop.** Entry gates protect the skill from running with wrong project state — the project state doesn't change between iterations. | Not applicable. | +| `plan` | **Depends on what the gate is choosing.** A "pick a strategy" plan gate runs once before the loop. A "confirm this iteration's parameters" plan gate runs **once per iteration.** Each catalog row must state which. | SKILL.md prose. | +| `progress` | **Per occurrence of the triggering delta.** If sync mode runs twice in a loop, this gate fires twice. If a delta is detected only on iteration 2, it fires only on iteration 2. | Not applicable. | +| `consent` | **PER ITERATION when the destructive action repeats.** Each instance of the destructive call gets its own consent. A consent given for iteration 1 does NOT cover iteration 2 even if the destruction is the same shape. | Hard rule — never override. | +| `final` | **PER ITERATION, full stop.** The whole point of `final` is "fire immediately before the destructive call." If the destructive call runs `N` times in a loop, the gate fires `N` times. | Hard rule — never override. | +| `pause` | **Per occurrence of the external pending state.** Polling can re-enter PendingApproval after a retry; each entry gets its own pause prompt. | Not applicable. | + +**Required prose in SKILL.md** for any gate that sits inside a loop: + +1. The gate marker block (`> 🚦 **Gate (...)**`) must include an explicit line stating *"Fires PER LOOP ITERATION"* (or equivalent) and naming the loop variable. Example: *"Three solutions in `deploymentOrder` → three Phase 6.0 prompts."* +2. The loop description elsewhere in the SKILL.md must call out the gate by name in the per-iteration sequence. Example: *"For each entry in `DEPLOYMENT_ORDER`: ... fire Phase 6.0 consent gate ... call `DeployPackageAsync`."* +3. The marker block must explicitly negate the most common shortcut: *"The upstream Phase 2 stage selection (whether via interactive prompt or `--stage` argument) does NOT cover subsequent iterations."* + +**Why prose, not lint?** The lint catches the *presence* of a marker. It cannot prove the agent actually *fired* the `AskUserQuestion` call at runtime. Loop-semantics prose narrows the LLM's interpretation space so the shortcut becomes textually impossible — *"the gate fires N times for N iterations"* leaves no room to read it as *"once is enough"*. + +**Future hardening (out of scope for v2):** runtime telemetry on gate firing — a `gate-fire-log.js` helper the skill calls before each `AskUserQuestion`, with a validator that asserts the expected pattern post-run. That would let us detect runtime non-firing empirically instead of just structurally. + +--- + +## 4. Marker syntax (proposed) + +Every gate gets a structural marker in SKILL.md. The marker has two parts: a **machine-readable HTML comment** (lint anchor) and a **human-readable block** (documentation). + +### 4.1 The marker + +```markdown + + +> 🚦 **Gate (plan · skill-name:phase-id):** One-line summary of what the user is approving. +> +> **Trigger:** When this gate fires. +> **Blast radius if skipped:** What goes wrong if a tool bypasses the prompt. +> **Cancel leaves:** Explicit state description — either `nothing` (clean exit) or a specific state. +``` + +`skill-name` is kebab-case; `phase-id` matches the SKILL.md phase number (`6.0`, `5.4c`, `q1b`). + +### 4.2 Pairing rule (replaces v1's "within 10 lines" proximity rule) + +Lint uses the **HTML comment** as the structural anchor, not text proximity. The `AskUserQuestion` block paired with a marker: + +- Must appear **after** the marker (anywhere later in the same phase section). +- Has no maximum line distance — rationale prose can be arbitrarily long between marker and `AskUserQuestion`. +- May be followed by sub-`AskUserQuestion` calls (e.g., follow-up free-text input within the same gate); those don't need their own markers if the catalog entry says "may include follow-up data-gathering prompts". + +A second `AskUserQuestion` in the same phase section that is **not** covered by an existing marker must have its own marker OR an explicit `` comment justifying why. + +### 4.3 `cancel-leaves` field (new in v2) + +Required, normalized vocabulary: + +| Value | Meaning | +|---|---| +| `nothing` | Clean exit. No Dataverse write, no filesystem write, no state change anywhere. | +| `validated-stage-run` | A `deploymentstageruns` row remains on the host in validated-but-not-deployed state. | +| `partial-manifest` | `.solution-manifest.json` written but not all components added to Dataverse. | +| `partial-solution` | Some components added to Dataverse via `AddSolutionComponent` before Cancel. | +| `deferral-marker` | `.alm-deferred` file written (an intentional user-facing artifact). | +| `host-binding` | Dev env's `ProjectHostEnvironmentId` org-db setting changed. | +| `attachment-block-modified` | Env's `blockedattachments` setting modified before Cancel. | +| `cross-host-stamp-moved` | Pattern 15 force-link partially completed. | +| `external-state-pending` | Skill cancelled while external system (PP Pipelines) was in `PendingApproval` — the run remains on the host in that state. | +| `invalid-secret-in-file` | `deployment-settings.json` carries Secret values in invalid formats (e.g. `@KeyVault(...)` short-form). Cancel leaves the file as-is so the user can hand-fix with canonical Key Vault URIs. | + +Custom values are allowed when none of the above fits — lint accepts any kebab-case slug but flags duplicate slugs across the catalog for de-duplication. + +### 4.4 Example — `deploy-pipeline` Phase 6.0 + +```markdown + + +> 🚦 **Gate (final · deploy-pipeline:6.0):** Final consent before DeployPackageAsync. +> +> **Trigger:** Validation passed (Phase 5); no completeness drift outstanding; no env-var override prompts outstanding. About to fire `DeployPackageAsync` or the `pac pipeline deploy` fallback. +> **Blast radius if skipped:** Wrong-stage deploy. Non-transactional — partial failure leaves whatever already imported on the target. +> **Cancel leaves:** Validated stage run on host (no `docs/alm/last-deploy.json` written). User can retry by re-invoking `deploy-pipeline`. + +[arbitrarily long rationale prose explaining why this gate exists, what alternatives were considered, etc.] + +Use `AskUserQuestion`: + +> "Ready to deploy `{ARTIFACT_SOLUTION_NAME}` (v`{newVersion}`) to **`{SELECTED_STAGE.name}`** (`{targetEnvUrl}`)?" +> +> Options: +> 1. Deploy now (Recommended) +> 2. Cancel +``` + +### 4.5 Why an emoji in the human block? + +`🚦` (traffic-light) is high-contrast and unusual. Verified: it appears nowhere else in any SKILL.md or reference doc on the current branch, so the grep-safety claim holds today. Plain-text fallback if emoji is undesirable: `[GATE]`. Note that lint anchors on the HTML comment, not the emoji — the emoji is purely for human readability. + +--- + +## 5. Lint rules (proposed) + +Add to `scripts/lint-skills-alm.js`: + +### `GATE-must-have-marker` +Every `AskUserQuestion` block in an ALM SKILL.md must be preceded (within the same phase section) by either: +- A paired `` comment, **or** +- An explicit `` comment justifying why. + +Pairing is established by section boundary (`### Phase`), not line proximity. Multiple `AskUserQuestion` calls in the same phase may share one marker only if the catalog entry explicitly documents the sub-prompts. + +Waivable via ``. Tracked in `.almlintignore` for known exceptions. + +### `GATE-id-must-be-unique` +The `gate-id` slug must be unique across all SKILL.md files in the plugin. + +### `GATE-must-be-in-catalog` +Every `gate-id` in a SKILL.md must appear in §6 of this catalog. Catches drift when a skill adds a gate without documenting it. + +Strict for ALM skills (hard-fail). Warn-only for non-ALM skills until the catalog is extended to cover them (§10). + +### `GATE-intent-must-call-helper` +A marker tagged `category=intent` must be in a SKILL.md section that invokes a known helper script (one of: `check-alm-plan.js`, `verify-alm-prerequisites.js`, `check-activation-status.js`). Prevents `intent` from being abused as a generic "first prompt" label. + +### `GATE-cancel-leaves-known-vocab` +The `cancel-leaves=` value must be one of the §4.3 vocabulary entries or a kebab-case slug. Lint flags duplicate slugs across the catalog for de-duplication. + +--- + +## 6. The ALM-skill catalog + +Each section lists every `AskUserQuestion` in that skill. Catalog rows are marked as one of: + +- **`gate`** — meets the §2 definition; gets a marker and a lint check. +- **`not-a-gate`** — informational sub-prompt or data-gathering; gets a `` comment. + +> **Phase numbers reference the SKILL.md as of branch `users/nityagi/EnvVariableChanges`.** Phase IDs may need re-anchoring if SKILL.md is restructured. + +--- + +### 6.1 `plan-alm` (19 calls; orchestrator) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `plan-alm:1.deferral` | gate | progress | 1 | `.alm-deferred` marker present — *"Continue with deferral / remove and proceed / cancel"* | `deferral-marker` | +| `plan-alm:1.completeness` | gate | progress | 1 | Completeness check found gaps — *"Sync first / plan with gaps / cancel"* | nothing | +| `plan-alm:2.q1-existing` | gate | plan | 2 (Q1) | `SOLUTION_DONE=true` — *"Use existing solution **{name}**?"* | nothing | +| `plan-alm:2.q1-fresh` | gate | plan | 2 (Q1) | `SOLUTION_DONE=false` — *"Include solution setup in plan?"* | nothing | +| `plan-alm:2.q1b-split` | gate | plan | 2 (Q1b) | `RECOMMEND_SPLIT=true` — *"Follow recommended {strategy} split?"* | nothing | +| `plan-alm:2.q1b-override` | gate | consent | 2 (Q1b) | User picked "keep single" — *"Confirm override + free-text reason"* | nothing | +| `plan-alm:2.q2-strategy` | gate | plan | 2 (Q2) | *"PP Pipelines / Manual export-import / Already have pipeline / Help me decide"* | nothing | +| `plan-alm:2.q3-stages` | gate | plan | 2 (Q3 PP) | *"How many deployment stages?"* | nothing | +| `plan-alm:2.q4-stage-env` | gate | plan | 2 (Q4 PP per stage) | *"Target env URL for stage {N}?"* | nothing | +| `plan-alm:2.q5-approval` | gate | plan | 2 (Q5 PP) | *"Approvals: required each stage / staging auto + prod required / no gates"* | nothing | +| `plan-alm:2.q3-manual` | gate | plan | 2 (Q3 Manual) | *"How many target envs?"* | nothing | +| `plan-alm:2.q4-manual-target` | gate | plan | 2 (Q4 Manual per stage) | *"URL for target env {N}?"* | nothing | +| `plan-alm:2.q5-manual-type` | gate | plan | 2 (Q5 Manual) | *"Export managed or unmanaged?"* | nothing | +| `plan-alm:2.q6-manual-checkpoint` | gate | plan | 2 (Q6 Manual) | *"Pause between export and import?"* | nothing | +| `plan-alm:4.approve` | gate | plan | 4 | *"Approve and execute / save for later / change something"* | nothing | +| `plan-alm:4.approver-fallback` | not-a-gate | — | 4 | Free-text "approver name" — pure data-gathering | — | +| `plan-alm:7.manual-checkpoint` | gate | progress | 7 (Manual path) | `MANUAL_CHECKPOINT=true` — *"Export done; proceed to import?"* | partial-manifest | +| `plan-alm:7.deploy-failure` | gate | plan | 7 (Step A.1) | deploy-pipeline halted before completing — *"Retry / Skip stage / Exit"*. Fires per failed stage. | nothing | +| `plan-alm:7.activate-step-b` | gate | plan | 7 (Step B) | Post-deploy activation prompt per stage — *"Activate now / skip"* | nothing | + +--- + +### 6.2 `setup-solution` (13 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `setup-solution:0.no-plan` | gate | intent | 0 | `check-alm-plan.js` returned `exists:false` — *"Run plan-alm? / Continue without / Cancel"* | nothing | +| `setup-solution:0.stale-plan` | gate | intent | 0 | `check-alm-plan.js` returned `stale:true` — *"Refresh plan? / Continue / Cancel"* | nothing | +| `setup-solution:1.preloaded` | gate | plan | 1 | `docs/alm/alm-plan-context.json` present — *"Use pre-loaded choices? / Re-discover"* | nothing | +| `setup-solution:1.stale-manifest` | gate | consent | 1 | Manifest references a solution not in env — *"Start fresh (back up) / Abort"* | nothing | +| `setup-solution:2.publisher-prefix` | gate | consent | 2 | Publisher prefix selection — *"This is PERMANENT — confirm"* | nothing | +| `setup-solution:5.4a.promote` | gate | plan | 5.4A | `multiSelect` over auth settings — *"Which to promote to env vars?"* | nothing | +| `setup-solution:5.4c.credentials` | gate | consent | 5.4C.2 | Bulk credential handling — *"Secret env var / String env var / Skip per credential"* | nothing | +| `setup-solution:5.4b.orphan-envvars` | gate | plan | 5.4b | `DEFAULT-ONLY` env vars found — *"Which to adopt?"* (multiSelect) | nothing | +| `setup-solution:5.4c.orphan-ppcs` | gate | plan | 5.4c | Orphan ppcs found (incl. siteLanguages) — *"Which to adopt?"* (multiSelect) | nothing | +| `setup-solution:5.5.manifest-confirm` | gate | plan | 5.5 | Manifest assembly + final confirmation. Covers sub-prompts: tables multi-select, flows multi-select, bots multi-select, and the closing *"Proceed / change something"* gate. Single marker covers all four because the lint regex matches the closing prompt; the multi-select sub-prompts share the same gate semantics. | partial-manifest | +| `setup-solution:7.next-step` | gate | plan | 7 | *"How to deploy: pipeline / manual / later"* | nothing | +| `setup-solution:1.no-config` | not-a-gate | — | 1 | Free-text "site name" if `powerpages.config.json` missing — data-gathering | — | +| `setup-solution:1.no-website-record` | not-a-gate | — | 1 | Free-text "website record ID" fallback — data-gathering | — | + +--- + +### 6.3 `setup-pipeline` (11 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `setup-pipeline:0.no-plan` | gate | intent | 0 | `check-alm-plan.js` returned `exists:false` — *"Run plan-alm? / Continue / Cancel"* | nothing | +| `setup-pipeline:0.stale-plan` | gate | intent | 0 | `check-alm-plan.js` returned `stale:true` — *"Refresh plan? / Continue / Cancel"* | nothing | +| `setup-pipeline:1.existing-pipeline` | gate | plan | 1 | `docs/alm/last-pipeline.json` found — *"Overwrite / Review first / Cancel"* | nothing | +| `setup-pipeline:2.platform` | gate | plan | 2 | *"PP Pipelines / GitHub (coming soon) / ADO (coming soon)"* | nothing | +| `setup-pipeline:3.config` | gate | plan | 3 | Auto-detected pipeline config — *"Confirm / correct"* | nothing | +| `setup-pipeline:4.3.name-conflict` | gate | plan | 4.3 | Existing pipeline with same name — *"Use existing / different name"* | nothing | +| `setup-pipeline:4.4.blocked-attachments` | gate | consent | 4.4 | `.js` blocked on source or target — *"Remove block / skip"* | `attachment-block-modified` | +| `setup-pipeline:5a.pattern-15` | gate | consent | 5a | Env stamped to different host — *"Run force-link (DESTRUCTIVE) / cancel"* | nothing | +| `setup-pipeline:6b.v2-migration` | gate | plan | 6b | v2 manifest detected on re-run — *"Migrate to v3 / keep legacy"* | nothing | +| `setup-pipeline:coming-soon.exit` | gate | plan | (coming-soon path) | GitHub/ADO selected — *"Switch to PP Pipelines / Exit"* | nothing | +| `setup-pipeline:1.host-fallback` | not-a-gate | — | 1 | Free-text host URL if discovery returns empty — data-gathering | — | + +--- + +### 6.4 `deploy-pipeline` (18 gates / 3 sub-prompts; 21 calls total) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `deploy-pipeline:0.no-plan` | gate | intent | 0 | `check-alm-plan.js` `exists:false` — *"Run plan-alm? / Continue / Cancel"* | nothing | +| `deploy-pipeline:0.stale-plan` | gate | intent | 0 | `check-alm-plan.js` `stale:true` — *"Refresh / Continue / Cancel"* | nothing | +| `deploy-pipeline:2.stage` | gate | plan | 2 | *"Target stage?"* (Staging / Prod / etc.) | nothing | +| `deploy-pipeline:2.5.blocked-attachments` | gate | consent | 2.5 | Pre-flight detected `.js` on `blockedattachments` — *"Unblock / skip"* | `attachment-block-modified` | +| `deploy-pipeline:3.5.completeness` | gate | progress | 3.5 | Solution missing components vs. live site — *"Sync now / deploy anyway / cancel"* | nothing | +| `deploy-pipeline:3.5.post-sync` | gate | progress | 3.5 | Post-sync re-confirm — *"New version + adopted components — proceed?"* | nothing | +| `deploy-pipeline:3.6.batch-pending-approval` | gate | pause | 3.6 | `MULTI_RUN_MODE` parallel-validation batch — N of M solutions hit `stagerunstatus=200000005` — *"Approve all in PPAC, then re-poll / Cancel"* (fires once per batch, not per pending solution) | `external-state-pending` | +| `deploy-pipeline:3.6.batch-validation-failed` | gate | plan | 3.6 | `MULTI_RUN_MODE` parallel-validation batch — one or more solutions failed or timed out — *"Abort (Recommended) / Deploy succeeded subset only (advanced) / Cancel"* | `validated-stage-run` | +| `deploy-pipeline:4.pending-approval` | gate | pause | 4 | `stagerunstatus=200000005` during validation (single-solution / legacy v2 only — `MULTI_RUN_MODE` handles approval via `3.6.batch-pending-approval` instead) — *"Approved in PPAC? Yes / Cancel"* | `external-state-pending` | +| `deploy-pipeline:5.env-vars` | gate | plan | 5 | Unconfigured env vars per stage — *"Enter values"* | nothing | +| `deploy-pipeline:6.0.final-consent` | gate | final | 6.0 | About to fire `DeployPackageAsync` — *"Deploy now / Cancel"* | `validated-stage-run` | +| `deploy-pipeline:6.pending-approval` | gate | pause | 6 | `stagerunstatus=200000005` mid-deploy — *"Approved? Yes / Cancel"* | `external-state-pending` | +| `deploy-pipeline:7.6.2.blocked-attachments` | gate | consent | 7.6.2 | Reactive `AttachmentBlocked` — *"Modify `blockedattachments`? Yes / No"* | `attachment-block-modified` | +| `deploy-pipeline:7.6.3.retry-exit` | gate | plan | 7.6.3 | Failed deploy, no known pattern matched — *"Retry / Exit"* | `validated-stage-run` | +| `deploy-pipeline:7.6.4.strip-secret-values` | gate | consent | 7.6.4 | Reactive Secret-reference validation failure — *"Strip invalid Secret values from `deployment-settings.json` and retry? Yes / No"* | `invalid-secret-in-file` | +| `deploy-pipeline:7.7.activate` | gate | plan | 7.7 | Site deployed, not yet activated — *"Activate now / later"* | nothing | +| `deploy-pipeline:7.cloud-flow-register` | gate | plan | 7 (cloud-flow path) | Cloud flows in solution — *"Registered in target? Yes / Later"* (informational continue) | nothing | +| `deploy-pipeline:6.1.pac-fallback-consent` | gate | final | 6.1 | `VALIDATE_PACKAGE_UNAVAILABLE=true` path uses `pac pipeline deploy` instead of `DeployPackageAsync` — same shape as `6.0` | `validated-stage-run` | + +(Three additional `AskUserQuestion` calls in this skill are sub-prompts inside the gates above — env-var value entry per variable inside `5.env-vars`, validation `Approved? Yes / No` follow-ups inside `4.pending-approval` and `6.pending-approval`. They share the parent gate's marker.) + +--- + +### 6.5 `export-solution` (8 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `export-solution:0.no-plan` | gate | intent | 0 | `check-alm-plan.js` `exists:false` — *"Run plan-alm? / Continue / Cancel"* | nothing | +| `export-solution:0.stale-plan` | gate | intent | 0 | `check-alm-plan.js` `stale:true` — *"Refresh / Continue / Cancel"* | nothing | +| `export-solution:2.identify` | gate | plan | 2 | Solution not auto-found — *"Pick / paste unique name"* | nothing | +| `export-solution:2.5.completeness` | gate | progress | 2.5 | Completeness gap — *"Sync now / export anyway / cancel"* | nothing | +| `export-solution:2.5.post-sync` | gate | progress | 2.5 | Post-sync re-confirm — *"New version — proceed?"* | nothing | +| `export-solution:3.export-type` | gate | consent | 3 | *"Managed (for staging/prod) / Unmanaged (for dev-to-dev)"* | nothing | +| `export-solution:3.overwrite` | gate | plan | 3 | Existing zip at target path — *"Overwrite / pick new name / cancel"* | nothing | +| `export-solution:2.unique-name` | not-a-gate | — | 2 | Free-text fallback for solution unique name — data-gathering | — | + +--- + +### 6.6 `import-solution` (11 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `import-solution:0.no-plan` | gate | intent | 0 | `check-alm-plan.js` `exists:false` — *"Run plan-alm? / Continue / Cancel"* | nothing | +| `import-solution:0.stale-plan` | gate | intent | 0 | `check-alm-plan.js` `stale:true` — *"Refresh / Continue / Cancel"* | nothing | +| `import-solution:2.multiple-zips` | gate | plan | 2 | More than one valid zip found — *"Choose"* | nothing | +| `import-solution:3.0.version-skew` | gate | consent | 3.0 | Zip version `≤` installed target version — *"Re-export with bump / Import anyway / Cancel"* | nothing | +| `import-solution:3.config` | gate | plan | 3 | Import config — *"Staged dependency check / direct / overwrite options"* | nothing | +| `import-solution:5b.blocked-attachments` | gate | consent | 5b.3 | `AttachmentBlocked` during import — *"Modify `blockedattachments` and retry? Yes / Skip"* | `attachment-block-modified` | +| `import-solution:6b.env-vars` | gate | plan | 6b | Env vars need per-stage values — *"Enter values"* | nothing | +| `import-solution:6c.cloud-flow-register` | gate | plan | 6c | Cloud flows in imported solution — *"Registered? Yes / Later"* | nothing | +| `import-solution:6d.activate` | gate | plan | 6d | Site present but not activated — *"Activate now / later"* | nothing | +| `import-solution:2.confirm-target` | not-a-gate | — | 2 | Display warning, no choice needed (single-option ack) | — | +| `import-solution:2.zip-path` | not-a-gate | — | 2 | Free-text fallback for zip path — data-gathering | — | + +--- + +### 6.7 `configure-env-variables` (5 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `configure-env-variables:0.no-plan` | gate | intent | 0 | `check-alm-plan.js` `exists:false` — *"Run plan-alm? / Continue / Cancel"* | nothing | +| `configure-env-variables:0.stale-plan` | gate | intent | 0 | `check-alm-plan.js` `stale:true` — *"Refresh / Continue / Cancel"* | nothing | +| `configure-env-variables:2.selection` | gate | plan | 2 | Settings classified — *"Which to promote? Per-stage values per setting"* | nothing | +| `configure-env-variables:6.confirm-matrix` | gate | plan | 6 | `deployment-settings.json` assembled — *"Confirm matrix before write"* | nothing | +| `configure-env-variables:6.1.invalid-secret-values` | gate | consent | 6.1 | Pre-write validation found Secret refs in invalid formats — hard-stop, *"Fix or abort"* | nothing | + +--- + +### 6.8 `ensure-pipelines-host` (10 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `ensure-pipelines-host:1.4.tenant-identity` | gate | consent | 1.4 | Tenant identity echo before any provisioning — *"Is this the right tenant?"* | nothing | +| `ensure-pipelines-host:3.C.host-type` | gate | plan | 3.C | `NoHost` status — *"Platform / Custom / PPAC / Manual strategy / Cancel"* | nothing | +| `ensure-pipelines-host:3.C.env-pick` | gate | plan | 3.C (sub-option a) | Eligible env list — *"Pick env to install Pipelines on"* | nothing | +| `ensure-pipelines-host:4.sandbox-confirm` | gate | consent | 4 (Sandbox) | Picked env has `environmentSku=Sandbox` — *"Sandbox limits — proceed?"* | nothing | +| `ensure-pipelines-host:4.0.pre-call` | gate | consent | 4.0 | PE `getOrCreate` about to fire — *"Echoed API body — proceed?"* | nothing | +| `ensure-pipelines-host:4.A.pre-call` | gate | consent | 4.A | Custom Host create about to fire — *"Echoed API body — proceed?"* | nothing | +| `ensure-pipelines-host:4.A.sku-fallback` | gate | plan | 4.A (on 409) | Capacity error — *"Try {nextSku} / Cancel"* | nothing | +| `ensure-pipelines-host:4.C.ppac-done` | gate | progress | 4.C | Manual PPAC fallback — *"Done in PPAC? / Cancel"* | `host-binding` | +| `ensure-pipelines-host:4.B.guid-confirm` | not-a-gate | — | 4.B | Confirm GUID identity when uncertain — data-gathering | — | +| `ensure-pipelines-host:4.B.admin-check` | not-a-gate | — | 4.B | Single confirm of admin role — informational | — | + +(`4.B.guid-confirm` is conditional and only fires when the BAP GUID is ambiguous — a typical run sees ~9 prompts. The header count reflects total catalog rows, not per-run prompt count.) + +--- + +### 6.9 `force-link-environment` (5 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `force-link-environment:2.host-url` | gate | plan | 2 | Host URL not resolved from markers — *"Pick host"* | nothing | +| `force-link-environment:2.dev-env` | gate | plan | 2 | Dev env BAP GUID not resolved — *"Pick / paste"* | nothing | +| `force-link-environment:4.destructive` | gate | consent | 4 | Mandatory gate before `ManageEnvironmentStamp` — *"DESTRUCTIVE: confirm cross-host stamp move"* | nothing | +| `force-link-environment:2.host-fallback` | not-a-gate | — | 2 | Free-text host URL — data-gathering | — | +| `force-link-environment:2.dev-fallback` | not-a-gate | — | 2 | Free-text dev env GUID — data-gathering | — | + +--- + +### 6.10 `activate-site` (4 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `activate-site:2.1.site-name` | not-a-gate | — | 2.1 | Free-text site name fallback — data-gathering | — | +| `activate-site:2.2.subdomain` | gate | plan | 2.2 | Generated subdomain — *"Accept / enter your own"* | nothing | +| `activate-site:2.3.website-record` | not-a-gate | — | 2.3 | Free-text website record ID fallback — data-gathering | — | +| `activate-site:3.confirm` | gate | final | 3 | All activation params assembled — *"Activate {siteName} at {subdomain}?"* | nothing | + +--- + +### 6.11 `test-site` (6 calls) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `test-site:1.4.site-url` | not-a-gate | — | 1.4 | Free-text site URL fallback — data-gathering | — | +| `test-site:3.2.private-gate-login` | gate | pause | 3.2 | Private site gate detected — *"Logged in? / Skip"* | nothing | +| `test-site:3.2.login-retry` | gate | pause | 3.2 | Login not completed after first prompt — *"Retry / Skip"* | nothing | +| `test-site:3.5.public-vs-auth` | gate | plan | 3.5 | Site appears to have auth UI — *"Test as anonymous / sign in"* | nothing | +| `test-site:3.5.login-retry` | gate | pause | 3.5 | Site-auth login not completed — *"Retry / Skip"* | nothing | +| `test-site:5.5.form-submit` | gate | consent | 5.5 | About to submit a form on the live site — *"Submit / skip"* | nothing | + +--- + +### 6.12 `diagnose-deployment` (1 loop-style gate) + +| ID | Kind | Category | Phase | Trigger / question | Cancel leaves | +|---|---|---|---|---|---| +| `diagnose-deployment:6.auto-fix` | gate | consent | 6 | Per-finding: each suggested auto-fix loops through this same prompt template, surfacing the pattern ID and the proposed fix. User answers Yes / No / Skip-all per finding. | varies by fix | + +The single `AskUserQuestion` template fires once per Error finding with `autoFixAvailable: true`. **Resolves the v1 wildcard problem (`diagnose-deployment:6.*`)** by collapsing all per-pattern loops under one gate ID. The prompt's content varies by pattern; the gate identity does not. Pattern IDs themselves are stable: see `references/deployment-error-catalog.md`. + +--- + +## 7. How to add a new gate + +When introducing a gate in an existing or new ALM skill: + +1. **Pick the category** from §3. If it doesn't fit, propose a new one — don't shoehorn. +2. **Pick a gate ID** of the form `skill-name:phase-id` (kebab-case skill name; phase number / step matches the SKILL.md heading). +3. **Add a row to the catalog** (§6 table for the owning skill) with `kind`, `category`, `phase`, trigger, question, `cancel-leaves`. +4. **Add the marker block** in SKILL.md immediately before the (possibly distant) `AskUserQuestion` call. Use both the HTML comment and the human-readable block from §4.1. +5. **If `category=intent`**, ensure the SKILL.md section invokes a helper script (`GATE-intent-must-call-helper` lint rule). +6. **Run** `node scripts/lint-skills-alm.js`. + +When **removing** a gate, also remove its catalog row in the same PR. + +--- + +## 8. Non-ALM skills — explicitly deferred + +Per the v1 review, the catalog was incomplete because it claimed full coverage but only covered ~30% of `AskUserQuestion` calls. v2 fixes this by **scoping to ALM only**. The 13 non-ALM skills below contain ~70 additional `AskUserQuestion` calls that need to be catalogued in a follow-up: + +| Skill | `AskUserQuestion` count | Status | +|---|---|---| +| `create-site` | 11 | Deferred | +| `deploy-site` | 9 | Deferred | +| `add-server-logic` | 13 | Deferred | +| `add-cloud-flow` | 7 | Deferred | +| `setup-auth` | 5 | Deferred | +| `integrate-webapi` | 6 | Deferred | +| `setup-datamodel` | 3 | Deferred | +| `add-sample-data` | 3 | Deferred | +| `add-seo` | 3 | Deferred | +| `create-webroles` | 3 | Deferred | +| `audit-permissions` | 2 | Deferred | +| `integrate-backend` | (see SKILL.md) | Deferred | +| `report-issue` | 1 | Deferred (cross-plugin, may not need a gate) | + +For non-ALM skills, the lint rules in §5 are **warn-only** until this section is extended. ALM lint rules are **hard-fail** from day one (per §9 decision). + +--- + +## 9. Decisions — pre-resolved with recommendations + +These need explicit confirmation from the reviewer before SKILL.md edits land. Recommendation in **bold**. + +| # | Decision | Recommendation | Rationale | +|---|---|---|---| +| 1 | Canonical term | **"Approval Gate"** | CI/CD heritage; already the most common word in our SKILL.md files; concrete. Drop "review gate" if used informally. | +| 2 | Marker syntax | **HTML comment `` + human `> 🚦 Gate (...)` block** | Comment is the lint anchor; block is for humans. Robust to interleaved prose. | +| 3 | Catalog location | **`plugins/power-pages/references/approval-gates.md`** (this file) + a one-line pointer in `PLUGIN_DEVELOPMENT_GUIDE.md` | Sits with other shared references; cross-skill scope is obvious from the path. | +| 4 | Lint rollout strictness | **ALM: hard-fail. Non-ALM: warn-only until §8 catalog extends.** | ALM is fully catalogued; non-ALM is the follow-up. Hard-fail on ALM forces drift to be caught at PR time. | +| 5 | Emoji vs plain text | **Keep `🚦` in the human block; lint anchors on the HTML comment regardless** | Emoji is for humans; tooling doesn't depend on it. | +| 6 | Wildcard gate IDs (e.g. `diagnose-deployment:6.*`) | **Disallowed. Enumerate per pattern.** | Per-pattern markers enforce that each catalog-listed deployment-error pattern has matching prompt logic. | + +--- + +## 10. Landing plan + +The reviewer's recommendation — **land §1–§5 + §7–§9 as documentation now; do the SKILL.md sweep + lint rule as a follow-up PR** — is the right shape. Concretely: + +**This PR (proposed):** +- Land this `approval-gates.md` v2 file. +- Add a one-line pointer in `PLUGIN_DEVELOPMENT_GUIDE.md` (under the Three-Point Approval Pattern section). +- No SKILL.md edits. +- No new lint rule yet. + +**Follow-up PR (after §9 decisions confirmed):** +- For each ALM SKILL.md, add the `` HTML comment + human `> 🚦 Gate (...)` block above every gate listed in §6. +- Mark every "not-a-gate" row with ``. +- Add the 5 lint rules to `scripts/lint-skills-alm.js` with hard-fail for ALM, warn-only for non-ALM. +- Update `references/deployment-error-catalog.md` to cross-reference the per-pattern gate IDs from §6.12. + +**Follow-up #2 (non-ALM extension):** +- Sweep the 13 non-ALM skills, populate §8 with full catalog rows, switch their lint mode from warn to hard-fail. + +--- + +## 11. Open questions remaining + +These are honest unresolved questions — not necessary to answer before v2 lands, but flagged for future tightening: + +- **Does `intent` need a sub-category for plan-alm itself?** plan-alm is the orchestrator; it doesn't have a Phase 0 ALM-plan gate (because it *is* the plan). The closest analogue is `plan-alm:1.deferral` (handle `.alm-deferred` marker) and `plan-alm:1.completeness` (completeness check). Both are tagged `progress` in §6.1 — defensible but worth a second look. +- **Should `pause` gates be allowed to auto-resume?** Currently the lint rule would flag any tooling that auto-responds. But if PP Pipelines exposes a polling endpoint that detects approval state, a deterministic auto-resume becomes possible. Worth a future rule extension. +- **Telemetry on gate cancellation.** A gate that's cancelled 80% of the time is asking the wrong question. Out of scope for v2; worth instrumenting once §5 lint lands. +- **Multi-prompt gates.** Some entries in §6 cover multiple `AskUserQuestion` calls under one marker (e.g., `setup-solution:5.5*` is one logical gate but renders three multiSelect prompts). The lint rule says one marker can cover multiple calls if the catalog row documents it. Worth a more precise rule once we see drift. diff --git a/plugins/power-pages/references/cicd-pipeline-patterns.md b/plugins/power-pages/references/cicd-pipeline-patterns.md new file mode 100644 index 000000000..8562857d7 --- /dev/null +++ b/plugins/power-pages/references/cicd-pipeline-patterns.md @@ -0,0 +1,981 @@ +# CI/CD Pipeline Patterns + +Reference patterns for generating CI/CD pipelines for Power Pages deployments. Used by the `setup-pipeline` skill. + +## Implementation Status + +| Platform | Skill Status | Reference Patterns | +|---|---|---| +| Power Platform Pipelines | ✅ Fully implemented | Section 4 below | +| Azure DevOps | 🚧 Coming soon | Section 2 below | +| GitHub Actions | 🚧 Coming soon | Section 3 below | + +> **Full ADO/GitHub implementation spec:** `C:\Users\nityagi\OneDrive - Microsoft\Design Documents\Plans\ALM skills for plugin\ado-cicd-skills-guide.md` + +--- + +## Service Principal Authentication (PAC CLI) + +Both ADO and GitHub Actions pipelines authenticate PAC CLI using a service principal (app registration). + +### Prerequisites (manual steps — cannot be automated) +1. Create an app registration in Entra ID (Azure AD) +2. Add the app as a **System Administrator** or **Power Pages Site Owner** in each target environment (Power Platform Admin Center → Environments → Settings → Users → App Users) +3. Store credentials as secrets in ADO or GitHub + +### PAC CLI Auth Command + +```bash +pac auth create \ + --applicationId "$APP_ID" \ + --clientSecret "$CLIENT_SECRET" \ + --tenant "$TENANT_ID" \ + --environment "$ENV_URL" \ + --name "pipeline-auth" +``` + +For certificate-based auth (more secure, recommended for production): +```bash +pac auth create \ + --applicationId "$APP_ID" \ + --certificateThumbprint "$CERT_THUMBPRINT" \ + --tenant "$TENANT_ID" \ + --environment "$ENV_URL" +``` + +### Power Pages Upload Step + +```bash +pac pages upload-code-site --rootPath "." +``` + +This command uploads the compiled site from the `compiledPath` defined in `powerpages.config.json`. Always run `npm run build` before this step. + +--- + +## Azure DevOps Pipeline (azure-pipelines.yml) + +> ⚠️ **Coming Soon** — The `setup-pipeline` GitHub/ADO path is not yet implemented. + +### Full ADO Pipeline Template + +```yaml +# azure-pipelines.yml +# Power Pages CI/CD Pipeline +# Requires pipeline variables: APP_ID, CLIENT_SECRET, TENANT_ID +# Requires environment-specific variables: DEV_ENV_URL, STAGING_ENV_URL, PROD_ENV_URL + +trigger: + branches: + include: + - main + - release/* + +pr: + branches: + include: + - main + +variables: + nodeVersion: '20.x' + # Solution export/import variables (uncomment if using solution-based deployment) + # SOLUTION_NAME: 'ContosoSite' + +stages: + + # ─── Build ──────────────────────────────────────────────────────────────── + - stage: Build + displayName: 'Build' + jobs: + - job: BuildSite + displayName: 'Build Power Pages Site' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: NodeTool@0 + inputs: + versionSpec: '$(nodeVersion)' + displayName: 'Install Node.js' + + - script: npm ci + displayName: 'Install dependencies' + + - script: npm run build + displayName: 'Build site' + + - task: PublishPipelineArtifact@1 + inputs: + targetPath: 'dist' + artifact: 'site-build' + displayName: 'Publish build artifact' + + # ─── Deploy to Dev ───────────────────────────────────────────────────────── + - stage: DeployDev + displayName: 'Deploy to Dev' + dependsOn: Build + condition: succeeded() + jobs: + - deployment: DeployToDev + displayName: 'Deploy to Dev Environment' + environment: 'dev' + pool: + vmImage: 'ubuntu-latest' + strategy: + runOnce: + deploy: + steps: + - task: DownloadPipelineArtifact@2 + inputs: + artifact: 'site-build' + path: 'dist' + + - script: | + dotnet tool install --global Microsoft.PowerApps.CLI.Tool 2>/dev/null || true + pac auth create \ + --applicationId "$(APP_ID)" \ + --clientSecret "$(CLIENT_SECRET)" \ + --tenant "$(TENANT_ID)" \ + --environment "$(DEV_ENV_URL)" + pac pages upload-code-site --rootPath "." + displayName: 'Deploy to Dev' + env: + APP_ID: $(APP_ID) + CLIENT_SECRET: $(CLIENT_SECRET) + TENANT_ID: $(TENANT_ID) + DEV_ENV_URL: $(DEV_ENV_URL) + + # ─── Deploy to Staging ───────────────────────────────────────────────────── + - stage: DeployStaging + displayName: 'Deploy to Staging' + dependsOn: DeployDev + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: DeployToStaging + displayName: 'Deploy to Staging Environment' + environment: 'staging' + # Add an approval check in ADO: Environments → staging → Approvals and checks + pool: + vmImage: 'ubuntu-latest' + strategy: + runOnce: + deploy: + steps: + - task: DownloadPipelineArtifact@2 + inputs: + artifact: 'site-build' + path: 'dist' + + - script: | + dotnet tool install --global Microsoft.PowerApps.CLI.Tool 2>/dev/null || true + pac auth create \ + --applicationId "$(APP_ID)" \ + --clientSecret "$(CLIENT_SECRET)" \ + --tenant "$(TENANT_ID)" \ + --environment "$(STAGING_ENV_URL)" + pac pages upload-code-site --rootPath "." + displayName: 'Deploy to Staging' + env: + APP_ID: $(APP_ID) + CLIENT_SECRET: $(CLIENT_SECRET) + TENANT_ID: $(TENANT_ID) + STAGING_ENV_URL: $(STAGING_ENV_URL) + + # ─── Deploy to Production ────────────────────────────────────────────────── + - stage: DeployProd + displayName: 'Deploy to Production' + dependsOn: DeployStaging + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: DeployToProduction + displayName: 'Deploy to Production Environment' + environment: 'production' + # Add an approval check in ADO: Environments → production → Approvals and checks + pool: + vmImage: 'ubuntu-latest' + strategy: + runOnce: + deploy: + steps: + - task: DownloadPipelineArtifact@2 + inputs: + artifact: 'site-build' + path: 'dist' + + - script: | + dotnet tool install --global Microsoft.PowerApps.CLI.Tool 2>/dev/null || true + pac auth create \ + --applicationId "$(APP_ID)" \ + --clientSecret "$(CLIENT_SECRET)" \ + --tenant "$(TENANT_ID)" \ + --environment "$(PROD_ENV_URL)" + pac pages upload-code-site --rootPath "." + displayName: 'Deploy to Production' + env: + APP_ID: $(APP_ID) + CLIENT_SECRET: $(CLIENT_SECRET) + TENANT_ID: $(TENANT_ID) + PROD_ENV_URL: $(PROD_ENV_URL) + +# ─── Solution Export/Import (uncomment to enable solution-based deployment) ── +# Add these stages between Build and DeployDev if using Dataverse solutions: +# +# - stage: ExportSolution +# dependsOn: Build +# jobs: +# - job: ExportSolution +# steps: +# - script: | +# pac auth create --applicationId "$(APP_ID)" --clientSecret "$(CLIENT_SECRET)" --tenant "$(TENANT_ID)" --environment "$(DEV_ENV_URL)" +# pac solution export --name "$(SOLUTION_NAME)" --path ./solutions --async +# displayName: 'Export solution from Dev' +# - task: PublishPipelineArtifact@1 +# inputs: +# targetPath: 'solutions' +# artifact: 'solution' +# +# - stage: ImportSolution +# dependsOn: ExportSolution +# jobs: +# - job: ImportSolution +# steps: +# - task: DownloadPipelineArtifact@2 +# inputs: +# artifact: 'solution' +# path: 'solutions' +# - script: | +# pac auth create --applicationId "$(APP_ID)" --clientSecret "$(CLIENT_SECRET)" --tenant "$(TENANT_ID)" --environment "$(STAGING_ENV_URL)" +# pac solution import --path ./solutions/$(SOLUTION_NAME).zip --async +# displayName: 'Import solution to Staging' +``` + +### ADO Pipeline Variables Setup + +Set these as secret pipeline variables in ADO (Pipelines → Library → Variable Groups, or per-pipeline Variables): + +| Variable | Description | Secret? | +|---|---|---| +| `APP_ID` | Service principal Application (client) ID | Yes | +| `CLIENT_SECRET` | Service principal client secret | Yes | +| `TENANT_ID` | Azure AD tenant ID | No | +| `DEV_ENV_URL` | Dev environment URL (e.g., `https://contoso-dev.crm.dynamics.com`) | No | +| `STAGING_ENV_URL` | Staging environment URL | No | +| `PROD_ENV_URL` | Production environment URL | No | + +### ADO Manual Steps Required + +> **IMPORTANT**: These steps cannot be automated from Claude and must be done manually in the ADO portal: + +1. **Create service connection** (optional but recommended): ADO Project → Project Settings → Service Connections → New service connection → Azure Resource Manager +2. **Add approval gates**: ADO → Pipelines → Environments → `staging` → Approvals and checks → Add approval → specify approvers +3. **Add approval gates for production**: same for `production` environment +4. **Grant pipeline permission to agent pool**: ADO → Project Settings → Agent Pools → select pool → Security → grant pipeline access +5. **Grant pipeline permission to environments**: ADO → Pipelines → Environments → select environment → Security → grant pipeline access + +--- + +## GitHub Actions Workflow (.github/workflows/deploy.yml) + +> ⚠️ **Coming Soon** — The `setup-pipeline` GitHub/ADO path is not yet implemented. + +### Full GitHub Actions Template + +```yaml +# .github/workflows/deploy.yml +# Power Pages CI/CD Workflow +# Requires repository secrets: APP_ID, CLIENT_SECRET, TENANT_ID +# Requires environment secrets: DEV_ENV_URL, STAGING_ENV_URL, PROD_ENV_URL + +name: Deploy Power Pages Site + +on: + push: + branches: [main, 'release/**'] + pull_request: + branches: [main] + workflow_dispatch: + +env: + NODE_VERSION: '20.x' + # SOLUTION_NAME: 'ContosoSite' # Uncomment if using solution-based deployment + +jobs: + + # ─── Build ──────────────────────────────────────────────────────────────── + build: + name: Build Site + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build site + run: npm run build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: site-build + path: dist/ + retention-days: 5 + + # ─── Deploy to Dev ───────────────────────────────────────────────────────── + deploy-dev: + name: Deploy to Dev + runs-on: ubuntu-latest + needs: build + environment: dev + if: github.event_name != 'pull_request' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: site-build + path: dist/ + + - name: Install PAC CLI + run: dotnet tool install --global Microsoft.PowerApps.CLI.Tool + + - name: Authenticate PAC CLI + run: | + pac auth create \ + --applicationId "${{ secrets.APP_ID }}" \ + --clientSecret "${{ secrets.CLIENT_SECRET }}" \ + --tenant "${{ secrets.TENANT_ID }}" \ + --environment "${{ vars.DEV_ENV_URL }}" + + - name: Deploy to Dev + run: pac pages upload-code-site --rootPath "." + + # ─── Deploy to Staging ───────────────────────────────────────────────────── + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: deploy-dev + environment: staging + # GitHub environment protection rules handle approvals + # Configure at: Settings → Environments → staging → Protection rules + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: site-build + path: dist/ + + - name: Install PAC CLI + run: dotnet tool install --global Microsoft.PowerApps.CLI.Tool + + - name: Authenticate PAC CLI + run: | + pac auth create \ + --applicationId "${{ secrets.APP_ID }}" \ + --clientSecret "${{ secrets.CLIENT_SECRET }}" \ + --tenant "${{ secrets.TENANT_ID }}" \ + --environment "${{ vars.STAGING_ENV_URL }}" + + - name: Deploy to Staging + run: pac pages upload-code-site --rootPath "." + + # ─── Deploy to Production ────────────────────────────────────────────────── + deploy-prod: + name: Deploy to Production + runs-on: ubuntu-latest + needs: deploy-staging + environment: production + # GitHub environment protection rules handle approvals + # Configure at: Settings → Environments → production → Protection rules + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: site-build + path: dist/ + + - name: Install PAC CLI + run: dotnet tool install --global Microsoft.PowerApps.CLI.Tool + + - name: Authenticate PAC CLI + run: | + pac auth create \ + --applicationId "${{ secrets.APP_ID }}" \ + --clientSecret "${{ secrets.CLIENT_SECRET }}" \ + --tenant "${{ secrets.TENANT_ID }}" \ + --environment "${{ vars.PROD_ENV_URL }}" + + - name: Deploy to Production + run: pac pages upload-code-site --rootPath "." + +# ─── Solution Export/Import (uncomment to enable solution-based deployment) ── +# Add these jobs between build and deploy-dev if using Dataverse solutions: +# +# export-solution: +# needs: build +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - run: dotnet tool install --global Microsoft.PowerApps.CLI.Tool +# - run: | +# pac auth create --applicationId "${{ secrets.APP_ID }}" --clientSecret "${{ secrets.CLIENT_SECRET }}" --tenant "${{ secrets.TENANT_ID }}" --environment "${{ vars.DEV_ENV_URL }}" +# mkdir -p solutions +# pac solution export --name "${{ env.SOLUTION_NAME }}" --path ./solutions --async +# - uses: actions/upload-artifact@v4 +# with: +# name: solution +# path: solutions/ +# +# import-solution: +# needs: export-solution +# runs-on: ubuntu-latest +# environment: staging +# steps: +# - uses: actions/download-artifact@v4 +# with: { name: solution, path: solutions/ } +# - run: dotnet tool install --global Microsoft.PowerApps.CLI.Tool +# - run: | +# pac auth create --applicationId "${{ secrets.APP_ID }}" --clientSecret "${{ secrets.CLIENT_SECRET }}" --tenant "${{ secrets.TENANT_ID }}" --environment "${{ vars.STAGING_ENV_URL }}" +# pac solution import --path "./solutions/${{ env.SOLUTION_NAME }}.zip" --async +``` + +### GitHub Actions Secrets & Variables Setup + +**Repository Secrets** (Settings → Secrets and variables → Actions → Secrets): + +| Secret | Description | +|---|---| +| `APP_ID` | Service principal Application (client) ID | +| `CLIENT_SECRET` | Service principal client secret | +| `TENANT_ID` | Azure AD tenant ID | + +**Environment Variables** (Settings → Environments → {env name} → Environment variables): + +| Variable | Dev | Staging | Prod | +|---|---|---|---| +| `DEV_ENV_URL` | `https://contoso-dev.crm.dynamics.com` | — | — | +| `STAGING_ENV_URL` | — | `https://contoso-staging.crm.dynamics.com` | — | +| `PROD_ENV_URL` | — | — | `https://contoso.crm.dynamics.com` | + +### GitHub Actions Manual Steps Required + +> **IMPORTANT**: These steps cannot be automated and must be done in GitHub: + +1. **Create environments**: Settings → Environments → New environment (create `dev`, `staging`, `production`) +2. **Add protection rules for staging**: Settings → Environments → staging → Protection rules → Required reviewers → add approvers +3. **Add protection rules for production**: same for `production` +4. **Add secrets**: Settings → Secrets and variables → Actions → New repository secret (for APP_ID, CLIENT_SECRET, TENANT_ID) +5. **Add environment variables**: Settings → Environments → {env} → Add environment variable (for ENV_URL per environment) + +--- + +## Power Platform Pipelines — API Patterns + +HAR-confirmed patterns for creating and running Power Platform Pipelines via the Dataverse OData API. Used by the `setup-pipeline` (PP Pipelines path) and `deploy-pipeline` skills. + +All API calls target the **host environment** URL — never the source or target environment URLs. Auth token is obtained via `az account get-access-token --resource {hostEnvOrigin} --query accessToken -o tsv`. + +### API Version Matrix + +| Operation | API Version | +|---|---| +| Create/update records, Action calls | `v9.0` | +| List queries, `RetrieveDeploymentPipelineInfo` | `v9.1` | +| `RetrieveSetting` | `v9.2` | + +### Host Environment Discovery + +Call `RetrieveSetting` from the **dev environment** to find the tenant's configured Pipelines host: + +``` +GET {devEnvUrl}/api/data/v9.2/RetrieveSetting(SettingName='DefaultCustomPipelinesHostEnvForTenant') +Authorization: Bearer {devEnvToken} +OData-MaxVersion: 4.0 +OData-Version: 4.0 +Accept: application/json +``` + +Returns `{ "SettingValue": "{BAP-environment-GUID}" }` or empty/null if no default is configured. + +Cross-reference the GUID with `pac env list` output to find the host environment URL: + +```bash +pac env list --output json 2>/dev/null +``` + +Match on `EnvironmentId` field. If no match, probe each environment from `pac env list` with: + +``` +GET {envUrl}/api/data/v9.1/deploymentpipelines?$top=0 +``` + +Environments that return 200 (not 404) have the Pipelines package installed. + +### Get BAP Environment ID + +The `deploymentenvironments` entity requires the **BAP environment ID** (a GUID), not the Dataverse organization ID. Get it from `pac env list` output field `EnvironmentId`, or from `pac env who` output. This is different from the Dataverse `organizationid`. + +### Pipeline Setup — 5-Step Flow + +#### Step 1 — Create Deployment Environment Records + +Create one record per environment (source dev + each target): + +``` +POST {hostUrl}/api/data/v9.0/deploymentenvironments +Content-Type: application/json +Authorization: Bearer {hostEnvToken} + +{ + "name": "{siteName} Development", + "environmentid": "{BAP-environment-GUID}", + "environmenttype": 200000000 +} +``` + +> **`environmenttype` values**: `200000000` = source/development, `200000001` = target. This field is required — omitting it causes a 400 error. + +> **Response**: Most creates (deploymentenvironments, deploymentpipelines, deploymentstages) return **204** — parse the created record ID from the `OData-EntityId` response header. `deploymentstageruns` POST returns **201** (newer host package — ID in JSON body) or **204** (older package — ID in `OData-EntityId` header). Always implement both paths: try body first, fall back to header. + +#### Step 2 — Poll validationstatus + +After creating each `deploymentenvironment`, poll until validation completes: + +``` +GET {hostUrl}/api/data/v9.1/deploymentenvironments({id})?$select=validationstatus +``` + +Poll until `validationstatus = 200000001` AND `statecode = 0` (Active/succeeded). If `statecode = 1` with a non-null `errormessage`: the environment validation failed — report the error and do not continue. + +Poll every 3 seconds, max 20 attempts. + +#### Step 3 — Create Pipeline Record + +``` +POST {hostUrl}/api/data/v9.0/deploymentpipelines +Content-Type: application/json + +{ + "name": "{pipeline name}", + "description": "Power Pages deployment pipeline for {siteName}" +} +``` + +Extract `deploymentpipelineid` from `OData-EntityId` response header. + +#### Step 4 — Associate Source Environment via $ref + +Link the source deployment environment to the pipeline. **Use relative path format — not full URL** (HAR-confirmed): + +``` +POST {hostUrl}/api/data/v9.0/deploymentpipelines({pipelineId})/deploymentpipeline_deploymentenvironment/$ref +Content-Type: application/json + +{ + "@odata.context": "{hostUrl}/api/data/v9.0/$metadata#$ref", + "@odata.id": "deploymentenvironments({sourceDeploymentEnvironmentId})" +} +``` + +> **Note**: Response is **204** (not 200 with entity body as the HAR initially suggested). Treat any 2xx as success. + +> **Note**: `@odata.id` uses a relative path (no leading `/`). Do NOT use the full `https://...` URL — the portal sends the relative form and the API accepts it. + +#### Step 5 — Create Deployment Stages + +Create one stage per target environment, in deployment order: + +``` +POST {hostUrl}/api/data/v9.0/deploymentstages +Content-Type: application/json + +{ + "name": "Deploy to {targetName}", + "deploymentpipelineid@odata.bind": "/deploymentpipelines({pipelineId})", + "targetdeploymentenvironmentid@odata.bind": "/deploymentenvironments({targetDeploymentEnvironmentId})" +} +``` + +> **Note**: The `rank` field does not exist on `deploymentstage`. For multi-stage ordering, use `"previousdeploymentstageid@odata.bind": "/deploymentstages({previousStageId})"` to link stages in a chain (similar to a linked list). + +``` +``` + +Extract `deploymentstagesid` from `OData-EntityId` response header. + +### Deployment Flow — 4-Step Flow + +#### Step 1 — Resolve Pipeline Info + +Before creating a stage run, call `RetrieveDeploymentPipelineInfo` to get the source environment ID and available artifacts: + +``` +GET {hostUrl}/api/data/v9.1/RetrieveDeploymentPipelineInfo(DeploymentPipelineId={pipelineId},SourceEnvironmentId='{BAP_SOURCE_ENV_ID}',ArtifactName='{solutionName}') +Authorization: Bearer {hostEnvToken} +``` + +Where `BAP_SOURCE_ENV_ID` is the BAP GUID of the dev environment (from `pac env list` `EnvironmentId` field, or `pac env who`). + +Returns: `SourceDeploymentEnvironmentId`, `StageRunsDetails[]`, `EnableAIDeploymentNotes`, `EnableRedeployment`, `DeploymentType`. + +Use `SourceDeploymentEnvironmentId` as the `devdeploymentenvironment` binding in the stage run. Use `solutionId` from `.solution-manifest.json` as the artifact solution ID. + +> **Version note**: This function may not exist in older Pipelines package versions (returns 404). Fallback: query the `deploymentpipeline_deploymentenvironment` navigation property to get the source environment ID: +> +> ``` +> GET {hostUrl}/api/data/v9.1/deploymentpipelines({pipelineId})/deploymentpipeline_deploymentenvironment?$select=deploymentenvironmentid,name,environmenttype +> ``` +> +> Filter for `environmenttype = 200000000` to get the source deployment environment record. Use `deploymentenvironmentid` as the `sourceDeploymentEnvironmentId`. + +#### Step 2 — Create Stage Run + Validate + +Create the stage run (note the `$select` on the URL — required to get the ID back): + +``` +POST {hostUrl}/api/data/v9.0/deploymentstageruns?$select=deploymentstagerunid +Content-Type: application/json + +{ + "deploymentstageid@odata.bind": "/deploymentstages({stageId})", + "devdeploymentenvironment@odata.bind": "/deploymentenvironments({sourceDeploymentEnvironmentId})", + "artifactname": "{solutionUniqueName}", + "solutionid": "{solutionId}", + "makerainoteslanguagecode": "en-US" +} +``` + +> **Note**: `deploymentstageid` is the correct lookup binding name (not `stageid`). `devdeploymentenvironment` is the correct navigation property for the source deployment environment (not `artifactid`). `artifactname` is required — provide the solution unique name string. `solutionid` is **required** (not optional). Use the GUID from `RetrieveDeploymentPipelineInfo`. + +Then trigger validation — `ValidatePackageAsync` is a **top-level action** (not bound to the entity): + +``` +POST {hostUrl}/api/data/v9.0/ValidatePackageAsync +Content-Type: application/json +Authorization: Bearer {HOST_TOKEN} + +{"StageRunId": "{STAGE_RUN_ID}"} +``` + +Returns **204** when available. Returns **404** on older Pipelines package versions → fall back to `pac pipeline deploy` (see [pac pipeline deploy CLI (Fallback / Alternative)](#pac-pipeline-deploy-cli-fallback--alternative) below). + +> **Version note**: `ValidatePackageAsync` and `DeployPackageAsync` custom actions may not exist in older Pipelines package versions. If these return 404, use the `pac pipeline deploy` CLI as the deployment mechanism instead. + +Poll until validation completes — use single-entity GET, check `stagerunstatus`: + +``` +GET {hostUrl}/api/data/v9.0/deploymentstageruns({stageRunId})?$select=deploymentstagerunid,stagerunstatus,errormessage,operation,operationdetails,operationstatus,scheduledtime,targetenvironmentid,validationresults,artifactname,deploymentsettingsjson +``` + +`stagerunstatus` values during validation: +- `200000006` = **Validating** — in-progress, keep polling +- `200000007` = **Validation Succeeded** — terminal success, proceed to next step +- `200000003` = **Failed** — terminal failure, stop and display error +- `200000004` = **Canceled** — terminal, stop +- `200000005` = **Pending Approval** — pause and inform user to approve in Power Platform make.powerapps.com portal, then re-poll after user confirms + +`operation` field reference values: +| Value | Label | +|---|---| +| 200000200 | None (not started) | +| 200000201 | Validate | +| 200000202 | Deploy | + +> **Important**: `validationresults` is a **double-encoded JSON string** — call `JSON.parse()` on it twice (or once after `JSON.parse()` of the OData response body) to get the object. The object has shape: `{ ValidationStatus, SolutionValidationResults: [{ SolutionValidationResultType, Message, ErrorCode }], SolutionDetails, MissingDependencies }`. + +Surface any `SolutionValidationResults` entries to the user as warnings. Known error codes: +- `ErrorCode: -2147188672` — managed/unmanaged conflict: "The solution is already installed as unmanaged but this package is managed." The user must uninstall the existing solution from the target environment before retrying. + +**Fetch AI-generated deployment notes** (if `EnableAIDeploymentNotes = true` from `RetrieveDeploymentPipelineInfo`): + +``` +GET {hostUrl}/api/data/v9.0/deploymentstageruns({stageRunId})?$select=aigenerateddeploymentnotes,deploymentstagerunid +``` + +Store the value as `AI_DEPLOY_NOTES`. + +#### Step 3 — Optional: Configure Deployment Settings + +If the solution contains environment variables or connection references that need target-environment values, PATCH the stage run between Validate and Deploy: + +``` +PATCH {hostUrl}/api/data/v9.0/deploymentstageruns({stageRunId}) +Content-Type: application/json + +{ + "deploymentsettingsjson": "{...JSON string with env var overrides and connection ref mappings...}" +} +``` + +The `deploymentsettingsjson` value is a **JSON-serialized string** (not a nested object). Structure: +```json +{ + "EnvironmentVariables": [ + { "SchemaName": "prefix_VarName", "Value": "target-value" } + ], + "ConnectionReferences": [ + { "LogicalName": "prefix_ConnRefName", "ConnectionId": "target-connection-id" } + ] +} +``` + +#### Step 3b — PATCH stage run before deploy (always run) + +Before calling `DeployPackageAsync`, PATCH the stage run with version info and deployment notes: + +``` +PATCH {hostUrl}/api/data/v9.0/deploymentstageruns({stageRunId}) +Content-Type: application/json + +{ + "artifactdevcurrentversion": "{current version in source env — query GET solutions?$filter=uniquename eq '...'&$select=version}", + "artifactversion": "{new version — must be strictly > version already deployed in target stage}", + "deploymentnotes": "{AI_DEPLOY_NOTES if available, otherwise a brief description of what is being deployed}" +} +``` + +Returns HTTP 204. + +> **Version accuracy is critical**: `artifactdevcurrentversion` must match the live `version` field of the solution in the source environment (query it — do not use stale values from `.solution-manifest.json`). `artifactversion` must be strictly greater than the version already in the target stage — check `docs/alm/last-deploy.json` for the last deployed version and increment from there. + +#### Step 4 — Deploy + Poll + +Trigger deployment — `DeployPackageAsync` is a **top-level action** (not bound to the entity): + +``` +POST {hostUrl}/api/data/v9.0/DeployPackageAsync +Content-Type: application/json + +{"StageRunId": "{STAGE_RUN_ID}"} +``` + +Returns HTTP 204. + +Poll `stagerunstatus` until terminal — use filter GET pattern during deployment: + +``` +GET {hostUrl}/api/data/v9.0/deploymentstageruns?$filter=(deploymentstagerunid eq {stageRunId})&$select=_deploymentstageid_value,deploymentstagerunid,stagerunstatus,operation,operationstatus,suboperation,artifactname +``` + +`stagerunstatus` values during deployment: +- `200000010` = **Deploying** — in-progress, keep polling (every 10 seconds, max 120 attempts) +- `200000002` = **Succeeded** — terminal success +- `200000003` = **Failed** — terminal failure +- `200000004` = **Canceled** — terminal +- `200000005` = **Pending Approval** — pause and inform user to approve in Power Platform make.powerapps.com portal, then re-poll after user confirms + +`suboperation` field values during deploy: +| Value | Label | +|---|---| +| 200000100 | None (starting/finishing) | +| 200000105 | Deploying Artifact (actively installing solution) | + +If `stagerunstatus = 200000005` (Pending Approval): pause and inform user to approve in Power Platform make.powerapps.com portal, then re-poll after user confirms. + +### Retry Failed Deployment + +``` +POST {hostEnvUrl}/api/data/v9.1/RetryFailedDeploymentAsync +Content-Type: application/json +Authorization: Bearer {HOST_TOKEN} + +{"StageRunId": "{STAGE_RUN_ID}"} +→ HTTP 204 +``` + +Call this instead of creating a new stage run when retrying a failed deployment. Then resume polling `stagerunstatus` as in the deploy phase. + +### Cancel a Stage Run + +``` +PATCH {hostEnvUrl}/api/data/v9.0/deploymentstageruns({STAGE_RUN_ID}) +Content-Type: application/json +Authorization: Bearer {HOST_TOKEN} + +{"iscanceled": true} +→ HTTP 204 +``` + +### Environment Validation Polling (setup-pipeline) + +Poll every **2 seconds**, max 30 attempts (~1 minute): +- `validationstatus = 200000000` → Pending, keep polling +- `validationstatus = 200000001` → Succeeded ✓ +- `statecode = 1` with non-null `errormessage` → Failed ✗ + +### Scheduled Deployment + +Add `scheduledtime` to the stage run POST body for a scheduled future deployment: +```json +{ + "deploymentstageid@odata.bind": "...", + "devdeploymentenvironment@odata.bind": "...", + "artifactname": "...", + "solutionid": "...", + "scheduledtime": "2026-04-01T10:00:00Z" +} +``` + +### Redeployment (re-deploy an older artifact) + +```json +{ + "deploymentstageid@odata.bind": "...", + "devdeploymentenvironment@odata.bind": "...", + "artifactname": "...", + "artifactid@odata.bind": "/deploymentartifacts({artifactId})", + "isredeployment": true +} +``` + +Fetch prior successful deployments to show as redeployment options: +``` +GET {hostEnvUrl}/api/data/v9.1/deploymentstageruns + ?$filter=((stagerunstatus eq 200000002)) + &$orderby=starttime desc + &$select=artifactname,artifactversion,deploymentstagerunid,_artifactid_value,... +``` + +### Solution Artifact Download + +``` +GET {hostEnvUrl}/api/data/v9.0/deploymentartifacts({artifactId})/artifactfile/$value → managed zip +GET {hostEnvUrl}/api/data/v9.0/deploymentartifacts({artifactId})/artifactfileunmanaged/$value → unmanaged zip +``` + +### Platform Host Provisioning (BAP API) + +The platform host is auto-provisioned on demand via the BAP RP API (not the Dataverse OData API): +``` +POST {BapRpEndpoint}/environments/getOrCreate?api-version=2021-04-01 +Content-Type: application/json + +{ + "properties": { + "environmentSku": "Platform", + "linkedEnvironmentMetadata": { + "templates": ["D365_1stPartyAdminApps"] + } + } +} +``` +Returns 202 with `location` and `retry-after` headers. Poll `location` until `provisioningState` = "Succeeded". +`DefaultCustomPipelinesHostEnvForTenant` defaults to `''` (empty string) when using platform host — treat any falsy/empty value as "platform host in use." + +### Force Link Environment to a New Host (ManageEnvironmentStamp) + +Used to take over an environment's pipelines-host association when creating a `deploymentenvironments` record fails with *"this environment is already associated with another pipelines host"*. HAR-verified against the AppDeploymentConfiguration UI on `supplierportalpipelineshostch.crm17` (2026-05-11). Documented at [`alm/custom-host-pipelines#using-force-link…`](https://learn.microsoft.com/en-us/power-platform/alm/custom-host-pipelines#using-force-link-to-associate-an-environment-with-a-new-host). + +**Endpoint**: +``` +POST {hostEnvUrl}/api/data/v9.0/ManageEnvironmentStamp +Authorization: Bearer +Content-Type: application/json +Accept: application/json +clienthost: Browser +prefer: odata.include-annotations="*" +x-ms-app-name: AppDeploymentConfiguration + +{ "DeploymentEnvironmentId": "{C44399FE-BF4A-F111-BEC6-7CED8D42BEFA}" } +``` + +Note the GUID is wrapped in `{UPPER-CASE-BRACES}` — the only shape observed in production. + +**Success**: 204 No Content. The action is synchronous, but the record's `validationstatus` re-evaluates asynchronously. Poll afterward: + +``` +GET {hostEnvUrl}/api/data/v9.0/deploymentenvironments({deploymentEnvironmentId})?$select=validationstatus,errormessage,name +``` + +until `validationstatus = 200000001` (Succeeded). `200000002` (Failed) means the stamp move was rejected (e.g., previous host has reapply policy); surface `errormessage` verbatim. + +**Failure modes**: +| Status | Cause | Remediation | +|---|---|---| +| 403 | Caller lacks Deployment Pipeline Administrator on the target host | Host admin must grant the role | +| 404 | No `deploymentenvironments` record exists on the target host for this BAP env | Create it first (which will create it in a Failed state with the "already associated" errormessage — that's the trigger for Force Link) | +| Post-poll `validationstatus = Failed` | Previous host still claims the env | Show `errormessage` to the user; may require admin intervention on the previous host | + +**Side effects (documented on Microsoft Learn)**: +- Previous host's `deploymentenvironments` row for this BAP env is delinked; its `validationstatus` is left stale until refreshed in the previous host's UI. +- Makers in the previous host lose access to pipelines they ran through this environment. +- Reversible by performing Force Link again from the previous host. + +**Plugin implementation**: `scripts/lib/force-link-environment.js` wraps the POST + post-validation polling. Surface as auto-fix for Pattern 15 in `deployment-error-catalog.md` via the `/power-pages:force-link-environment` skill. + +### pac pipeline deploy CLI (Fallback / Alternative) + +When `ValidatePackageAsync` / `DeployPackageAsync` are unavailable (older Pipelines package), or when the deployment environment is configured via Power Platform Admin Center, use the PAC CLI: + +```bash +pac pipeline deploy \ + --environment "{devEnvUrl}" \ + --solutionName "{solutionUniqueName}" \ + --stageId "{deploymentstagesid}" \ + --currentVersion "{currentVersion}" \ + --newVersion "{newVersion}" \ + --wait +``` + +**Prerequisites for CLI deployment**: +- The dev environment must have a PP Pipelines host configured (via Power Platform Admin Center or `DefaultCustomPipelinesHostEnvForTenant` tenant setting). Without this, the CLI returns "Resource not found for the segment 'deploymentenvironments'". +- `--currentVersion` and `--newVersion` must be valid semver strings (e.g., `1.0.0.0`, `1.0.0.1`). + +### docs/alm/last-pipeline.json Format + +Written by `setup-pipeline` (PP Pipelines path) after successful pipeline creation: + +```json +{ + "pipelineId": "{deploymentpipelineid}", + "pipelineName": "{pipeline name}", + "hostEnvUrl": "{hostEnvUrl}", + "sourceDeploymentEnvironmentId": "{sourceDeploymentEnvironmentId}", + "sourceEnvironmentUrl": "{devEnvUrl}", + "solutionName": "{solutionUniqueName}", + "createdAt": "{ISO timestamp}", + "stages": [ + { + "stageId": "{deploymentstagesid}", + "name": "Deploy to Staging", + "rank": 1, + "targetDeploymentEnvironmentId": "{targetDeploymentEnvironmentId}", + "targetEnvironmentUrl": "{stagingEnvUrl}" + } + ] +} +``` + +### docs/alm/last-deploy.json Format + +Written by `deploy-pipeline` after each deployment run: + +```json +{ + "pipelineId": "{deploymentpipelineid}", + "stageId": "{deploymentstagesid}", + "stageRunId": "{deploymentstagerunid}", + "stageName": "Deploy to Staging", + "solutionName": "{solutionUniqueName}", + "solutionId": "{solutionId}", + "status": "Succeeded", + "deployedAt": "{ISO timestamp}", + "hostEnvUrl": "{hostEnvUrl}" +} +``` diff --git a/plugins/power-pages/references/deployment-error-catalog.md b/plugins/power-pages/references/deployment-error-catalog.md new file mode 100644 index 000000000..23904f838 --- /dev/null +++ b/plugins/power-pages/references/deployment-error-catalog.md @@ -0,0 +1,414 @@ +# Deployment Error Catalog + +Known failure patterns for Power Pages deployments. Used by the `diagnose-deployment` skill to pattern-match errors and propose auto-fixes. + +Each entry includes: error pattern, root cause, severity, whether an auto-fix is available, and the fix procedure. + +--- + +## Pattern 1: Stale Environment Manifest + +**Error pattern** (in PAC CLI stderr or stdout): +``` +Error: The manifest file is out of date +Manifest mismatch detected for environment +Upload failed: manifest version conflict +``` + +**Root cause**: The `-manifest.yml` file in `.powerpages-site/` was created for a different environment or is from an old upload cycle. PAC CLI rejects uploads when the local manifest doesn't match the target environment's current state. + +**Severity**: Error + +**Auto-fix available**: Yes + +**Fix procedure**: +1. Locate the stale manifest: `glob('.powerpages-site/*-manifest.yml')` +2. **Ask explicit user permission**: "The manifest file `{filename}` is stale. Delete it so PAC CLI can regenerate it on next upload? This is safe — the file is regenerated automatically." +3. If approved: delete the manifest file (`fs.unlinkSync`) +4. Retry `pac pages upload-code-site --rootPath ""` +5. Verify upload succeeded (exit code 0) + +--- + +## Pattern 2: Blocked JavaScript Attachments + +**Error pattern** (in PAC CLI stderr): +``` +Error: JavaScript attachment is blocked +Upload failed: .js files are not allowed +Blocked file type: .js +The following files could not be uploaded: *.js +``` + +**Root cause**: The Dataverse environment has `.js` files in the blocked attachments setting (`blockedattachments`). Power Pages upload requires this setting to allow JS files. + +**Severity**: Error + +**Auto-fix available**: Yes + +**Fix procedure**: +1. Retrieve current blocked attachments: `pac env update-settings --name blockedattachments --value ""` OR query current value first via `pac env list-settings` +2. **Ask explicit user permission**: "JavaScript files are blocked in your environment. Update the `blockedattachments` setting to allow JS uploads? This modifies an environment-level security setting." +3. If approved: + - Get current setting: `pac env list-settings --name blockedattachments` + - Remove `.js` from the comma-separated list (preserve other blocked types) + - Apply: `pac env update-settings --name blockedattachments --value "{updated-list}"` +4. Retry upload +5. Verify upload succeeded + +**Note**: If the user declines, document this as a manual step: navigate to Power Platform Admin Center → Environments → Settings → Product → Features → Blocked Attachments. + +--- + +## Pattern 3: Missing websiteRecordId + +**Error pattern** (in `powerpages.config.json` or PAC CLI): +``` +websiteRecordId is missing or empty +Error: No website record ID found +Upload failed: cannot identify target website +``` + +**Root cause**: `powerpages.config.json` does not have a `websiteRecordId` field, or it is empty/null. This happens when the site was never activated, or the config was corrupted. + +**Severity**: Error + +**Auto-fix available**: Partial (can retrieve record ID from PAC CLI, cannot auto-activate) + +**Fix procedure**: +1. Run `pac pages list` to list available website records +2. Parse output to find matching `websiteRecordId` by site name +3. **Ask explicit user permission**: "Found website record `{id}` for `{siteName}`. Update `powerpages.config.json` with this record ID?" +4. If approved: update `powerpages.config.json` with the correct `websiteRecordId` +5. If no matching record found: the site needs activation first — suggest running `/power-pages:activate-site` + +--- + +## Pattern 4: Authentication Expired + +**Error pattern** (in PAC CLI stderr or az CLI): +``` +Error: Authentication token expired +AADSTS70011: The provided request must include a 'scope' input parameter +Unauthorized: 401 +Please run 'pac auth create' to authenticate +``` + +**Root cause**: PAC CLI auth token or Azure CLI session has expired. Tokens typically expire after 60–90 minutes. + +**Severity**: Error + +**Auto-fix available**: Yes (guided re-auth) + +**Fix procedure**: +1. Check PAC CLI auth: `pac auth who` +2. Check Azure CLI auth: `az account show` +3. If PAC CLI expired: `pac auth create --environment {envUrl}` +4. If Azure CLI expired: `az login` +5. After re-auth, verify: `pac env who` should show environment URL +6. Retry the original operation + +--- + +## Pattern 5: Missing Web Files / Empty Upload + +**Error pattern** (in PAC CLI stdout): +``` +No files to upload +Upload complete: 0 files uploaded +Build output directory not found +Warning: dist/ directory is empty +``` + +**Root cause**: The site was not built before uploading, or the build output directory (`dist/`) is empty or missing. PAC CLI uploads from the `compiledPath` in `powerpages.config.json`. + +**Severity**: Error + +**Auto-fix available**: Yes + +**Fix procedure**: +1. Read `compiledPath` from `powerpages.config.json` +2. Check if the build output directory exists and is non-empty +3. **Ask explicit user permission**: "The build output at `{compiledPath}` is empty or missing. Run `npm run build` to build the site first?" +4. If approved: run `npm run build` in the project root +5. Verify build output directory now contains files +6. Retry upload + +--- + +## Pattern 6: Solution Import Missing Dependencies + +**Error pattern** (in async operation message or importjob data): +``` +MissingDependency +Cannot import solution: missing required component +Dependency not found: {componentId} +Solution requires {componentType} {componentId} which is not present +``` + +**Root cause**: The solution being imported depends on components (tables, choices, plugins) that don't exist in the target environment. Common when importing to a clean environment without the full base solution stack. + +**Severity**: Error + +**Auto-fix available**: No (manual) + +**Informational guidance**: +- Export the dependency solution from the source environment +- Import the dependency solution first into the target +- Then retry importing the main solution +- Alternatively, use `StageSolution` before import to identify missing dependencies upfront + +--- + +## Pattern 7: Solution Export Timeout + +**Error pattern** (from `poll-async-operation.js` or asyncoperations): +``` +Export operation still running after timeout +AsyncOperation status: InProgress (timeout exceeded) +statecode: 0 after maximum polling attempts +``` + +**Root cause**: Large solutions can take longer than the default polling timeout. The operation is still running in Dataverse — it has not failed. + +**Severity**: Warning + +**Auto-fix available**: No (informational) + +**Informational guidance**: +- The export is still in progress in Dataverse +- Wait 5–10 minutes and retry the export skill, which will re-poll +- Check the async operation status directly: `GET {envUrl}/api/data/v9.2/asyncoperations({asyncJobId})?$select=statecode,statuscode` +- If `statecode=3, statuscode=30` (Succeeded), the export completed — retry the download step + +--- + +## Pattern 8: PAC CLI Not Installed + +**Error pattern** (when running pac commands): +``` +pac: command not found +'pac' is not recognized as an internal or external command +Error: pac CLI is not installed +``` + +**Root cause**: Power Platform CLI is not installed or not in PATH. + +**Severity**: Error + +**Auto-fix available**: No (installation required) + +**Informational guidance**: +- Install PAC CLI: `dotnet tool install --global Microsoft.PowerApps.CLI.Tool` +- Or download from: https://aka.ms/PowerAppsCLI +- After installation, verify: `pac --version` +- If using VS Code: install the Power Platform Tools extension + +--- + +## Pattern 9: Environment Mismatch + +**Error pattern** (in PAC CLI stdout or manifest comparison): +``` +Warning: Uploading to a different environment than the manifest was created for +Environment URL mismatch +Target environment does not match manifest environment +``` + +**Root cause**: The authenticated PAC CLI environment differs from the `environmentUrl` in `.solution-manifest.json` or the manifest file was created for a different environment. + +**Severity**: Warning + +**Auto-fix available**: Partial (confirm and switch environments) + +**Fix procedure**: +1. Display current PAC CLI environment: `pac env who` +2. Display manifest environment: read `environmentUrl` from `.solution-manifest.json` +3. **Ask user**: "You appear to be deploying to a different environment than the solution was created for. Continue with current environment `{currentEnv}` or switch to `{manifestEnv}`?" +4. If switch: `pac org select --environment {manifestEnv}` +5. Verify after switch: `pac env who` + +--- + +## Pattern 10: Duplicate Solution Component + +**Error pattern** (from AddSolutionComponent): +``` +Component already exists in solution +Duplicate component: {componentId} +The component {componentId} of type {componentType} is already part of the solution +``` + +**Root cause**: The component was already added to the solution in a previous run. This is not a fatal error. + +**Severity**: Info (not an error) + +**Auto-fix available**: N/A (skip and continue) + +**Handling**: Log as informational, skip the duplicate add, continue with remaining components. + +--- + +## Error Severity Reference + +| Severity | Meaning | Action | +|---|---|---| +| **Error** | Blocks deployment, must be resolved | Present auto-fix if available, else document manual steps | +| **Warning** | Deployment may succeed but with issues | Present to user, offer guidance | +| **Info** | Informational, not blocking | Display in summary table only | + +--- + +## Pattern 11: Solution Import Blocked by Attachment Restrictions + +**Error pattern** (in async operation `message` / `friendlyMessage`): +``` +AttachmentBlocked +The attachment is either not a valid type or is too large. It cannot be uploaded or downloaded. +ErrorCode: -2147188706 / 80043e09 +Plugin: Microsoft.Crm.ObjectModel.FileStoreService +Method: InitializeFileBlocksUpload +``` + +**Root cause**: The target environment's `blockedattachments` setting includes file types present inside the solution zip (e.g., `.zip`, `.js`, `.css`, `.png`). When `ImportSolutionAsync` processes web file components, it tries to store them as file attachments — and the environment-level blocklist rejects them. This commonly affects environments where security policy blocks broad sets of file types. + +**Severity**: Error + +**Auto-fix available**: Yes (with explicit user permission) + +**Primary mitigation — pre-flight detection** (added 2026-05-15 after a real-world case where a Content solution failed at 3,909 rejected `.js` files on Staging 50-75 minutes into the import, even though Dev had been pre-fixed): the `deploy-pipeline` skill runs a pre-flight check in **Phase 2.5** for any Power Pages project. It queries the target env's `blockedattachments` setting via `fix-blocked-attachments.js --dry-run` and, if any relevant extensions are blocked, prompts the user immediately via `AskUserQuestion` to unblock + continue, proceed anyway (Phase 7.6 reactive path catches failure), or cancel. This catches the issue in ~10 seconds instead of after the full import attempt. The procedure below is the reactive path the **Phase 7.6** handler invokes when the pre-flight was skipped or declined. + +**Fix procedure**: +1. Retrieve the current blocked attachments list: + ```bash + pac env list-settings --name blockedattachments + ``` +2. Identify which file types in the solution are on the blocklist (common culprits: `.zip`, `.js`, `.css`) +3. **Ask explicit user permission**: "The environment blocks certain file types required by this solution. Remove the blocking for `{types}` from the `blockedattachments` setting? This modifies an environment-level security setting and affects all users." +4. If approved — remove the specific types from the comma-separated list and apply: + ```bash + pac env update-settings --name blockedattachments --value "{updated-list-without-blocked-types}" + ``` +5. Retry `ImportSolutionAsync` +6. After successful import, optionally restore the blocked types if the customer wants them re-blocked (they'll need to manage the web files differently going forward) + +**Note**: If the user declines, document as a manual step: Power Platform Admin Center → Environments → {env} → Settings → Product → Features → Blocked Attachments. + +--- + +## Pattern 12: Site Broken After Pipeline Deploy — Missing Web API Site Settings + +**Error pattern** (observed in browser, not a PAC CLI error): +``` +Web API calls return 404 or 403 in target environment +Webapi/{table}/enabled site setting not found +Site functions but data operations all fail silently +``` + +**Root cause**: The solution was packaged without `Webapi/*` site settings. By default, many ALM workflows warn against including all site settings (due to OAuth secrets) and users exclude all site settings. But `Webapi/*/enabled` and `Webapi/*/fields` are not secrets — they must be present in the target environment for Web API calls to work. Without them, Power Pages silently denies all Web API requests. + +**Severity**: Error + +**Auto-fix available**: Yes + +**Fix procedure**: +1. Query the source environment for all `Webapi/*` site settings: + ``` + GET {srcEnvUrl}/api/data/v9.2/mspp_sitesettings?$filter=startswith(mspp_name,'Webapi/') and _mspp_websiteid_value eq {websiteRecordId}&$select=mspp_name,mspp_value + ``` +2. For each missing setting in the target environment, create it: + ``` + POST {targetEnvUrl}/api/data/v9.2/mspp_sitesettings + { "mspp_name": "Webapi/crd50_invoice/enabled", "mspp_value": "true", "mspp_websiteid@odata.bind": "/powerpagesites({websiteRecordId})" } + ``` +3. Alternatively, re-run `setup-solution` and include the `Webapi/*` category when asked about site settings. + +**Prevention**: The `setup-solution` skill should ALWAYS include `Webapi/*` site settings by default — these are required for any code site that uses the Web API. + +--- + +## Pattern 13: Site Broken After Pipeline Deploy — Missing Dataverse Tables + +**Error pattern** (observed in browser console or Web API calls): +``` +Resource not found for the segment '{table}' +Entity '{logicalName}' does not exist in target environment +404 on GET /_api/{logicalName} +``` + +**Root cause**: The solution was created without adding the underlying Dataverse table definitions (ComponentType=1 entities). Without them, the tables don't exist in the target environment after import. Web API calls referencing those tables return 404. + +**Severity**: Error + +**Auto-fix available**: Partial (must re-export and re-import solution with tables) + +**Fix procedure**: +1. Identify missing tables: compare `EntityDefinitions` in source vs target for tables used by the site +2. Re-run `setup-solution` on the source environment, ensuring all custom tables from `.datamodel-manifest.json` are added to the solution (ComponentType=1) +3. Re-export and re-deploy the solution with tables included + +**Prevention**: The `setup-solution` skill reads `.datamodel-manifest.json` and automatically adds all custom tables as root solution components. + +--- + +## Pattern 14: Web API Returns 403 — Table Permissions Not in Solution + +**Error pattern** (from Web API response body): +``` +{ "error": { "code": "90040901", "message": "..." } } +403 Forbidden on POST/PATCH /_api/{table} +Authenticated users cannot create/read records +``` + +**Root cause**: Table Permissions (`adx_entitypermission` records, powerpagecomponenttype=18) were not added to the solution, so they weren't deployed to the target environment. The target site has no table permissions → all Web API calls are denied. + +**Severity**: Error + +**Auto-fix available**: Yes (if table permissions exist in source env) + +**Fix procedure**: +1. Query source env for table permissions for this site: + ``` + GET {srcEnvUrl}/api/data/v9.2/powerpagecomponents?$filter=_powerpagesiteid_value eq {websiteRecordId} and powerpagecomponenttype eq 18&$select=powerpagecomponentid,name + ``` +2. For each permission missing in target, add it to the solution and re-deploy. +3. Or re-run `setup-solution` — table permissions (type 18) are included by default. + +**Prevention**: Table Permissions are standard `powerpagecomponents` and are included by default in `setup-solution` Phase 5. Verify they were not accidentally excluded when the solution was created. + +--- + +## Pattern 15: Environment Already Associated With Another Pipelines Host + +> **Approval gates:** Detection by `setup-pipeline:5a.pattern-15` (consent). Auto-fix by `force-link-environment:4.destructive` (consent). See `references/approval-gates.md` §6.3 and §6.9. + +**Error pattern** (from `deploymentenvironments` POST or its `validationstatus` poll): +``` +This environment is already associated with another pipelines host. +errormessage: "...already associated with another pipelines host..." +validationstatus: 200000002 (Failed) +``` + +**Root cause**: The BAP environment being added to this Pipelines host is already stamped (linked) to a different Pipelines host. Each environment can only be linked to one host at a time — the stamp lives on the BAP env record and points back to whichever host claimed it most recently. Common cause: a tenant previously used the Platform Host (auto-created), and now an admin is migrating environments to a Custom Host (or between two Custom Hosts). + +**Severity**: Error + +**Auto-fix available**: Yes (via the `force-link-environment` skill — DESTRUCTIVE to the previous host). + +**Microsoft Learn reference**: [Using Force Link to associate an environment with a new host](https://learn.microsoft.com/en-us/power-platform/alm/custom-host-pipelines#using-force-link-to-associate-an-environment-with-a-new-host). + +**Fix procedure**: +1. Confirm with the user that the migration is intentional. Force Link is reversible (run it again from the previous host) but has two documented side effects: + - Makers in the previous host lose access to pipelines that ran through this environment. + - The previous host's `deploymentenvironments` row is left with a stale `validationstatus = Succeeded` until refreshed. +2. Identify the new host's `deploymentenvironments` record ID for this BAP env. If it does not exist yet, create it first (the create itself will mark the record as Failed with this exact errormessage). +3. Invoke `/power-pages:force-link-environment` with `--host ` and `--dev-env `. The skill will: + - Re-confirm the destructive action via `AskUserQuestion`. + - Call `POST {newHostEnvUrl}/api/data/v9.0/ManageEnvironmentStamp` with body `{"DeploymentEnvironmentId":"{UPPER-GUID}"}` and headers `clienthost: Browser`, `x-ms-app-name: AppDeploymentConfiguration`. + - Re-poll `validationstatus` on the record until Succeeded (200000001). + - Write `docs/alm/last-force-link.json` marker. +4. Verify by re-running the originally-failing operation (typically `setup-pipeline` or `deploy-pipeline`) — the env's `validationstatus` should now be Succeeded on this host. + +**Prerequisite**: Caller must have **Deployment Pipeline Administrator** role on the new host. Without it, `ManageEnvironmentStamp` returns 403. + +**Manual alternative**: In the Deployment Pipeline Configuration app, open the environment record on the new host and click **Force Link** on the command bar. Confirm the prompt. diff --git a/plugins/power-pages/references/skill-tracking-reference.md b/plugins/power-pages/references/skill-tracking-reference.md index 11121cca1..0020350b6 100644 --- a/plugins/power-pages/references/skill-tracking-reference.md +++ b/plugins/power-pages/references/skill-tracking-reference.md @@ -37,9 +37,19 @@ If the tracking script creates or updates site setting YAML files, include those | setup-auth | SetupAuth | Site/AI/Skills/SetupAuth | | test-site | TestSite | Site/AI/Skills/TestSite | | audit-permissions | AuditPermissions | Site/AI/Skills/AuditPermissions | +| plan-alm | PlanAlm | Site/AI/Skills/PlanAlm | | add-server-logic | AddServerLogic | Site/AI/Skills/AddServerLogic | | add-cloud-flow | AddCloudFlow | Site/AI/Skills/AddCloudFlow | | integrate-backend | IntegrateBackend | Site/AI/Skills/IntegrateBackend | +| setup-solution | SetupSolution | Site/AI/Skills/SetupSolution | +| export-solution | ExportSolution | Site/AI/Skills/ExportSolution | +| import-solution | ImportSolution | Site/AI/Skills/ImportSolution | +| diagnose-deployment | DiagnoseDeployment | Site/AI/Skills/DiagnoseDeployment | +| configure-env-variables | ConfigureEnvVariables | Site/AI/Skills/ConfigureEnvVariables | +| setup-pipeline | SetupPipeline | Site/AI/Skills/SetupPipeline | +| deploy-pipeline | DeployPipeline | Site/AI/Skills/DeployPipeline | +| ensure-pipelines-host | EnsurePipelinesHost | Site/AI/Skills/EnsurePipelinesHost | +| force-link-environment | ForceLinkEnvironment | Site/AI/Skills/ForceLinkEnvironment | ## YAML Format diff --git a/plugins/power-pages/references/solution-api-patterns.md b/plugins/power-pages/references/solution-api-patterns.md new file mode 100644 index 000000000..e270cefb2 --- /dev/null +++ b/plugins/power-pages/references/solution-api-patterns.md @@ -0,0 +1,526 @@ +# Solution API Patterns + +OData request body templates for Dataverse solution lifecycle operations. Used by `setup-solution`, `export-solution`, and `import-solution` skills. + +> **Auth**: All requests require `Authorization: Bearer ` and `OData-Version: 4.0` headers. See `references/odata-common.md` for full header set and retry patterns. + +--- + +## 1. Create Publisher + +**Endpoint**: `POST {envUrl}/api/data/v9.2/publishers` + +**Request body**: +```json +{ + "uniquename": "contoso", + "friendlyname": "Contoso", + "customizationprefix": "con", + "customizationoptionvalueprefix": 10000 +} +``` + +**Key fields**: +- `uniquename`: Lowercase letters/numbers only, no spaces. Cannot be changed after creation. +- `customizationprefix`: 2–8 lowercase letters. Used as prefix for all components (e.g., `con_WebsiteName`). **Irreversible.** +- `customizationoptionvalueprefix`: Integer 10000–99999. Prefix for option set values. + +**Success response**: `204 No Content` with `OData-EntityId` header containing the publisher URL (extract GUID for `publisherid`). + +**Check existing** (before creating): +``` +GET {envUrl}/api/data/v9.2/publishers?$filter=uniquename eq '{uniquename}'&$select=publisherid,uniquename,customizationprefix +``` + +--- + +## 2. Create Solution + +**Endpoint**: `POST {envUrl}/api/data/v9.2/solutions` + +**Request body**: +```json +{ + "uniquename": "ContosoSite", + "friendlyname": "Contoso Site", + "version": "1.0.0.0", + "description": "Power Pages site components for Contoso", + "publisherid@odata.bind": "/publishers({publisherId})" +} +``` + +**Key fields**: +- `uniquename`: Letters, numbers, underscores only. Cannot be changed after creation. +- `version`: Must be in `major.minor.build.revision` format. +- `publisher_solution@odata.bind`: Links to the publisher by `publisherid` GUID. + +**Success response**: `204 No Content` with `OData-EntityId` header. Extract `solutionid` GUID from URL. + +**Check existing**: +``` +GET {envUrl}/api/data/v9.2/solutions?$filter=uniquename eq '{uniquename}'&$select=solutionid,uniquename,version,ismanaged +``` + +--- + +## 3. Add Solution Component + +**Endpoint**: `POST {envUrl}/api/data/v9.2/AddSolutionComponent` + +**Request body**: +```json +{ + "ComponentId": "{componentGuid}", + "ComponentType": "{discoveredComponentType}", + "SolutionUniqueName": "ContosoSite", + "AddRequiredComponents": false, + "DoNotIncludeSubcomponents": false, + "IncludedComponentSettingsValues": null +} +``` + +Where `{discoveredComponentType}` is the integer value returned by the discovery query above for this component's objectId. + +**Component types for Power Pages**: + +> **IMPORTANT — Never hardcode component type numbers.** Component type codes are environment-specific metadata and vary across tenants and environments. Always resolve them at runtime using the discovery query below before calling `AddSolutionComponent`. + +**Discover the component type for any objectId**: +``` +GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=objectid eq '{knownObjectId}'&$select=componenttype&$top=1 +``` + +Run this query **twice**: once for `websiteRecordId` (captures `websiteComponentType`) and once for any `powerpagecomponentid` from the site (captures `subComponentType`). All Power Pages sub-components — web pages, web files, web roles, site settings, templates, etc. — share a **single `componenttype` value** in `solutioncomponents`. Only the top-level site record uses a different componenttype. + +**Known approximate values** (for reference only — do not hardcode; resolve at runtime): +| Type | Approximate ComponentType | Notes | +|---|---|---| +| Website (`powerpagesite` root) | **10427** (observed) | Resolve via discovery query using `websiteRecordId`. Earlier docs cited ~10374. | +| All sub-components (`powerpagecomponent`) — web pages, web files, web roles, site settings, templates, table permissions, etc. | **10426** (observed) | One shared componenttype for ALL powerpagecomponents — resolve via discovery query using any `powerpagecomponentid`. | +| Site language (`powerpagesitelanguage`) | **10428** (observed) | Separate sibling unified entity, NOT included by `AddRequiredComponents: true`. Earlier docs cited ~10375. | + +> **Why the 3-entity model matters for ALM.** Power Pages has **three** sibling unified entities for a single site: `powerpagesite` (root), `powerpagecomponent` (sub-records), and `powerpagesitelanguage` (languages). Each gets its own `solutioncomponent.componenttype`. If `setup-solution` only enumerates `powerpagecomponent` (the most common gap), the language record never lands in the user solution → solution import in the target env creates the site without any language → `powerpagesite.content.defaultlanguage` references an ID that doesn't exist → site silently fails to render post-auth. Always include `powerpagesitelanguages` in the discovery + bulk-add pass. See `scripts/lib/discover-site-components.js` `discoverSiteLanguages()` for the canonical enumeration. + +**Add the Website component first** with `AddRequiredComponents: true`. Then add site language records (componenttype 10428), then all sub-components individually (componenttype 10426). The `AddRequiredComponents: true` flag does NOT automatically cascade sub-components or site languages — each must be added explicitly. + +**Site language discovery**: +``` +GET {envUrl}/api/data/v9.2/powerpagesitelanguages?$filter=_powerpagesiteid_value eq '{websiteRecordId}'&$select=powerpagesitelanguageid,languagecode,displayname + +# Discover its componenttype: +GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=objectid eq '{powerpagesitelanguageid}'&$select=componenttype&$top=1 +``` + +**Adding Dataverse tables (entities) to the solution**: + +Tables are NOT powerpagecomponents — they use `ComponentType: 1` (fixed, not discovered). The component ID is the entity's `MetadataId`: + +``` +# Find entity MetadataId by logical name +GET {envUrl}/api/data/v9.2/EntityDefinitions?$filter=LogicalName eq '{logicalName}'&$select=MetadataId,LogicalName + +# Add entity to solution +POST {envUrl}/api/data/v9.2/AddSolutionComponent +{ + "ComponentId": "{MetadataId}", + "ComponentType": 1, + "SolutionUniqueName": "{solutionUniqueName}", + "AddRequiredComponents": false, + "DoNotIncludeSubcomponents": false +} +``` + +Read table logical names from `.datamodel-manifest.json` (`tables[].logicalName`). Without the table definitions in the solution, target environments won't have the tables and all Web API calls will return 404. + +**Success response**: `200 OK` with empty body or component details. + +**Verify components added**: +``` +GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq '{solutionId}'&$select=objectid,componenttype&$orderby=componenttype +``` + +--- + +## 3b. Discover All Power Pages Sub-Components (powerpagecomponents) + +The `AddRequiredComponents: true` flag on the website record does **not** cascade all sub-components (web pages, web files, site settings, templates, etc.) into the solution. Each sub-component must be added individually. Use the `powerpagecomponents` entity to enumerate all of them. + +**Endpoint**: +``` +GET {envUrl}/api/data/v9.2/powerpagecomponents + ?$filter=_powerpagesiteid_value eq '{websiteRecordId}' + &$select=powerpagecomponentid,name,powerpagecomponenttype + &$orderby=powerpagecomponenttype +``` + +**Pagination**: Follow `@odata.nextLink` in each response until the link is absent (all pages fetched). + +**Resolve component type labels dynamically** before grouping — never rely on a hardcoded table as the primary source: + +``` +GET {envUrl}/api/data/v9.2/GlobalOptionSetDefinitions(Name='powerpagecomponenttype') +``` + +Response shape: +```json +{ + "Options": [ + { "Value": 1, "Label": { "UserLocalizedLabel": { "Label": "Publishing State" } } }, + { "Value": 2, "Label": { "UserLocalizedLabel": { "Label": "Web Page" } } } + ] +} +``` + +Build a map `{ [Value]: Label.UserLocalizedLabel.Label }` and use it when displaying grouped results. For any type value not in the map, display as `Unknown (N)`. This query is always current — no code changes needed when Microsoft adds new component types. + +**Fallback table** (used only if the metadata query fails — values current as of 2026-03, source: Microsoft Learn `powerpagecomponent` entity reference): + +**Group by `powerpagecomponenttype`** for the user-facing summary: + +| powerpagecomponenttype | Label | Sensitive? | +|---|---|---| +| 1 | Publishing State | No | +| 2 | Web Page | No | +| 3 | Web File | No | +| 4 | Web Link Set | No | +| 5 | Web Link | No | +| 6 | Page Template | No | +| 7 | Content Snippet | No | +| 8 | Web Template | No | +| 9 | Site Setting | **YES** | +| 10 | Web Page Access Control Rule | No | +| 11 | Web Role | No | +| 12 | Website Access | No | +| 13 | Site Marker | No | +| 15 | Basic Form | No | +| 16 | Basic Form Metadata | No | +| 17 | List | No | +| 18 | Table Permission | No | +| 19 | Advanced Form | No | +| 20 | Advanced Form Step | No | +| 21 | Advanced Form Metadata | No | +| 24 | Poll Placement | No | +| 26 | Ad Placement | No | +| 27 | Bot Consumer | No | +| 28 | Column Permission Profile | No | +| 29 | Column Permission | No | +| 30 | Redirect | No | +| 31 | Publishing State Transition Rule | No | +| 32 | Shortcut | No | +| 33 | Cloud Flow | No | +| 34 | UX Component | No | +| 35 | Server Logic | No | + +> **Security warning — Type 9 (Site Settings)**: Site Settings can include OAuth provider secrets such as `Authentication/OpenAuth/Facebook/AppSecret`, `Authentication/OpenAuth/Microsoft/ClientSecret`, etc. Including these in a solution that is exported and deployed to other environments moves sensitive credentials across tenants. **Default: exclude site settings.** Ask the user explicitly before including them. + +**After fetching**, present a grouped summary and ask the user which categories to include. Then call `AddSolutionComponent` for each component in the selected categories, using the `subComponentType` discovered in Step 5.1. + +### Types 27 (Bot Consumer) and 33 (Cloud Flow) — Backing Entity Resolution + +The `powerpagecomponent` records for types 27 and 33 are link records only — they do NOT contain the Cloud Flow or Bot definition itself. To make flows and bots deployable, the backing `workflow` and `bot` entities must also be added to the solution separately. + +**Runtime field introspection pattern** (use when the lookup field name on `powerpagecomponent` is unknown): + +1. Query type-33 (or type-27) components for the site without `$select` restrictions on a single record: + ``` + GET {envUrl}/api/data/v9.2/powerpagecomponents({firstComponentId}) + ``` +2. Scan the response JSON for keys matching `_*_value` with a non-null GUID that ≠ `websiteRecordId`. This is the backing entity lookup field name (e.g., `_adx_workflow_value` for flows). +3. Re-query all components of that type with the discovered field in `$select` to collect all backing entity GUIDs. +4. Resolve backing entity names and statuses via: + - Cloud Flows: `GET {envUrl}/api/data/v9.2/workflows({workflowId})?$select=name,workflowid,statecode` + - Bots: `GET {envUrl}/api/data/v9.2/bots({botId})?$select=name,botid,statecode` +5. Discover each backing entity's `componenttype` via: + ``` + GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=objectid eq '{id}'&$select=componenttype&$top=1 + ``` + If empty (entity not yet in any solution), the entity still exists and can be added — note it as "not previously in a solution." + +--- + +## 4. Export Solution (Async) + +Export is a two-step process: trigger async export, then download the result. + +### Step 4a: Trigger Export + +**Endpoint**: `POST {envUrl}/api/data/v9.2/ExportSolutionAsync` + +**Request body**: +```json +{ + "SolutionName": "ContosoSite", + "Managed": false, + "TargetVersion": "", + "ExportAutoNumberingSettings": false, + "ExportCalendarSettings": false, + "ExportCustomizationSettings": false, + "ExportEmailTrackingSettings": false, + "ExportGeneralSettings": false, + "ExportIsvConfig": false, + "ExportMarketingSettings": false, + "ExportOutlookSynchronizationSettings": false, + "ExportRelationshipRoles": false, + "ExportSales": false +} +``` + +- Set `"Managed": true` to export as a managed solution (cannot be further customized in target environment — recommended for production deployments). +- Set `"Managed": false` for unmanaged (can be edited in target environment — use for development/staging). + +**Success response**: `200 OK` with JSON body: +```json +{ + "@odata.context": "...", + "AsyncOperationId": "00000000-0000-0000-0000-000000000000", + "ExportJobId": "00000000-0000-0000-0000-000000000000" +} +``` + +Capture `AsyncOperationId` and pass to `scripts/poll-async-operation.js` as `--asyncJobId`. + +### Step 4b: Download Export Result + +**Endpoint**: `POST {envUrl}/api/data/v9.2/DownloadSolutionExportData` + +**Request body**: +```json +{ + "ExportJobId": "{exportJobId}" +} +``` + +**Success response**: `200 OK` with JSON body: +```json +{ + "@odata.context": "...", + "ExportSolutionFile": "" +} +``` + +Decode `ExportSolutionFile` from base64 and write to disk as `{SolutionName}_{managed|unmanaged}.zip`. + +**Verify zip**: Confirm `Solution.xml` exists inside the zip (use `unzip -l` or read zip TOC). File size should be > 1000 bytes. + +--- + +## 5. Import Solution (Async) + +Import is optionally a two-step process: optionally stage first (dependency check), then import. + +### Step 5a: Stage Solution (Optional but recommended for managed) + +**Endpoint**: `POST {envUrl}/api/data/v9.2/StageSolution` + +**Request body**: +```json +{ + "CustomizationFile": "" +} +``` + +Use `scripts/encode-solution-file.js` to base64-encode the zip file. + +**Success response**: `200 OK` with JSON body: +```json +{ + "@odata.context": "...", + "StageSolutionResults": { + "StageSolutionStatus": "Completed", + "StageSolutionUploadId": "00000000-0000-0000-0000-000000000000", + "SolutionDetails": { + "SolutionUniqueName": "ContosoSite", + "SolutionFriendlyName": "Contoso Site", + "SolutionVersion": "1.0.0.0", + "IsManaged": false + }, + "MissingDependencies": [] + } +} +``` + +If `MissingDependencies` is non-empty, present each missing dependency to the user before proceeding. Staging does NOT commit the import — it is purely a validation step. + +### Step 5b: Import Solution + +**Endpoint**: `POST {envUrl}/api/data/v9.2/ImportSolutionAsync` + +**Request body (direct import)**: +```json +{ + "CustomizationFile": "", + "OverwriteUnmanagedCustomizations": true, + "PublishWorkflows": true, + "ConvertToManaged": false, + "SkipProductUpdateDependencies": false, + "HoldingSolution": false +} +``` + +> **Important**: `ImportSolutionAsync` does **not** accept `StageSolutionUploadId`. After a successful `StageSolution` (dependency check), you must still use `CustomizationFile` (re-encoded zip) when calling `ImportSolutionAsync`. The staging step is purely for pre-flight dependency validation — it does not alter the import call. + +**Request body (always use CustomizationFile)**: +```json +{ + "CustomizationFile": "", + "OverwriteUnmanagedCustomizations": true, + "PublishWorkflows": true, + "ConvertToManaged": false, + "SkipProductUpdateDependencies": false, + "HoldingSolution": false +} +``` + +- `OverwriteUnmanagedCustomizations: true`: Required when importing over existing customizations in target. +- `PublishWorkflows: true`: Activates workflows after import. +- `HoldingSolution: true`: Performs a staged upgrade (for upgrading managed solutions with delete operations). + +**Success response**: `200 OK` with JSON body: +```json +{ + "@odata.context": "...", + "AsyncOperationId": "00000000-0000-0000-0000-000000000000", + "ImportJobKey": "00000000-0000-0000-0000-000000000000" +} +``` + +Pass `AsyncOperationId` (as `asyncJobId`) to `scripts/poll-async-operation.js`. + +> **Note**: The response field is `ImportJobKey` (not `ImportJobId`). Use this value to query the import job for component-level results after polling completes. + +**Check import result after completion**: +``` +GET {envUrl}/api/data/v9.2/importjobs({ImportJobKey})?$select=solutionname,completedon,progress,data +``` + +The `data` field is XML containing `` with per-component import results. Parse for `result="success"` vs `result="failure"`. + +--- + +## 6. Query Async Operation Status + +See `scripts/poll-async-operation.js` for the reusable poller. + +**Manual status check**: +``` +GET {envUrl}/api/data/v9.2/asyncoperations({asyncJobId})?$select=statecode,statuscode,message,friendlymessage +``` + +**Status codes**: +| statecode | statuscode | Meaning | +|---|---|---| +| 0 | 0 | Ready | +| 0 | 20 | In Progress | +| 0 | 30 | Pausing | +| 0 | 40 | Canceling | +| 1 | 10 | Waiting for Resources | +| 3 | 30 | Succeeded | +| 3 | 31 | Failed | +| 3 | 32 | Canceled | + +Poll until `statecode === 3` (terminal). Check `statuscode === 30` for success, `statuscode === 31/32` for failure. + +--- + +## 7. Solution Manifest Format + +Written by `setup-solution`, read by `export-solution`, `import-solution`, and `setup-pipeline`. + +**File**: `.solution-manifest.json` (project root, alongside `powerpages.config.json`) + +```json +{ + "schemaVersion": "1.0", + "createdAt": "2025-01-01T00:00:00.000Z", + "environmentUrl": "https://contoso.crm.dynamics.com", + "publisher": { + "uniqueName": "contoso", + "friendlyName": "Contoso", + "prefix": "con", + "publisherId": "00000000-0000-0000-0000-000000000000" + }, + "solution": { + "uniqueName": "ContosoSite", + "friendlyName": "Contoso Site", + "version": "1.0.0.0", + "solutionId": "00000000-0000-0000-0000-000000000000" + }, + "components": [ + { + "componentType": 61, + "componentId": "00000000-0000-0000-0000-000000000000", + "description": "Website: My Contoso Site" + } + ], + "cloudFlows": [ + { + "workflowId": "00000000-0000-0000-0000-000000000000", + "name": "Invoice Approval Flow", + "status": "active" + } + ], + "botComponents": [ + { + "botId": "00000000-0000-0000-0000-000000000000", + "name": "Support Bot" + } + ] +} +``` + +**Notes on `cloudFlows` and `botComponents`:** +- These arrays are **omitted entirely** when no flows or bots were discovered during `setup-solution` (not tracked). +- An empty array `[]` means flows/bots were discovered but the user chose to exclude all of them. +- Downstream skills (`deploy-pipeline`, `plan-alm`) can check for the presence of these arrays and display counts or warnings accordingly. +- `status: "active"` or `"inactive"` reflects the `statecode` at time of setup — inactive flows will still deploy but may not trigger in the target environment until activated. + +--- + +## 8. Solution Packaging — BYOC vs Traditional Portal + +Power Pages supports two site types. How cloud flows and bots are packaged differs between them. + +**Detect site type** from `powerpages.config.json` → `powerpagesitetype`: +- `1` = Traditional (Classic) portal — uses managed metadata v1 +- `2` = BYOC code site — uses data model v2.0 (`datamodelversion: "2.0"`) + +### Cloud Flow Packaging + +| | Traditional portal | BYOC code site | +|---|---|---| +| Cloud flow component in `solutioncomponents` | `ComponentType: 29` (Workflow) | `ComponentType: 29` (Workflow) | +| Site-level binding record | `powerpagecomponent` type **33** ("cloud flow binding") — links the flow to a specific page | **Not present.** BYOC sites call flows directly via Web API from the React/Vue/Angular app | +| How to add to solution | Add website (pulls type-33 bindings automatically as sub-components) + add cloud flow workflow entity (type 29) separately | Add website (no type-33 records exist) + add cloud flow workflow entity (type 29) separately | + +> **Implication for `setup-solution`**: Querying `powerpagecomponents` for type 33 records is valid for traditional portals only. For BYOC sites this query returns 0 results — which is correct. The skill should still add cloud flow workflow entities (type 29) explicitly when present. + +### Bot Packaging + +Bots (Copilot Studio) always use the same packaging regardless of site type: + +| Component | Location in zip | In `solution.xml` RootComponents? | +|---|---|---| +| Bot definition | `bots/bot.xml` + `botcomponents/*.xml` | No — packaged implicitly | +| Bot consumer binding | `powerpagecomponents/{id}.xml` with `powerpagecomponenttype: 27` | No — pulled in as a sub-component when website is added | +| Bot schema reference | `botschemaname` field in the type-27 powerpagecomponent — must match bot in target | — | + +> **Post-import requirement**: Bots must be **republished** in the target environment after import. The `synchronizationstatus` in the exported `bot.xml` reflects the source environment's provisioning state and is not automatically updated on import. + +### Connection References (Cloud Flows) + +Connection references are declared in `customizations.xml` (`` block) and appear as separate records in the solution. They are NOT `RootComponent` entries. + +- The `promptingbehavior: 0` setting means the import will NOT prompt the user to bind connections during import. The flow will be imported but **left in a disabled state** if no connection is bound. +- After import, the user must navigate to Power Automate → target environment → each flow → Edit → bind connections. +- Connection references use logical names (e.g., `new_sharedcommondataserviceforapps_511b0`) that are consistent across environments, but the underlying connection ID is always user/environment-specific. + +### Hardcoded Environment URLs in Flow JSON + +Some flow actions (notably "Download a file or an image" from Dataverse) store the environment URL as a hardcoded string in the flow's JSON definition (e.g., `"organization": "https://orgXXXXXXXX.crm.dynamics.com"`). This is a known portability issue — the flow will silently call the **source** environment after import until the field is manually updated. + +**Detection** (run against the solution zip before import): +```bash +unzip -p "{zipPath}" "Workflows/*.json" 2>/dev/null | grep -o '"organization":\s*"https://[^"]*"' +``` + +If a URL is found, warn the user before import and include it in the post-import checklist. diff --git a/plugins/power-pages/scripts/create-environment-variable.js b/plugins/power-pages/scripts/create-environment-variable.js index ffb1516b4..340f44334 100644 --- a/plugins/power-pages/scripts/create-environment-variable.js +++ b/plugins/power-pages/scripts/create-environment-variable.js @@ -4,17 +4,26 @@ // Uses Dataverse OData API with Azure CLI authentication. // // Usage: -// node create-environment-variable.js --schemaName --displayName --value [--type ] +// node create-environment-variable.js --schemaName --displayName --value +// [--type ] +// [--solutionUniqueName ] // // Arguments: -// envUrl Dataverse environment URL (e.g., https://org123.crm.dynamics.com) -// --schemaName Schema name for the env var (e.g., cr5b4_ApiSecret) -// --displayName Human-readable display name -// --value The value (plain text for string type, Key Vault secret URI for secret type) -// --type "string" (default) or "secret" (Key Vault-backed) +// envUrl Dataverse environment URL (e.g., https://org123.crm.dynamics.com) +// --schemaName Schema name for the env var (e.g., cr5b4_ApiSecret) +// --displayName Human-readable display name +// --value The value (plain text for string type, Key Vault secret URI for secret type) +// --type "string" (default) or "secret" (Key Vault-backed) +// --solutionUniqueName Optional. When provided (or when .solution-manifest.json is present), +// the created definition is also added to that solution via +// AddSolutionComponent so it does not become an orphan in the +// `Default` solution. See AGENTS.md → ALM-aware-by-default principle. // // Output (JSON to stdout): -// { "definitionId": "", "valueId": "", "schemaName": "" } +// { +// "definitionId": "", "valueId": "", "schemaName": "", +// "addedToSolution": { "uniqueName": "...", "source": "arg" | "manifest" } | null +// } // // Exit codes: // 0 - Success @@ -22,6 +31,10 @@ const { getAuthToken, makeRequest } = require('./lib/validation-helpers'); const generateUuid = require('./generate-uuid'); +const { + resolveTargetSolution, + NoSolutionConfiguredError, +} = require('./lib/resolve-target-solution'); const cliArgs = process.argv.slice(2); @@ -43,6 +56,7 @@ const schemaName = getArg('schemaName'); const displayName = getArg('displayName'); const value = getArg('value'); const type = getArg('type') || 'string'; +const explicitSolutionUniqueName = getArg('solutionUniqueName'); if (!envUrl || !schemaName || !displayName || value === null) { process.stderr.write( @@ -124,7 +138,49 @@ async function main() { process.exit(1); } - process.stdout.write(JSON.stringify({ definitionId, valueId, schemaName })); + // ALM-aware-by-default (see AGENTS.md): if a target solution resolves, add the + // new definition via AddSolutionComponent so it lands in the user's solution + // instead of the `Default` orphan bucket. + let addedToSolution = null; + try { + const target = await resolveTargetSolution({ + explicit: explicitSolutionUniqueName, + // projectRoot defaults to cwd, which works when this script is invoked + // from within a Power Pages project that has `.solution-manifest.json`. + }); + const addRes = await apiPost(envUrl, token, 'AddSolutionComponent', { + ComponentId: definitionId, + ComponentType: 380, + SolutionUniqueName: target.solutionUniqueName, + AddRequiredComponents: false, + DoNotIncludeSubcomponents: true, + }); + if (!addRes.ok) { + // Non-fatal: definition was created successfully. Surface the failure on + // stderr so skills that wrap this script can decide how to handle. + process.stderr.write( + `Warning: env var definition created, but adding to solution "${target.solutionUniqueName}" failed: ${addRes.message}\n` + ); + } else { + addedToSolution = { uniqueName: target.solutionUniqueName, source: target.source }; + } + } catch (err) { + if (err instanceof NoSolutionConfiguredError) { + // No manifest and no explicit arg: the ALM-aware rule says we should NOT + // silently leave the definition in Default. Print a clear reminder. + process.stderr.write( + `Warning: env var "${schemaName}" was created but no target solution was resolved. ` + + `It currently lives only in the Default solution. ` + + `Pass --solutionUniqueName or run /power-pages:setup-solution to capture it.\n` + ); + } else { + process.stderr.write( + `Warning: env var "${schemaName}" was created; solution resolution failed: ${err.message}\n` + ); + } + } + + process.stdout.write(JSON.stringify({ definitionId, valueId, schemaName, addedToSolution })); } main(); diff --git a/plugins/power-pages/scripts/encode-solution-file.js b/plugins/power-pages/scripts/encode-solution-file.js new file mode 100644 index 000000000..d6458efad --- /dev/null +++ b/plugins/power-pages/scripts/encode-solution-file.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +// Base64-encodes a solution zip file for use in Dataverse OData request bodies. +// Handles large files by reading in chunks and encoding with Node.js built-ins. +// +// Usage: +// node encode-solution-file.js --zipPath "/path/to/solution.zip" +// +// Output (JSON to stdout): +// { "encoded": "", "fileSizeBytes": 12345, "fileName": "solution.zip" } +// { "error": "..." } — when the file is missing or cannot be read + +const fs = require('fs'); +const path = require('path'); + +function output(obj) { + process.stdout.write(JSON.stringify(obj)); + process.exit(0); +} + +function parseArgs(argv) { + const args = {}; + const idx = argv.indexOf('--zipPath'); + if (idx !== -1 && idx + 1 < argv.length) { + args.zipPath = argv[idx + 1]; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); + +if (!args.zipPath) { + output({ error: 'Missing required argument: --zipPath' }); +} + +const resolvedPath = path.resolve(args.zipPath); + +if (!fs.existsSync(resolvedPath)) { + output({ error: `File not found: ${resolvedPath}` }); +} + +const stat = fs.statSync(resolvedPath); +if (!stat.isFile()) { + output({ error: `Path is not a file: ${resolvedPath}` }); +} + +if (stat.size === 0) { + output({ error: `File is empty: ${resolvedPath}` }); +} + +try { + // Read entire file as Buffer and encode to base64 + // Node.js handles this efficiently for files up to ~100MB + const buffer = fs.readFileSync(resolvedPath); + const encoded = buffer.toString('base64'); + + output({ + encoded, + fileSizeBytes: stat.size, + fileName: path.basename(resolvedPath), + }); +} catch (err) { + output({ error: `Failed to read file: ${err.message}` }); +} diff --git a/plugins/power-pages/scripts/lib/add-components-to-solution.js b/plugins/power-pages/scripts/lib/add-components-to-solution.js new file mode 100644 index 000000000..2f0a453f8 --- /dev/null +++ b/plugins/power-pages/scripts/lib/add-components-to-solution.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node + +// Bulk-adds solution components via AddSolutionComponent OData action. +// Refreshes the Azure CLI token every TOKEN_REFRESH_INTERVAL calls. +// Treats "already in solution" responses as success (idempotent). +// +// Usage: node add-components-to-solution.js +// --envUrl +// --componentsFile +// --solutionUniqueName +// [--batchSize 20] +// [--token ] +// +// Components JSON file format (array): +// [ +// { +// "componentId": "guid", +// "componentType": 10373, +// "addRequired": false, // optional, default false +// "description": "Web Page: Home" // optional, for progress display +// } +// ] +// +// Output (JSON to stdout): +// { "total": N, "success": N, "skipped": N, "failed": N, "failures": [{ "componentId", "error" }] } +// +// Progress is written to stderr so stdout stays clean for JSON capture. +// Exit 0 always (caller inspects failures array); exit 1 on fatal setup errors. + +'use strict'; + +const fs = require('fs'); +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; + +const TOKEN_REFRESH_INTERVAL = 20; +const ALREADY_IN_SOLUTION_CODE = -2147160463; // Dataverse error: component already in solution + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { envUrl: null, componentsFile: null, solutionUniqueName: null, batchSize: 20, token: null }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) result.envUrl = args[++i]; + else if (args[i] === '--componentsFile' && args[i + 1]) result.componentsFile = args[++i]; + else if (args[i] === '--solutionUniqueName' && args[i + 1]) result.solutionUniqueName = args[++i]; + else if (args[i] === '--batchSize' && args[i + 1]) result.batchSize = parseInt(args[++i], 10); + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + } + + return result; +} + +function isAlreadyInSolution(responseBody) { + // Dataverse returns a specific error code when the component is already in the solution + try { + const data = JSON.parse(responseBody); + const code = data?.error?.code; + // Both string and numeric representations + return code === String(ALREADY_IN_SOLUTION_CODE) || + code === ALREADY_IN_SOLUTION_CODE || + (data?.error?.message || '').toLowerCase().includes('already in the solution'); + } catch { + return false; + } +} + +// Upfront input-shape validation. The helper expects camelCase keys +// (`componentId`, `componentType`); PascalCase (`ComponentId`, `ComponentType`) +// is a common silent-failure mode — destructuring at line ~97 returns +// `undefined` for both fields and the OData POST body carries +// `ComponentId: undefined`, producing a stream of cryptic HTTP 400 +// "missing parameters" responses with no upfront signal. This validator +// runs once over the whole array and surfaces a clear error before any +// Dataverse call. Returns null when valid; returns an Error to throw when +// invalid (caller throws to abort). +function validateComponentsShape(components) { + if (!Array.isArray(components)) { + return new Error('--componentsFile must contain a JSON array.'); + } + // Detect PascalCase fan-fail: every entry has ComponentId/ComponentType but + // none has componentId/componentType. Surface a targeted error so the user + // knows to fix the casing rather than chasing 400s. + const sample = components.slice(0, Math.min(5, components.length)); + const allPascal = sample.length > 0 && sample.every((c) => + c && typeof c === 'object' && + 'ComponentId' in c && 'ComponentType' in c && + !('componentId' in c) && !('componentType' in c) + ); + if (allPascal) { + return new Error( + '--componentsFile entries use PascalCase keys (ComponentId/ComponentType). ' + + 'This helper expects camelCase keys (componentId/componentType). ' + + 'Rename the keys in your input file or transform via `jq` / Node before invoking. ' + + 'See the header comment of add-components-to-solution.js for the expected shape.', + ); + } + // Per-entry validation: each must have componentId (string) and componentType + // (number). Surface the FIRST malformed entry with its index so the user can + // jump straight to the bad row. + for (let i = 0; i < components.length; i++) { + const c = components[i]; + if (!c || typeof c !== 'object') { + return new Error(`--componentsFile entry [${i}] is not an object: ${JSON.stringify(c)}`); + } + if (typeof c.componentId !== 'string' || c.componentId.length === 0) { + const hint = 'ComponentId' in c + ? ' (found `ComponentId` (PascalCase) instead — keys must be camelCase)' + : ''; + return new Error(`--componentsFile entry [${i}] is missing required field 'componentId' (string)${hint}.`); + } + if (typeof c.componentType !== 'number' || !Number.isFinite(c.componentType)) { + const hint = 'ComponentType' in c + ? ' (found `ComponentType` (PascalCase) instead — keys must be camelCase)' + : ''; + return new Error(`--componentsFile entry [${i}] is missing required field 'componentType' (number)${hint}.`); + } + } + return null; +} + +async function addComponentsToSolution({ envUrl, componentsFile, solutionUniqueName, batchSize, token }) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!componentsFile) throw new Error('--componentsFile is required'); + if (!solutionUniqueName) throw new Error('--solutionUniqueName is required'); + + const components = JSON.parse(fs.readFileSync(componentsFile, 'utf8')); + if (!Array.isArray(components) || components.length === 0) { + return { total: 0, success: 0, skipped: 0, failed: 0, failures: [] }; + } + + // Validate shape upfront — abort with a clear error rather than streaming + // 400s. This catches the PascalCase silent-failure case observed in the + // field (all calls returned HTTP 400 "missing parameters" with no upfront + // signal until the user inspected the input file). + const shapeError = validateComponentsShape(components); + if (shapeError) throw shapeError; + + let currentToken = token || getAuthToken(envUrl); + if (!currentToken) throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + + const total = components.length; + let success = 0; + let skipped = 0; + let failed = 0; + const failures = []; + + process.stderr.write(`Adding ${total} components to solution "${solutionUniqueName}"...\n`); + + for (let i = 0; i < components.length; i++) { + // Refresh token every TOKEN_REFRESH_INTERVAL calls + if (i > 0 && i % TOKEN_REFRESH_INTERVAL === 0) { + const refreshed = getAuthToken(envUrl); + if (refreshed) currentToken = refreshed; + process.stderr.write(` Token refreshed at component ${i + 1}/${total}\n`); + } + + const { componentId, componentType, addRequired, description } = components[i]; + + const body = JSON.stringify({ + ComponentId: componentId, + ComponentType: componentType, + SolutionUniqueName: solutionUniqueName, + AddRequiredComponents: addRequired === true, + DoNotIncludeSubcomponents: false, + IncludedComponentSettingsValues: null, + }); + + const res = await helpers.makeRequest({ + url: `${envUrl}/api/data/v9.2/AddSolutionComponent`, + method: 'POST', + headers: { + Authorization: `Bearer ${currentToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body, + timeout: 30000, + }); + + const label = description || componentId; + + if (res.error) { + failed++; + failures.push({ componentId, error: res.error }); + process.stderr.write(` ✗ FAILED ${label}: ${res.error}\n`); + continue; + } + + if (res.statusCode === 200 || res.statusCode === 204) { + success++; + if ((i + 1) % 10 === 0 || i === total - 1) { + process.stderr.write(` Added ${i + 1}/${total} components...\n`); + } + continue; + } + + // 4xx with "already in solution" is idempotent success + if (isAlreadyInSolution(res.body)) { + skipped++; + continue; + } + + // Retry once on 401 with token refresh + if (res.statusCode === 401) { + currentToken = getAuthToken(envUrl) || currentToken; + const retry = await makeRequest({ + url: `${envUrl}/api/data/v9.2/AddSolutionComponent`, + method: 'POST', + headers: { + Authorization: `Bearer ${currentToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body, + timeout: 30000, + }); + if (retry.statusCode === 200 || retry.statusCode === 204) { + success++; + continue; + } + if (isAlreadyInSolution(retry.body)) { + skipped++; + continue; + } + failed++; + failures.push({ componentId, error: `401 after token refresh: ${retry.body}` }); + process.stderr.write(` ✗ FAILED ${label}: 401 after token refresh\n`); + continue; + } + + failed++; + failures.push({ componentId, error: `HTTP ${res.statusCode}: ${res.body}` }); + process.stderr.write(` ✗ FAILED ${label}: HTTP ${res.statusCode}\n`); + } + + process.stderr.write(`Done. ${success} added, ${skipped} already present, ${failed} failed.\n`); + return { total, success, skipped, failed, failures }; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + addComponentsToSolution(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { addComponentsToSolution, validateComponentsShape }; diff --git a/plugins/power-pages/scripts/lib/alm-paths.js b/plugins/power-pages/scripts/lib/alm-paths.js new file mode 100644 index 000000000..b0546e081 --- /dev/null +++ b/plugins/power-pages/scripts/lib/alm-paths.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +// Single source of truth for ALM artifact paths. +// +// Every ALM-only state file (plan context, size estimate, split plan, +// host resolution, env-var snapshot, and the eight `.last-*.json` markers) +// lives under `/docs/alm/` rather than the project root. This +// keeps the root uncluttered for users who otherwise see ~13 dot-files in +// `git status` after any ALM run. +// +// NOT moved here (intentionally — see CLAUDE.md): +// - .solution-manifest.json (referenced by non-ALM skills too) +// - .datamodel-manifest.json (written by setup-datamodel, not ALM) +// - .alm-config.json (user-authored config dotfile) +// - .alm-deferred (project-level opt-out marker) +// - deployment-settings.json (Microsoft-standard schema, expected at root) +// - docs/alm-plan.html, docs/.alm-plan-data.json, docs/alm-migration-plan.md +// docs/pipeline-setup.md, docs/ci-cd-setup.md (already under docs/) +// +// All callers must require this module instead of inlining `path.join(root, '.last-*.json')`. + +const fs = require('fs'); +const path = require('path'); + +const ALM_DIR = 'docs/alm'; + +const FILE_NAMES = Object.freeze({ + // Plan / decision context (written during plan-alm phases) + planContext: 'alm-plan-context.json', + sizeEstimate: 'alm-size-estimate.json', + splitPlan: 'alm-split-plan.json', + hostResolution: 'alm-host-resolution.json', + envVars: 'alm-env-vars.json', + + // Skill-run markers (written when a skill completes) + lastPipeline: 'last-pipeline.json', + lastDeploy: 'last-deploy.json', + lastHostCheck: 'last-host-check.json', + lastImport: 'last-import.json', + lastActivate: 'last-activate.json', + lastTestSite: 'last-test-site.json', + lastForceLink: 'last-force-link.json', + lastEnvVars: 'last-env-vars.json', + lastExport: 'last-export.json', +}); + +/** + * Returns the absolute directory path that holds the ALM artifacts. + * Callers should pass an absolute projectRoot; relative is tolerated. + * + * @param {string} projectRoot + * @returns {string} + */ +function almDir(projectRoot) { + if (!projectRoot) throw new Error('almDir: projectRoot is required'); + return path.join(projectRoot, ALM_DIR); +} + +/** + * Returns the absolute path of an ALM artifact for a given logical key. + * Use the keys from FILE_NAMES (e.g. 'lastDeploy', 'planContext'). + * + * @param {string} projectRoot + * @param {keyof typeof FILE_NAMES} key + * @returns {string} + */ +function almPath(projectRoot, key) { + const fileName = FILE_NAMES[key]; + if (!fileName) throw new Error(`almPath: unknown key '${key}'`); + return path.join(almDir(projectRoot), fileName); +} + +/** + * Creates `/docs/alm/` if it doesn't exist. Idempotent. + * Callers should invoke this once before any write to an ALM artifact. + * + * @param {string} projectRoot + * @returns {string} The absolute ALM dir path + */ +function ensureAlmDir(projectRoot) { + const dir = almDir(projectRoot); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +module.exports = { + ALM_DIR, + FILE_NAMES, + almDir, + almPath, + ensureAlmDir, +}; diff --git a/plugins/power-pages/scripts/lib/alm-thresholds.js b/plugins/power-pages/scripts/lib/alm-thresholds.js new file mode 100644 index 000000000..3b57f030c --- /dev/null +++ b/plugins/power-pages/scripts/lib/alm-thresholds.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +// Central threshold defaults for ALM split-decision logic. +// Loaded by estimate-solution-size.js and compute-split-plan.js. +// Override in project root via `.alm-config.json`. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// NOTE: split-decision thresholds are intentionally tighter than the platform +// hard caps (95 MB / 6000 components) to leave growth headroom in each +// solution. Recommending a split at ~75 MB / 4000 components means each split +// child has ~20 MB / ~2000 components of room before the platform refuses an +// import. Bumped down on 2026-05-08 (IronItOut release-readiness pass). +const DEFAULTS = Object.freeze({ + maxSolutionSizeMB: 75, + warnComponentCount: 2500, + maxComponentCount: 4000, + hardFlagComponentCount: 10000, + maxSchemaAttrs: 15000, + maxTableCount: 20, + maxAggregateWebFilesMB: 40, + maxSingleFileMB: 2, + maxEnvVarCount: 500, + webFileDominanceRatio: 0.4, + mediaRatioTrigger: 0.6, + sizeExceedsCapUpperBound: 200, + changeFreqMinFlows: 5, + changeFreqMinSizeMB: 60, +}); + +const DEFAULT_CONFIG = Object.freeze({ + thresholds: DEFAULTS, + strategyPreference: 'auto', + strategyOverride: null, + assetAdvisory: Object.freeze({ + enabled: true, + preferredStorage: 'azure-blob', + excludePatterns: [], + }), + domains: [], + sizeEstimation: Object.freeze({ + method: 'metadata', + dryRunEnabled: false, + }), +}); + +function deepMerge(target, source) { + if (!source || typeof source !== 'object') return target; + const out = { ...target }; + for (const key of Object.keys(source)) { + const val = source[key]; + if (val && typeof val === 'object' && !Array.isArray(val)) { + out[key] = deepMerge(target[key] || {}, val); + } else if (val !== undefined) { + out[key] = val; + } + } + return out; +} + +function loadConfig(projectRoot) { + if (!projectRoot) return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULTS } }; + const configPath = path.join(projectRoot, '.alm-config.json'); + if (!fs.existsSync(configPath)) { + return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULTS } }; + } + try { + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')); + return deepMerge({ ...DEFAULT_CONFIG, thresholds: { ...DEFAULTS } }, raw); + } catch (err) { + process.stderr.write(`Warning: failed to parse .alm-config.json: ${err.message}\n`); + return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULTS } }; + } +} + +// Bounds are strict upper bounds for each tier: +// value < greenUpperExclusive -> green +// value < yellowUpperExclusive -> yellow +// otherwise -> red +// Callers that want inclusive bounds should pass `bound + epsilon`. +function classifyTier(value, greenUpperExclusive, yellowUpperExclusive) { + if (value == null) return 'unknown'; + if (value < greenUpperExclusive) return 'green'; + if (value < yellowUpperExclusive) return 'yellow'; + return 'red'; +} + +module.exports = { + DEFAULTS, + DEFAULT_CONFIG, + loadConfig, + deepMerge, + classifyTier, +}; diff --git a/plugins/power-pages/scripts/lib/bump-solution-version.js b/plugins/power-pages/scripts/lib/bump-solution-version.js new file mode 100644 index 000000000..0f542c16b --- /dev/null +++ b/plugins/power-pages/scripts/lib/bump-solution-version.js @@ -0,0 +1,287 @@ +#!/usr/bin/env node + +// Bumps the patch segment (4th segment) of a Dataverse solution version and +// PATCHes it back. Used by: +// - setup-solution Phase 4 sync mode (before AddSolutionComponent calls) +// - export-solution Phase 4 (before ExportSolutionAsync) so every produced +// zip carries a strictly-increasing version label +// +// Both callers must use this helper so the bump semantics stay consistent +// (e.g. how trailing segments are inferred when the source version has fewer +// than 4 segments, how `1.0.0.9 → 1.0.0.10` is computed). +// +// Usage: +// node bump-solution-version.js --envUrl --uniqueName [--token ] +// node bump-solution-version.js --envUrl --solutionId [--token ] +// +// Output (JSON to stdout): +// { "solutionId": "...", "uniqueName": "...", "previous": "1.0.0.2", "next": "1.0.0.3", "bumped": true } +// bumped=false would only appear if the caller passed --dryRun. +// +// Exit 0 on success, exit 1 on failure (missing args, solution not found, +// PATCH rejected). + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; +const { verifySolutionExists } = require('./verify-solution-exists'); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { envUrl: null, uniqueName: null, solutionId: null, token: null, dryRun: false, projectRoot: null }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--uniqueName' && args[i + 1]) out.uniqueName = args[++i]; + else if (args[i] === '--solutionId' && args[i + 1]) out.solutionId = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--dryRun' || args[i] === '--dry-run') out.dryRun = true; + else if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + } + return out; +} + +// Update `.solution-manifest.json` in the project root with the just-bumped +// version so consumers reading the manifest (deploy-pipeline, export-solution, +// the rendered plan) see the current Dataverse state without a stale-data +// surprise. Best-effort — a missing or unparseable manifest is a no-op rather +// than a fatal error (some callers run without a manifest, e.g. when bumping +// a one-off solution outside a Power Pages project layout). +// +// Single-solution shape (schemaVersion: 1 or absent): +// { "solution": { "uniqueName": "...", "solutionId": "...", "version": "..." } } +// Multi-solution shape (schemaVersion: 2): +// { "solutions": [{ "uniqueName": "...", "solutionId": "...", "version": "..." }, ...] } +// +// We match by solutionId (preferred) or uniqueName (fallback) to find the +// matching entry. Atomic tmp + rename to avoid mid-write corruption. +function updateManifestVersion(projectRoot, { solutionId, uniqueName, nextVersion }) { + if (!projectRoot) return { updated: false, reason: 'no-projectRoot' }; + try { + const manifestPath = path.join(projectRoot, '.solution-manifest.json'); + if (!fs.existsSync(manifestPath)) return { updated: false, reason: 'no-manifest' }; + const raw = fs.readFileSync(manifestPath, 'utf8'); + let manifest; + try { manifest = JSON.parse(raw); } catch { return { updated: false, reason: 'unparseable' }; } + + const matches = (entry) => { + if (!entry || typeof entry !== 'object') return false; + if (solutionId && entry.solutionId && String(entry.solutionId).toLowerCase() === String(solutionId).toLowerCase()) return true; + if (uniqueName && entry.uniqueName && entry.uniqueName === uniqueName) return true; + return false; + }; + + let updated = false; + // Single-solution shape + if (manifest.solution && matches(manifest.solution)) { + manifest.solution.version = nextVersion; + updated = true; + } + // Multi-solution shape + if (Array.isArray(manifest.solutions)) { + for (const sol of manifest.solutions) { + if (matches(sol)) { + sol.version = nextVersion; + updated = true; + } + } + } + + if (!updated) return { updated: false, reason: 'no-matching-entry' }; + + const tmp = manifestPath + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2)); + fs.renameSync(tmp, manifestPath); + return { updated: true, manifestPath }; + } catch (e) { + return { updated: false, reason: `write-failed: ${e.message}` }; + } +} + +/** + * Parses a Dataverse version string into a 4-segment integer tuple. + * Pads missing trailing segments with `0` so `1.0` → `[1,0,0,0]`. + * Rejects non-numeric or negative segments and > 4 segments. + * + * @param {string} version + * @returns {number[]} + */ +function parseVersionToSegments(version) { + if (typeof version !== 'string' || version.trim() === '') { + throw new Error(`parseVersionToSegments: version is required (got ${JSON.stringify(version)})`); + } + const segments = version.split('.'); + if (segments.length > 4) { + throw new Error(`parseVersionToSegments: version "${version}" has more than 4 segments`); + } + const padded = [...segments, '0', '0', '0', '0'].slice(0, 4); + return padded.map((s, i) => { + if (!/^\d+$/.test(s)) { + throw new Error(`parseVersionToSegments: segment ${i} of "${version}" is not a non-negative integer ("${s}")`); + } + return Number(s); + }); +} + +/** + * Bumps the patch (4th) segment of a Dataverse version string. + * Pads missing segments with `0` so 1.0 → 1.0.0.1 and 1 → 1.0.0.1. + * Rejects non-numeric segments, negative numbers, and empty input. + * + * @param {string} version + * @returns {string} + */ +function bumpPatchSegment(version) { + const nums = parseVersionToSegments(version); + nums[3] += 1; + return nums.join('.'); +} + +/** + * Integer-segment-wise comparison of two Dataverse version strings. + * Returns -1 when `a < b`, 0 when equal, +1 when `a > b`. + * + * Critically, this does NOT compare lexically — `compareVersions('1.0.0.9', '1.0.0.10')` + * correctly returns -1 (i.e., `1.0.0.9 < 1.0.0.10`), where JS string `'1.0.0.9' > '1.0.0.10'` + * is `true`. Callers that use `>`/`<` on raw version strings (in agent prose, in SKILL.md + * decision tables, etc.) will get the wrong branch as soon as any segment crosses 10 — + * a real-world failure mode for any project on its 10th+ deploy of the day. + * + * Used by `import-solution` Phase 3.0 version-skew advisory and any other caller that + * needs to compare zip-version vs installed-version, dev-version vs target-version, etc. + * Same segment-parse rules as `bumpPatchSegment` (pad-with-zero, integer-only, max-4-segments). + * + * @param {string} a + * @param {string} b + * @returns {-1 | 0 | 1} + */ +function compareVersions(a, b) { + const aSeg = parseVersionToSegments(a); + const bSeg = parseVersionToSegments(b); + for (let i = 0; i < 4; i++) { + if (aSeg[i] < bSeg[i]) return -1; + if (aSeg[i] > bSeg[i]) return 1; + } + return 0; +} + +async function bumpSolutionVersion({ envUrl, uniqueName, solutionId, token, dryRun = false, projectRoot = null }) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!uniqueName && !solutionId) { + throw new Error('Either --uniqueName or --solutionId is required'); + } + + const resolvedToken = token || getAuthToken(envUrl); + if (!resolvedToken) { + throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + } + + let resolvedSolutionId = solutionId; + let resolvedUniqueName = uniqueName; + let currentVersion; + + if (resolvedUniqueName) { + const existing = await verifySolutionExists({ + envUrl, + uniqueName: resolvedUniqueName, + token: resolvedToken, + }); + if (!existing.found) { + throw new Error(`Solution '${resolvedUniqueName}' not found in ${envUrl}`); + } + resolvedSolutionId = existing.solutionId; + currentVersion = existing.version; + } else { + // Look up by solutionId + const url = `${envUrl}/api/data/v9.2/solutions(${resolvedSolutionId})?$select=solutionid,uniquename,version`; + const res = await helpers.makeRequest({ + url, + headers: { + Authorization: `Bearer ${resolvedToken}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + if (res.error) throw new Error(`API request failed: ${res.error}`); + if (res.statusCode === 404) { + throw new Error(`Solution ${resolvedSolutionId} not found in ${envUrl}`); + } + if (res.statusCode !== 200) { + throw new Error(`Unexpected response (${res.statusCode}): ${res.body}`); + } + const data = JSON.parse(res.body); + resolvedUniqueName = data.uniquename; + currentVersion = data.version; + } + + const next = bumpPatchSegment(currentVersion); + + if (dryRun) { + return { + solutionId: resolvedSolutionId, + uniqueName: resolvedUniqueName, + previous: currentVersion, + next, + bumped: false, + }; + } + + const patchRes = await helpers.makeRequest({ + url: `${envUrl}/api/data/v9.2/solutions(${resolvedSolutionId})`, + method: 'PATCH', + headers: { + Authorization: `Bearer ${resolvedToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + 'If-Match': '*', + }, + body: JSON.stringify({ version: next }), + timeout: 15000, + }); + + if (patchRes.error) throw new Error(`Version PATCH failed: ${patchRes.error}`); + if (patchRes.statusCode !== 204) { + throw new Error(`Version PATCH returned ${patchRes.statusCode}: ${patchRes.body}`); + } + + // Best-effort: update .solution-manifest.json so its `version` field tracks + // the just-bumped Dataverse state. Without this, the manifest drifts behind + // every bump — validated against a real Citizens portal deploy where the + // manifest sat at 1.0.0.2 while Dataverse had reached 1.0.0.4. + const manifestUpdate = updateManifestVersion(projectRoot, { + solutionId: resolvedSolutionId, + uniqueName: resolvedUniqueName, + nextVersion: next, + }); + + return { + solutionId: resolvedSolutionId, + uniqueName: resolvedUniqueName, + previous: currentVersion, + next, + bumped: true, + manifestUpdated: manifestUpdate.updated, + manifestUpdateReason: manifestUpdate.updated ? null : manifestUpdate.reason, + }; +} + +if (require.main === module) { + const args = parseArgs(process.argv); + bumpSolutionVersion(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { bumpSolutionVersion, bumpPatchSegment, parseVersionToSegments, compareVersions, updateManifestVersion }; diff --git a/plugins/power-pages/scripts/lib/check-alm-plan.js b/plugins/power-pages/scripts/lib/check-alm-plan.js new file mode 100644 index 000000000..f599b2e94 --- /dev/null +++ b/plugins/power-pages/scripts/lib/check-alm-plan.js @@ -0,0 +1,331 @@ +#!/usr/bin/env node + +// Checks for an ALM plan and reports freshness. Used as a Phase 0 gate by ALM +// skills (setup-pipeline, deploy-pipeline, etc.) so the orchestrator +// (plan-alm) becomes the front door for ALM intents. +// +// Usage: +// node check-alm-plan.js --projectRoot +// [--envUrl ] [--token ] [--solutionId ] +// +// Output (JSON to stdout): +// { +// exists: true | false, +// deferred: true | false, // .alm-deferred marker present +// deferral: { reason, deferredBy, ... } | null, // contents of the marker +// planPath: "/docs/.alm-plan-data.json" | null, +// htmlPath: "/docs/alm-plan.html" | null, +// generatedAt: "" | null, +// lastInvocationAt: "" | null, // heartbeat refreshed by this helper +// approver: "..." | null, +// planStatus: "Draft" | "Approved" | "In Execution" | "Completed" | null, +// stale: true | false, +// staleness: { +// reason: "no-plan" | "solution-modified" | "deferred" | null, +// detail: "" | null +// }, +// inExecution: { +// status: "active" | "stale-heartbeat" | "not-running" | "no-plan", +// reason: "", +// windowMin: 60 // staleness threshold for the heartbeat +// } +// } +// +// Heartbeat semantics (the `inExecution` block): +// - "active": planStatus === "In Execution" AND a `lastInvocationAt` exists +// AND lastInvocationAt is within `windowMin` minutes of now. +// Phase 0 in calling skills should SKIP the no-plan / stale-plan gates. +// - "stale-heartbeat": planStatus === "In Execution" but the heartbeat is older than +// `windowMin` minutes. Likely a stalled or abandoned orchestration — +// treat as "not in execution" (run Phase 0 gates normally). +// - "not-running": planStatus is something other than "In Execution" (Draft, Approved, +// Completed) — Phase 0 gates run normally. +// - "no-plan": plan file doesn't exist or is unreadable — Phase 0 fires the no-plan gate. +// +// Heartbeat write: when the plan exists AND planStatus === "In Execution", this helper +// writes `lastInvocationAt: ` back to docs/.alm-plan-data.json before returning. +// This is the "any in-chain skill refreshes the heartbeat" mechanism that lets the +// orchestration survive multi-hour deploys (deploy-pipeline alone can take 60+ minutes +// per stage) without Phase 0 gates incorrectly firing in downstream skills. Pass +// `--no-heartbeat` to disable the write (e.g. for read-only audits / tests). +// +// Deferral handling: if the project root contains a .alm-deferred marker +// (created by the user when they explicitly defer ALM for a project, e.g. +// "ni-dev — handled separately"), the helper reports deferred:true. The +// Phase 0 gate in setup-pipeline / deploy-pipeline should treat this as +// "user-explicit decision, do not nag" — pass through silently to Phase 1 +// without recommending plan-alm. +// +// Exit 0 always (callers inspect the JSON). Exit 1 on argparse / fatal error. +// +// Freshness logic: +// - No plan file -> exists:false, stale:true (reason: "no-plan"). +// - Plan file unreadable -> exists:false, stale:true (reason: "no-plan"). +// - When --envUrl + --token + --solutionId are all provided, query the +// solution's modifiedon and compare against `max(GENERATED_AT, LAST_SYNC_AT)`. +// If the solution was modified after that reference point -> stale +// (reason: "solution-modified"). +// - `LAST_SYNC_AT` is written by `refresh-alm-plan-data.js` when setup-solution +// runs in sync mode (its bump-then-add operations modify `modifiedon`, so +// without this field every post-sync invocation would incorrectly report +// stale: true). Acts as a "the plan accepts changes up to this timestamp" +// marker — orchestrations that finished sync expect their next +// setup-pipeline / deploy-pipeline / etc. to see stale:false. +// - Without env credentials, the helper returns stale:false based on +// existence alone — callers that want a deeper check can run +// discover-site-components.js separately. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const helpers = require('./validation-helpers'); + +// Heartbeat window — how recent `lastInvocationAt` must be for the plan to count +// as actively executing. 60 minutes is comfortably larger than the longest single +// skill runtime (deploy-pipeline can take 60 min for a large solution import), and +// any in-chain skill's Phase 0 check refreshes the heartbeat on entry so the chain +// stays "active" as long as something is making forward progress. +const HEARTBEAT_WINDOW_MIN = 60; + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + projectRoot: process.cwd(), + envUrl: null, + token: null, + solutionId: null, + writeHeartbeat: true, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + else if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--solutionId' && args[i + 1]) out.solutionId = args[++i]; + else if (args[i] === '--no-heartbeat') out.writeHeartbeat = false; + } + return out; +} + +function emptyResult(extraStaleness) { + return { + exists: false, + deferred: false, + deferral: null, + planPath: null, + htmlPath: null, + generatedAt: null, + lastInvocationAt: null, + approver: null, + planStatus: null, + stale: true, + staleness: extraStaleness || { reason: 'no-plan', detail: 'ALM plan not found. Run /power-pages:plan-alm to create one.' }, + inExecution: { status: 'no-plan', reason: 'No ALM plan file found.', windowMin: HEARTBEAT_WINDOW_MIN }, + }; +} + +// Compute the `inExecution` block from the plan status + heartbeat timestamp. +// Caller-supplied `now` lets tests pin the clock; defaults to Date.now(). +function computeInExecution(planStatus, lastInvocationAt, now) { + if (planStatus !== 'In Execution') { + return { + status: 'not-running', + reason: `planStatus is '${planStatus || 'null'}' (not 'In Execution').`, + windowMin: HEARTBEAT_WINDOW_MIN, + }; + } + if (!lastInvocationAt) { + // In Execution but no heartbeat yet — could be the very first invocation + // since plan-alm wrote PLAN_STATUS. Treat as active so the first skill in + // the chain doesn't fire its no-plan gate; the heartbeat is written below. + return { + status: 'active', + reason: 'planStatus is In Execution and this is the first heartbeat.', + windowMin: HEARTBEAT_WINDOW_MIN, + }; + } + const heartbeatMs = Date.parse(lastInvocationAt); + if (!Number.isFinite(heartbeatMs)) { + return { + status: 'stale-heartbeat', + reason: `lastInvocationAt='${lastInvocationAt}' is not a parseable ISO timestamp.`, + windowMin: HEARTBEAT_WINDOW_MIN, + }; + } + const ageMin = (now - heartbeatMs) / 60000; + if (ageMin > HEARTBEAT_WINDOW_MIN) { + return { + status: 'stale-heartbeat', + reason: `Last in-chain invocation was ${Math.round(ageMin)}min ago (window=${HEARTBEAT_WINDOW_MIN}min). Orchestration likely stalled.`, + windowMin: HEARTBEAT_WINDOW_MIN, + }; + } + return { + status: 'active', + reason: `Last in-chain invocation was ${Math.round(ageMin)}min ago (within ${HEARTBEAT_WINDOW_MIN}min window).`, + windowMin: HEARTBEAT_WINDOW_MIN, + }; +} + +function readDeferralLocal(projectRoot) { + // Inline minimal version (matches readDeferralMarker in validation-helpers). + // Kept here so this helper stays standalone and can be invoked from any cwd. + if (!projectRoot) return null; + const markerPath = path.join(projectRoot, '.alm-deferred'); + if (!fs.existsSync(markerPath)) return null; + let raw = ''; + try { raw = fs.readFileSync(markerPath, 'utf8'); } catch {} + let info = null; + const trimmed = raw.trim(); + if (trimmed.startsWith('{')) { + try { info = JSON.parse(trimmed); } catch {} + } + return { path: markerPath, raw, info }; +} + +async function checkAlmPlan({ projectRoot, envUrl, token, solutionId, makeRequest, writeHeartbeat = true, now }) { + if (!projectRoot) throw new Error('--projectRoot is required'); + const planPath = path.join(projectRoot, 'docs', '.alm-plan-data.json'); + const htmlPath = path.join(projectRoot, 'docs', 'alm-plan.html'); + const nowMs = (typeof now === 'number') ? now : Date.now(); + + // Deferral marker check — runs first, regardless of plan presence. + // When deferred, the Phase 0 gate in calling skills should pass through + // without nagging the user about a missing plan. + const deferral = readDeferralLocal(projectRoot); + if (deferral) { + const reason = (deferral.info && (deferral.info.reason || deferral.info.detail)) + || (deferral.raw && deferral.raw.trim()) + || 'ALM explicitly deferred for this project (.alm-deferred marker present).'; + return { + exists: false, + deferred: true, + deferral: deferral.info || { reason }, + planPath: null, + htmlPath: null, + generatedAt: null, + lastInvocationAt: null, + approver: null, + planStatus: null, + stale: false, // Not stale — deferred is a deliberate state. + staleness: { reason: 'deferred', detail: 'ALM deferred: ' + reason }, + inExecution: { status: 'not-running', reason: 'ALM deferred for this project.', windowMin: HEARTBEAT_WINDOW_MIN }, + }; + } + + if (!fs.existsSync(planPath)) { + return emptyResult(); + } + + let planData; + try { + planData = JSON.parse(fs.readFileSync(planPath, 'utf8')); + } catch (e) { + return emptyResult({ + reason: 'no-plan', + detail: 'docs/.alm-plan-data.json could not be parsed as JSON: ' + e.message, + }); + } + + // Refresh the heartbeat when the plan is actively executing. We READ the + // existing value first (it drives the `inExecution.status` classification + // for this very call) and then WRITE the refreshed timestamp back — that + // way each in-chain skill's Phase 0 call both observes the prior heartbeat + // (for its own decision) AND keeps the chain alive for the next skill. + const priorLastInvocationAt = planData.LAST_INVOCATION_AT || null; + const planStatus = planData.PLAN_STATUS || null; + const inExecution = computeInExecution(planStatus, priorLastInvocationAt, nowMs); + + if (writeHeartbeat && planStatus === 'In Execution') { + try { + planData.LAST_INVOCATION_AT = new Date(nowMs).toISOString(); + // Tmp-file + rename for atomicity — a concurrent read mid-write should + // never see a half-written file. (Cross-process races on Windows are + // possible but plan-alm orchestrations are single-process.) + const tmp = planPath + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(planData, null, 2)); + fs.renameSync(tmp, planPath); + } catch { + // Best-effort — a failed heartbeat write must not break the gate check. + // Subsequent calls will see the stale heartbeat and reclassify as + // `stale-heartbeat` after windowMin elapses. + } + } + + const result = { + exists: true, + deferred: false, + deferral: null, + planPath, + htmlPath: fs.existsSync(htmlPath) ? htmlPath : null, + generatedAt: planData.GENERATED_AT || null, + lastInvocationAt: priorLastInvocationAt, + approver: planData.APPROVED_BY || null, + planStatus, + stale: false, + staleness: { reason: null, detail: null }, + inExecution, + }; + + // Optional: solution modifiedon vs plan GENERATED_AT comparison. + if (envUrl && token && solutionId) { + const url = envUrl.replace(/\/+$/, '') + + '/api/data/v9.2/solutions(' + solutionId + ')?$select=modifiedon,version'; + let res; + try { + res = await (makeRequest || helpers.makeRequest)({ + url, + method: 'GET', + headers: { + Authorization: 'Bearer ' + token, + 'OData-Version': '4.0', + 'OData-MaxVersion': '4.0', + Accept: 'application/json', + }, + timeout: 10000, + }); + } catch { + // Network errors are non-fatal — skip the check + return result; + } + + if (res && res.statusCode === 200 && res.body) { + let sol; + try { sol = JSON.parse(res.body); } catch { return result; } + const modOn = sol.modifiedon; + if (modOn && result.generatedAt) { + // Reference point = the LATER of GENERATED_AT and LAST_SYNC_AT. + // setup-solution sync mode writes LAST_SYNC_AT (via refresh-alm-plan-data.js + // refreshSetupSolution) because its bump-then-add operations bump + // `modifiedon` past GENERATED_AT — without LAST_SYNC_AT, every + // subsequent Phase 0 check would falsely flag the plan as stale. + const lastSyncAt = planData.LAST_SYNC_AT || null; + const planTime = Date.parse(result.generatedAt); + const syncTime = lastSyncAt ? Date.parse(lastSyncAt) : NaN; + const refTime = Number.isFinite(syncTime) ? Math.max(planTime, syncTime) : planTime; + const solTime = Date.parse(modOn); + if (Number.isFinite(refTime) && Number.isFinite(solTime) && solTime > refTime) { + const refLabel = Number.isFinite(syncTime) && syncTime > planTime + ? 'last sync at ' + lastSyncAt + : 'plan generated at ' + result.generatedAt; + result.stale = true; + result.staleness = { + reason: 'solution-modified', + detail: 'Solution was modified at ' + modOn + ' (after ' + refLabel + '). Components may have changed since.', + }; + } + } + } + } + + return result; +} + +if (require.main === module) { + const args = parseArgs(process.argv); + checkAlmPlan(args) + .then((r) => { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); }) + .catch((e) => { process.stderr.write('check-alm-plan: ' + e.message + '\n'); process.exit(1); }); +} + +module.exports = { checkAlmPlan, computeInExecution, HEARTBEAT_WINDOW_MIN }; diff --git a/plugins/power-pages/scripts/lib/check-env-host-binding.js b/plugins/power-pages/scripts/lib/check-env-host-binding.js new file mode 100644 index 000000000..affaffca4 --- /dev/null +++ b/plugins/power-pages/scripts/lib/check-env-host-binding.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +// Checks whether a Dataverse environment is bound to a Power Platform Pipelines host +// via the org-db setting `ProjectHostEnvironmentId`. Mirrors `useGetOrgDbOrgSetting` +// from ProjectHostProvider.tsx — same setting name the Pipelines UI reads. +// +// POST {envUrl}/api/data/v9.0/GetOrgDbOrgSetting +// Body: { "SettingName": "ProjectHostEnvironmentId" } +// +// Empty or whitespace SettingValue → not bound. +// Non-empty SettingValue → bound; returns the env GUID (BAP environment "name" / id). +// +// Usage: node check-env-host-binding.js --envUrl --token +// +// Output (JSON to stdout): +// { "bound": false, "hostEnvId": null } +// { "bound": true, "hostEnvId": "" } +// +// Exit 0 on success (including "not bound"), exit 1 on error (stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +function parseArgs(argv) { + const args = argv.slice(2); + let envUrl = null; + let token = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) token = args[++i]; + } + + return { envUrl, token }; +} + +async function checkEnvHostBinding({ envUrl, token } = {}) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!token) throw new Error('--token is required'); + + const cleanEnvUrl = envUrl.replace(/\/+$/, ''); + + const body = JSON.stringify({ SettingName: 'ProjectHostEnvironmentId' }); + + const res = await helpers.makeRequest({ + url: `${cleanEnvUrl}/api/data/v9.0/GetOrgDbOrgSetting`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-Version': '4.0', + 'OData-MaxVersion': '4.0', + }, + body, + timeout: 15000, + }); + + if (res.error) { + throw new Error(`GetOrgDbOrgSetting request failed: ${res.error}`); + } + + // 404 → action not registered or env unreachable; treat as "not bound" to mirror + // ProjectHostProvider.tsx behavior on missing setting. + if (res.statusCode === 404) { + return { bound: false, hostEnvId: null }; + } + + if (res.statusCode !== 200) { + throw new Error(`GetOrgDbOrgSetting returned unexpected status ${res.statusCode}: ${res.body}`); + } + + let data; + try { + data = JSON.parse(res.body); + } catch (e) { + throw new Error(`Failed to parse GetOrgDbOrgSetting response: ${e.message}`); + } + + const settingValue = data.SettingValue || data.settingvalue || null; + + if (!settingValue || settingValue.trim() === '') { + return { bound: false, hostEnvId: null }; + } + + return { bound: true, hostEnvId: settingValue.trim() }; +} + +if (require.main === module) { + const { envUrl, token } = parseArgs(process.argv); + + checkEnvHostBinding({ envUrl, token }) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { checkEnvHostBinding }; diff --git a/plugins/power-pages/scripts/lib/classify-site-settings.js b/plugins/power-pages/scripts/lib/classify-site-settings.js new file mode 100644 index 000000000..879453194 --- /dev/null +++ b/plugins/power-pages/scripts/lib/classify-site-settings.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +// Classifies Power Pages site settings (mspp_sitesettings rows) into buckets +// that drive setup-solution's per-setting handling and plan-alm's Phase 1 +// summary + risks list. Single source of truth for the credential-detection +// regex + the bulk auto-classify regex pair (Secret-vs-String defaults inside +// the credential bucket). +// +// Usage as a CLI (rare — most callers require this module): +// echo '[{"name":"Authentication/.../ClientSecret","value":"xxx"},...]' \ +// | node classify-site-settings.js +// +// Output: { keepAsIs:[], authNoValue:[], promoteToEnvVar:[], credentialNeedsDecision:[] } +// +// As a module (typical usage): +// const { classify, bulkClassify, autoClassifyCredential, +// CREDENTIAL_REGEX, AUTH_PREFIX_REGEX, +// CREDENTIAL_SECRET_REGEX, CREDENTIAL_STRING_REGEX } = require('./classify-site-settings'); +// +// bulkClassify([{name, value}, ...]) +// → { keepAsIs: [...], authNoValue: [...], promoteToEnvVar: [...], credentialNeedsDecision: [...] } +// +// classify({name, value}) +// → { tier: 'credential' | 'authValue' | 'authNoValue' | 'keepAsIs' } +// +// autoClassifyCredential(name) +// → { default: 'secret' | 'string', reason: string } +// +// Tier definitions (mirror plan-alm Phase 1 Step 7): +// - Tier 1 ('credential', → bucket 'credentialNeedsDecision'): +// Name matches CREDENTIAL_REGEX. setup-solution Phase 5.4.C runs the +// bulk-with-override prompt against this bucket — auto-classify by +// name (default), all-Secret, all-String, skip-all, or pick-per-credential. +// - Tier 2a ('authValue', → bucket 'promoteToEnvVar'): +// Name matches AUTH_PREFIX_REGEX (and not credential), AND value is +// non-empty. setup-solution Phase 5.4.A asks which to back with env vars. +// - Tier 2b ('authNoValue', → bucket 'authNoValue'): +// Name matches AUTH_PREFIX_REGEX (and not credential), AND value is +// null/empty. Setup-solution adds these to the solution as-is with a +// note that the user must set the value in each target env. +// - Tier 3 ('keepAsIs', → bucket 'keepAsIs'): +// Everything else. Added to the solution unchanged. +// +// Auto-classify regex (used by setup-solution Phase 5.4.C.1 to default each +// credentialNeedsDecision setting to Secret or String env var): +// - CREDENTIAL_SECRET_REGEX: name contains Secret/Password/ApiKey/AppKey +// → recommend Secret env var (Key Vault per stage) +// - CREDENTIAL_STRING_REGEX: name contains Id/ConsumerKey AND not Secret +// → recommend String env var (plain text per stage) +// - Anything not matching either → fallback to Secret (defensive — credential +// names are sensitive by default). + +'use strict'; + +const CREDENTIAL_REGEX = /ConsumerKey|ConsumerSecret|ClientId|ClientSecret|AppSecret|AppKey|ApiKey|Password/i; +const AUTH_PREFIX_REGEX = /^(Authentication\/|AzureAD\/)/i; +const CREDENTIAL_SECRET_REGEX = /Secret|Password|ApiKey|AppKey/i; +const CREDENTIAL_STRING_REGEX = /Id|ConsumerKey/i; + +function isNonEmpty(value) { + if (value == null) return false; + if (typeof value !== 'string') return Boolean(value); + return value.trim().length > 0; +} + +// Classify a single setting. Returns one of four tiers. +function classify(setting) { + if (!setting || typeof setting.name !== 'string') { + throw new Error('classify(setting): setting.name must be a string'); + } + const name = setting.name; + const value = setting.value; + + if (CREDENTIAL_REGEX.test(name)) { + return { tier: 'credential' }; + } + if (AUTH_PREFIX_REGEX.test(name)) { + return { tier: isNonEmpty(value) ? 'authValue' : 'authNoValue' }; + } + return { tier: 'keepAsIs' }; +} + +// Apply classify() to an array; return the four-bucket shape that plan-alm +// SITE_SETTINGS_DATA + setup-solution preloadedSettings expect. +function bulkClassify(settings) { + if (!Array.isArray(settings)) { + throw new Error('bulkClassify(settings): settings must be an array'); + } + const out = { + keepAsIs: [], + authNoValue: [], + promoteToEnvVar: [], + credentialNeedsDecision: [], + }; + for (const s of settings) { + if (!s || typeof s.name !== 'string') continue; + const { tier } = classify(s); + switch (tier) { + case 'credential': + out.credentialNeedsDecision.push({ name: s.name, value: s.value ?? null }); + break; + case 'authValue': + out.promoteToEnvVar.push({ name: s.name, value: s.value ?? null }); + break; + case 'authNoValue': + out.authNoValue.push({ name: s.name }); + break; + case 'keepAsIs': + default: + out.keepAsIs.push({ name: s.name }); + break; + } + } + return out; +} + +// For a given credential-style setting name, recommend Secret-typed vs +// String-typed env var. Used by setup-solution Phase 5.4.C.1 to pre-classify +// before the bulk prompt; the user can override via Option 5 (per-credential). +function autoClassifyCredential(name) { + if (typeof name !== 'string') { + throw new Error('autoClassifyCredential(name): name must be a string'); + } + if (CREDENTIAL_SECRET_REGEX.test(name)) { + return { + default: 'secret', + reason: 'Name matches Secret/Password/ApiKey/AppKey — defaults to Secret env var (Key Vault per stage).', + }; + } + if (CREDENTIAL_STRING_REGEX.test(name)) { + return { + default: 'string', + reason: 'Name matches Id/ConsumerKey (and not Secret) — defaults to String env var (plain text per stage).', + }; + } + return { + default: 'secret', + reason: 'Name did not match Secret or String pattern — defaults to Secret env var (defensive — credentials are sensitive by default).', + }; +} + +if (require.main === module) { + // CLI mode: read stdin as JSON array, write classification to stdout. + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { buf += chunk; }); + process.stdin.on('end', () => { + try { + const settings = JSON.parse(buf); + const result = bulkClassify(settings); + console.log(JSON.stringify(result)); + process.exit(0); + } catch (err) { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + }); +} + +module.exports = { + classify, + bulkClassify, + autoClassifyCredential, + CREDENTIAL_REGEX, + AUTH_PREFIX_REGEX, + CREDENTIAL_SECRET_REGEX, + CREDENTIAL_STRING_REGEX, +}; diff --git a/plugins/power-pages/scripts/lib/compute-split-plan.js b/plugins/power-pages/scripts/lib/compute-split-plan.js new file mode 100644 index 000000000..e4291b371 --- /dev/null +++ b/plugins/power-pages/scripts/lib/compute-split-plan.js @@ -0,0 +1,782 @@ +#!/usr/bin/env node + +// Runs the solution split decision tree against a size-estimate blob. +// +// Usage: +// node compute-split-plan.js --estimate [--projectRoot ] +// +// Inputs: +// estimate.json — output of estimate-solution-size.js +// .alm-config.json — optional, loaded from projectRoot if present +// +// Outputs JSON to stdout: +// { +// sizeAnalysis: { ...computed tier classifications }, +// assetAdvisory: { candidates: [...], recommendation, enabled }, +// splitStrategy: "single" | "strategy-1-layer" | "strategy-2-change-frequency" +// | "strategy-3-schema-segmentation" | "strategy-4-config-isolation", +// appliedStrategies: [...] // includes strategy-4 additive if applicable +// proposedSolutions: [ { uniqueName, displayName, order, components, sizeMB, componentCount, ... } ], +// recommendations: [ { type, message } ] +// } + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { loadConfig, classifyTier } = require('./alm-thresholds'); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { estimate: null, projectRoot: null, publisherPrefix: null, siteName: null }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--estimate' && args[i + 1]) out.estimate = args[++i]; + else if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + else if (args[i] === '--publisherPrefix' && args[i + 1]) out.publisherPrefix = args[++i]; + else if (args[i] === '--siteName' && args[i + 1]) out.siteName = args[++i]; + } + return out; +} + +// --- Tier classification ---------------------------------------------------- + +function buildSizeAnalysis(estimate, thresholds) { + return { + totalSizeMB: { + value: estimate.totalSizeMB, + tier: classifyTier(estimate.totalSizeMB, 60, thresholds.maxSolutionSizeMB), + }, + componentCount: { + value: estimate.componentCountSiteTotal, + tier: classifyTier( + estimate.componentCountSiteTotal, + thresholds.warnComponentCount, + thresholds.maxComponentCount, + ), + }, + schemaAttrCount: { + value: estimate.schemaAttrCount, + tier: classifyTier(estimate.schemaAttrCount, 5000, thresholds.maxSchemaAttrs), + }, + tableCount: { + value: estimate.tableCount, + tier: classifyTier(estimate.tableCount, 10, thresholds.maxTableCount), + }, + webFilesAggregateMB: { + value: estimate.webFilesAggregateMB, + tier: classifyTier(estimate.webFilesAggregateMB, 20, thresholds.maxAggregateWebFilesMB), + }, + envVarCount: { + value: estimate.envVarCount, + tier: classifyTier(estimate.envVarCount, 50, thresholds.maxEnvVarCount), + }, + }; +} + +// --- Gate A: Asset Advisory ------------------------------------------------- + +function computeAssetAdvisory(estimate, config) { + if (!config.assetAdvisory.enabled || config.assetAdvisory.preferredStorage === 'none') { + return { enabled: false, candidates: [], recommendation: null }; + } + + const excludePatterns = config.assetAdvisory.excludePatterns || []; + const matchesExclude = (name) => + excludePatterns.some((pat) => { + const re = globToRegex(pat); + return re.test(name); + }); + + const threshold = config.thresholds.maxSingleFileMB; + const storagePriority = config.assetAdvisory.preferredStorage === 'cdn' + ? ['cdn', 'azure-blob'] + : ['azure-blob', 'cdn']; + + const candidates = (estimate.webFilesIndividual || []) + .filter((f) => f.sizeMB >= threshold && !matchesExclude(f.name)) + .map((f) => ({ + name: f.name, + sizeMB: f.sizeMB, + currentPath: f.currentPath || f.name, + classification: classifyFile(f.name), + recommendation: storagePriority[0], + suggestedUrlFormat: storagePriority[0] === 'azure-blob' + ? `https://{account}.blob.core.windows.net/{container}/${basename(f.name)}` + : `https://{cdn-host}/${basename(f.name)}`, + rationale: buildRationale(f, storagePriority[0]), + })); + + let recommendation = null; + if ( + estimate.webFilesAggregateMB > config.thresholds.maxAggregateWebFilesMB && + estimate.mediaRatio > config.thresholds.mediaRatioTrigger + ) { + recommendation = 'externalize-media'; + } + + return { enabled: true, candidates, recommendation }; +} + +function globToRegex(pattern) { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '__DOUBLESTAR__') + .replace(/\*/g, '[^/]*') + .replace(/__DOUBLESTAR__/g, '.*') + .replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`); +} + +function basename(p) { + return String(p).split(/[\\/]/).pop(); +} + +function classifyFile(name) { + const lower = String(name).toLowerCase(); + if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(lower)) return 'image-media'; + if (/\.(woff2?|ttf|otf|eot)$/.test(lower)) return 'font'; + if (/\.(mp4|webm|mov|avi)$/.test(lower)) return 'video'; + if (/\.(pdf|docx?|xlsx?|pptx?)$/.test(lower)) return 'document'; + if (/\.js$/.test(lower)) return 'script'; + if (/\.css$/.test(lower)) return 'stylesheet'; + return 'other'; +} + +function buildRationale(file, storage) { + const cls = classifyFile(file.name); + const parts = [`${cls === 'image-media' ? 'Large image' : 'Large file'} (${file.sizeMB.toFixed(1)} MB).`]; + if (storage === 'azure-blob') { + parts.push('Private access via SAS preserves any auth requirements.'); + } else { + parts.push('Public CDN URL improves edge latency.'); + } + if (cls === 'image-media' && /\.(png|jpe?g)$/i.test(file.name)) { + parts.push('Consider WebP conversion before upload (est. 30–70% reduction).'); + } + return parts.join(' '); +} + +// --- Gate B: Strategy selection -------------------------------------------- + +function selectStrategy(estimate, config) { + const t = config.thresholds; + + if (config.strategyOverride) { + return { primary: config.strategyOverride, additive: false }; + } + + const hasSchemaHeavy = + estimate.schemaAttrCount > t.maxSchemaAttrs || estimate.tableCount > t.maxTableCount; + const isWebHeavy = + estimate.totalSizeMB > t.maxSolutionSizeMB && + estimate.totalSizeMB <= t.sizeExceedsCapUpperBound && + estimate.webFilesAggregateMB > t.webFileDominanceRatio * estimate.totalSizeMB; + // Hard-flag counts still route to Strategy 2 — a split is the best option we have. The + // hard-flag warning is added separately in buildRecommendations. + const isComponentHeavy = + estimate.componentCountSiteTotal > t.maxComponentCount || + (estimate.cloudFlowCount > t.changeFreqMinFlows && estimate.totalSizeMB > t.changeFreqMinSizeMB); + const hasManyEnvVars = estimate.envVarCount > t.maxEnvVarCount; + + let primary = 'single'; + if (hasSchemaHeavy) primary = 'strategy-3-schema-segmentation'; + else if (isWebHeavy) primary = 'strategy-1-layer'; + else if (isComponentHeavy) primary = 'strategy-2-change-frequency'; + else if (hasManyEnvVars) primary = 'strategy-4-config-isolation'; + + const additive = hasManyEnvVars && primary !== 'single' && primary !== 'strategy-4-config-isolation'; + + return { primary, additive }; +} + +// --- Partitioning ----------------------------------------------------------- + +function partitionBySingle(estimate, meta) { + return [ + { + uniqueName: meta.baseName, + displayName: meta.siteName, + order: 1, + componentTypes: ['All'], + description: + 'All components packaged in a single managed solution. Estimated size is within recommended thresholds.', + sizeMB: estimate.totalSizeMB, + componentCount: estimate.componentCountSiteTotal, + components: [], + }, + ]; +} + +function partitionByLayer(estimate, meta) { + const coreSize = Math.max(estimate.totalSizeMB - estimate.webFilesAggregateMB, 0); + const coreCount = Math.max(estimate.componentCountSiteTotal - (estimate.webFileCount || 0), 0); + return [ + { + uniqueName: `${meta.baseName}_Core`, + displayName: `${meta.siteName} — Core`, + order: 1, + componentTypes: ['Table', 'Site Setting', 'Web Role', 'Table Permission', 'Cloud Flow', 'Environment Variable', 'Bot Component'], + description: + 'Tables, security, integrations, site settings, environment variables. Low change frequency.', + sizeMB: round(coreSize), + componentCount: coreCount, + components: [], + }, + { + uniqueName: `${meta.baseName}_WebAssets`, + displayName: `${meta.siteName} — Web Assets`, + order: 2, + componentTypes: ['Web File'], + description: + 'Web files (media, content uploads tracked in powerpagecomponent). High change frequency — deploy independently.', + sizeMB: round(estimate.webFilesAggregateMB), + componentCount: estimate.webFileCount || 0, + components: [], + }, + ]; +} + +function partitionByChangeFrequency(estimate, meta) { + const foundationCount = Math.ceil(estimate.componentCountSiteTotal * 0.15); + const integrationCount = estimate.cloudFlowCount + estimate.botCount; + const configCount = Math.ceil(estimate.componentCountSiteTotal * 0.1); + const contentCount = Math.max( + estimate.componentCountSiteTotal - foundationCount - integrationCount - configCount, + 0, + ); + + // Derive size from count shares so size and componentCount stay self-consistent. + // Avoids the earlier bug where fixed 25/20/10/45% size fractions didn't track the + // count allocation and confused users reading the HTML. + const totalCounts = foundationCount + integrationCount + configCount + contentCount; + const sizePerCount = totalCounts > 0 ? estimate.totalSizeMB / totalCounts : 0; + const sizeFor = (n) => round(n * sizePerCount); + + return [ + { + uniqueName: `${meta.baseName}_Foundation`, + displayName: `${meta.siteName} — Foundation`, + order: 1, + componentTypes: ['Table', 'Environment Variable', 'Web Role', 'Table Permission'], + description: 'Schema and security — rarely changes.', + sizeMB: sizeFor(foundationCount), + componentCount: foundationCount, + components: [], + }, + { + uniqueName: `${meta.baseName}_Integration`, + displayName: `${meta.siteName} — Integration`, + order: 2, + componentTypes: ['Cloud Flow', 'Bot Component', 'Connection Reference'], + description: 'Cloud flows, bots, connection references.', + sizeMB: sizeFor(integrationCount), + componentCount: integrationCount, + components: [], + }, + { + uniqueName: `${meta.baseName}_Config`, + displayName: `${meta.siteName} — Config`, + order: 3, + componentTypes: ['Site Setting', 'Site Marker', 'Publishing State'], + description: 'Site settings, markers, publishing states.', + sizeMB: sizeFor(configCount), + componentCount: configCount, + components: [], + }, + { + uniqueName: `${meta.baseName}_Content`, + displayName: `${meta.siteName} — Content`, + order: 4, + componentTypes: ['Web Page', 'Web Template', 'Page Template', 'Content Snippet', 'Web File'], + description: 'Pages, templates, content snippets, web files. Highest change frequency.', + sizeMB: sizeFor(contentCount), + componentCount: contentCount, + components: [], + }, + ]; +} + +function partitionBySchema(estimate, meta, config) { + const explicitDomains = Array.isArray(config.domains) && config.domains.length > 0 + ? config.domains + : deriveDomainsFromPrefix(estimate); + + // Derive domain vs site size shares from the estimator's breakdown when available, + // falling back to a 50/50 heuristic only if breakdown is absent. + const tablesSizeMB = estimate.breakdown && Number.isFinite(Number(estimate.breakdown.tables)) + ? Number(estimate.breakdown.tables) + : estimate.totalSizeMB * 0.5; + const siteSizeMB = Math.max(estimate.totalSizeMB - tablesSizeMB, 0); + const domainCount = Math.max(explicitDomains.length, 1); + const sizePerDomain = tablesSizeMB / domainCount; + const breakdownAvailable = estimate.breakdown && Number.isFinite(Number(estimate.breakdown.tables)); + const domainDescSuffix = breakdownAvailable ? '' : ' (rough estimate — breakdown unavailable)'; + + const domainSolutions = explicitDomains.map((dom, i) => ({ + uniqueName: `${meta.baseName}_${sanitizeDomainName(dom.name)}`, + displayName: `${meta.siteName} — ${dom.name}`, + order: i + 1, + componentTypes: ['Table'], + description: `Schema domain: ${dom.name}. Tables: ${(dom.tableLogicalNames || []).join(', ') || '(derived)'}${domainDescSuffix}`, + sizeMB: round(sizePerDomain), + componentCount: Math.ceil( + (estimate.schemaAttrCount || 0) / domainCount, + ), + components: [], + tableLogicalNames: dom.tableLogicalNames || [], + })); + + const siteOrder = domainSolutions.length + 1; + const siteSolution = { + uniqueName: `${meta.baseName}_Site`, + displayName: `${meta.siteName} — Site`, + order: siteOrder, + componentTypes: ['Web Role', 'Table Permission', 'Site Setting', 'Cloud Flow', 'Web File', 'Web Page', 'Web Template'], + description: + 'Site artifacts — web roles, permissions, settings, flows, pages. Imports after all domain solutions.', + sizeMB: round(siteSizeMB), + componentCount: Math.max( + estimate.componentCountSiteTotal - domainSolutions.reduce((s, d) => s + d.componentCount, 0), + 0, + ), + components: [], + }; + + return [...domainSolutions, siteSolution]; +} + +function deriveDomainsFromPrefix(estimate) { + const tables = estimate.tables || []; + if (tables.length === 0) return [{ name: 'All', tableLogicalNames: [] }]; + + const groups = new Map(); + for (const t of tables) { + const name = (t.logicalName || t).toString(); + const afterPrefix = name.includes('_') ? name.split('_').slice(1).join('_') : name; + const stem = afterPrefix.split(/[_]/)[0] || 'misc'; + const key = stem.charAt(0).toUpperCase() + stem.slice(1); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(name); + } + + return Array.from(groups.entries()).map(([name, tableLogicalNames]) => ({ + name, + tableLogicalNames, + })); +} + +function applyConfigIsolation(solutions, estimate, meta) { + return [ + { + uniqueName: `${meta.baseName}_EnvVars`, + displayName: `${meta.siteName} — Environment Variables`, + order: 1, + componentTypes: ['Environment Variable'], + description: + 'Environment variable definitions isolated so value updates do not force a full solution re-import.', + sizeMB: round(Math.max(estimate.envVarCount * 0.001, 0.3)), + componentCount: estimate.envVarCount, + components: [], + }, + ...solutions.map((s) => ({ ...s, order: s.order + 1 })), + ]; +} + +function sanitizeDomainName(name) { + return String(name).replace(/[^A-Za-z0-9]/g, ''); +} + +/** + * Appends an empty "Future Growth" solution to a multi-solution split so there + * is an obvious default target for any new components the team adds later. Without + * this buffer, every new server-logic / flow / env var tends to end up crammed + * into the wrong layer solution and forces a re-plan. + * + * Rules: + * - Only appended when the split already has ≥ 2 solutions (splits, not `single`). + * - Sized at 0 MB / 0 components — it's a reserved slot, not a prediction. + * - Marked with `isFutureBuffer: true` so renderers and setup-solution can + * style/describe it distinctly from partition-owned solutions. + * - Tagged with `componentTypes: ['Any']` to signal "open to any type." + */ +function appendFutureBuffer(solutions, meta) { + if (!Array.isArray(solutions) || solutions.length < 2) return solutions; + const nextOrder = (solutions[solutions.length - 1].order || solutions.length) + 1; + return [ + ...solutions, + { + uniqueName: `${meta.baseName}_Future`, + displayName: `${meta.siteName} — Future Growth`, + order: nextOrder, + componentTypes: ['Any'], + description: + 'Reserved empty solution. New components added to the site after this plan (server logic, cloud flows, env vars, pages, etc.) should be added here by default so the partition-owned solutions above stay stable. Rename it or fold it into an existing solution if site growth plateaus.', + sizeMB: 0, + componentCount: 0, + components: [], + isFutureBuffer: true, + }, + ]; +} + +function round(n) { + return Math.round((Number(n) || 0) * 10) / 10; +} + +// --- Per-split validation --------------------------------------------------- + +function validateSplits(solutions, thresholds) { + const warnings = []; + for (const sol of solutions) { + // Future buffer is a reserved 0/0 slot — never warn on it. + if (sol.isFutureBuffer === true) continue; + if (sol.sizeMB > thresholds.maxSolutionSizeMB) { + warnings.push({ + type: 'warning', + message: `Solution ${sol.uniqueName} is still estimated at ${sol.sizeMB.toFixed( + 1, + )} MB — consider tree-shaking, WebP conversion, or removing sourcemaps.`, + }); + } + if (sol.componentCount > thresholds.maxComponentCount) { + warnings.push({ + type: 'warning', + message: `Solution ${sol.uniqueName} is still estimated at ${sol.componentCount.toLocaleString()} components — exceeds the recommended ${thresholds.maxComponentCount.toLocaleString()}-component cap. Consider sub-partitioning or archiving unused components.`, + }); + } + } + return warnings; +} + +// --- Sub-partition oversized children of the primary partition -------------- +// +// Runs ONCE after the primary partition is built. For each child that still +// busts either the size or component-count cap, replace it with 3 (or 4) +// change-frequency-shaped sub-solutions. Single-component-type slices +// (WebAssets, EnvVars, future buffer) are left alone — sub-partitioning them +// makes no sense; validateSplits will flag them instead. The intent is to +// catch the Strategy-1 (Layer) case where Core inherits flows/bots/tables and +// stays over cap even after Web Assets are peeled off. + +function subPartitionIfOverCap(solutions, estimate, thresholds, opts = {}) { + if (!Array.isArray(solutions)) return { solutions, modified: false }; + let modified = false; + const out = []; + let nextOrder = 1; + + for (const sol of solutions) { + const overSize = sol.sizeMB > thresholds.maxSolutionSizeMB; + const overCount = sol.componentCount > thresholds.maxComponentCount; + + // Single-type slices and the future buffer are never sub-partitioned. + // We pattern-match on componentTypes rather than uniqueName so renamed + // splits (`Test_WebAssets` vs `MySite_WebAssets`) still hit the guard. + const types = Array.isArray(sol.componentTypes) ? sol.componentTypes : []; + const isSingleTypeSlice = + sol.isFutureBuffer === true || + (types.length === 1 && + (types[0] === 'Web File' || + types[0] === 'Environment Variable' || + types[0] === 'Any')); + + if ((overSize || overCount) && !isSingleTypeSlice) { + modified = true; + const children = buildSubChildren(sol, estimate, thresholds, nextOrder, opts); + for (const c of children) { + out.push(c); + nextOrder++; + } + } else { + out.push({ ...sol, order: nextOrder }); + nextOrder++; + } + } + + return { solutions: out, modified }; +} + +function buildSubChildren(parent, estimate, thresholds, startOrder, opts = {}) { + const parentCount = parent.componentCount || 0; + const parentSize = parent.sizeMB || 0; + const flows = (estimate.cloudFlowCount || 0) + (estimate.botCount || 0); + // Always emit `_Integration` when the parent carries flows or bots — even + // below `changeFreqMinFlows`. Without it, downstream setup-solution Phase 5 + // routing can't place Cloud Flow / Bot Component records (they fall to the + // Default solution). The `changeFreqMinFlows` threshold governs whether + // change-frequency is the right TOP-LEVEL split strategy; once we've + // committed to sub-partitioning, coverage takes priority over heuristic. + const parentTypes = Array.isArray(parent.componentTypes) ? parent.componentTypes : []; + const parentHasIntegrationTypes = + parentTypes.includes('Cloud Flow') || + parentTypes.includes('Bot Component') || + parentTypes.includes('Connection Reference'); + const includeIntegration = flows > 0 || parentHasIntegrationTypes; + + // additiveStrategy4 flips the _Config componentTypes: when the top-level + // _EnvVars solution will own env vars, we drop 'Environment Variable' from + // _Config to prevent double-claiming. When it WON'T (envVarCount under cap + // OR additive not firing), _Config absorbs env vars so they have an owner. + const additiveStrategy4 = opts.additiveStrategy4 === true; + + // Proportional shares — foundation 20%, config 15%, content 65% when no + // integration slice; otherwise foundation 20%, integration = actual flow+bot + // count, config 15%, content = remainder. Sizes derive from the count share + // so size and count stay self-consistent (same pattern as + // partitionByChangeFrequency). + const foundation = Math.max(1, Math.round(parentCount * 0.20)); + const config = Math.max(1, Math.round(parentCount * 0.15)); + const integration = includeIntegration ? Math.max(flows, 1) : 0; + const content = Math.max(parentCount - foundation - config - integration, 0); + + const totalAlloc = foundation + config + integration + content; + const sizePerCount = totalAlloc > 0 ? parentSize / totalAlloc : 0; + const sizeFor = (n) => round(n * sizePerCount); + + const children = [ + { + uniqueName: `${parent.uniqueName}_Foundation`, + displayName: `${parent.displayName} — Foundation`, + order: startOrder, + componentTypes: ['Table', 'Web Role', 'Table Permission'], + description: `Sub-partition of ${parent.uniqueName}: schema and security. Created automatically because the parent exceeded the recommended caps.`, + sizeMB: sizeFor(foundation), + componentCount: foundation, + components: [], + }, + ]; + + let order = startOrder + 1; + if (includeIntegration) { + children.push({ + uniqueName: `${parent.uniqueName}_Integration`, + displayName: `${parent.displayName} — Integration`, + order: order++, + componentTypes: ['Cloud Flow', 'Bot Component', 'Connection Reference'], + description: `Sub-partition of ${parent.uniqueName}: cloud flows, bots, connection references.`, + sizeMB: sizeFor(integration), + componentCount: integration, + components: [], + }); + } + + // Env vars: included in _Config UNLESS the top-level additive _EnvVars + // solution will own them. Double-claim would break setup-solution Phase 5 + // routing (one component type owned by two solutions). The original + // partitionByChangeFrequency Config block doesn't list env vars because + // change-frequency mode never combines with additive Strategy 4 (selectStrategy + // sets additive=true only for strategies 1/3); here additive can fire so we + // condition on it explicitly. + const configTypes = ['Site Setting', 'Site Marker', 'Publishing State']; + if (!additiveStrategy4) configTypes.push('Environment Variable'); + children.push({ + uniqueName: `${parent.uniqueName}_Config`, + displayName: `${parent.displayName} — Config`, + order: order++, + componentTypes: configTypes, + description: additiveStrategy4 + ? `Sub-partition of ${parent.uniqueName}: site settings, markers, publishing states.` + : `Sub-partition of ${parent.uniqueName}: site settings, markers, publishing states, env vars.`, + sizeMB: sizeFor(config), + componentCount: config, + components: [], + }); + + children.push({ + uniqueName: `${parent.uniqueName}_Content`, + displayName: `${parent.displayName} — Content`, + order: order++, + componentTypes: ['Web Page', 'Web Template', 'Page Template', 'Content Snippet'], + description: `Sub-partition of ${parent.uniqueName}: pages, templates, content snippets.`, + sizeMB: sizeFor(content), + componentCount: content, + components: [], + }); + + return children; +} + +// --- Recommendations -------------------------------------------------------- + +function buildRecommendations(estimate, strategy, config) { + const recs = []; + const t = config.thresholds; + + if (strategy.primary === 'strategy-3-schema-segmentation') { + recs.push({ + type: 'warning', + message: + 'Schema-heavy solution detected. Expected import time per stage: 2–10+ hours. Test in staging first and do not schedule production deploys during peak hours.', + }); + } + if (estimate.componentCountSiteTotal > t.hardFlagComponentCount) { + recs.push({ + type: 'error', + message: + `Component count (${estimate.componentCountSiteTotal.toLocaleString()}) exceeds the hard-flag threshold of ${t.hardFlagComponentCount.toLocaleString()}. Splitting alone is unlikely to be sufficient — archive historical data, remove unused components, or consolidate before proceeding.`, + }); + } + if (estimate.totalSizeMB > t.maxSolutionSizeMB) { + recs.push({ + type: 'info', + message: `Estimated total size (${estimate.totalSizeMB.toFixed( + 1, + )} MB) exceeds the recommended ${t.maxSolutionSizeMB} MB cap.`, + }); + } + if (estimate.webFilesAggregateMB > t.maxAggregateWebFilesMB) { + recs.push({ + type: 'info', + message: `Web files total ${estimate.webFilesAggregateMB.toFixed( + 1, + )} MB. Externalize large media to Azure Blob before import for reliability.`, + }); + } + if (estimate.envVarCount > t.maxEnvVarCount) { + recs.push({ + type: 'info', + message: `${estimate.envVarCount} environment variables — isolate into a dedicated EnvVars solution so value updates don't require a full re-import.`, + }); + } + return recs; +} + +// --- Main ------------------------------------------------------------------- + +function computeSplitPlan({ estimate, config, meta }) { + const sizeAnalysis = buildSizeAnalysis(estimate, config.thresholds); + const assetAdvisory = computeAssetAdvisory(estimate, config); + const strategy = selectStrategy(estimate, config); + + let proposedSolutions; + switch (strategy.primary) { + case 'strategy-3-schema-segmentation': + proposedSolutions = partitionBySchema(estimate, meta, config); + break; + case 'strategy-1-layer': + proposedSolutions = partitionByLayer(estimate, meta); + break; + case 'strategy-2-change-frequency': + proposedSolutions = partitionByChangeFrequency(estimate, meta); + break; + case 'strategy-4-config-isolation': + proposedSolutions = applyConfigIsolation(partitionBySingle(estimate, meta), estimate, meta); + break; + case 'single': + default: + proposedSolutions = partitionBySingle(estimate, meta); + break; + } + + // Sub-partition any oversized children of the primary partition. Only runs + // for strategy-1-layer (Core often inherits flows/bots/tables and stays + // over cap after Web Assets are peeled off). Skip for: + // - single (no partition to sub-divide) + // - strategy-2-change-frequency (children are already a change-frequency + // slice — re-splitting by change-frequency would produce the same shape) + // - strategy-3-schema-segmentation (children are domain-scoped Table + // slices; change-frequency re-splitting doesn't fit the domain model) + // - strategy-4-config-isolation (primary): the all-in-one child is by + // definition not partitioned; sub-partitioning it would silently + // promote it to a multi-solution split the user didn't opt into. + // Runs ONCE; if children still bust caps validateSplits will surface a + // manual-archival warning. + let compositeSubPartitioned = false; + if (strategy.primary === 'strategy-1-layer') { + // Pass `additiveStrategy4` so `_Config` knows whether the top-level + // `_EnvVars` solution will claim env vars (in which case _Config drops + // 'Environment Variable' from its componentTypes to avoid double-claim). + const sub = subPartitionIfOverCap(proposedSolutions, estimate, config.thresholds, { + additiveStrategy4: strategy.additive === true, + }); + if (sub.modified) { + proposedSolutions = sub.solutions; + compositeSubPartitioned = true; + } + } + + if (strategy.additive) { + proposedSolutions = applyConfigIsolation(proposedSolutions, estimate, meta); + } + + // Add a reserved `{Prefix}_Future` solution when the site is actually being + // split so new components have a defined home. Single-solution plans skip + // this — there's no split to protect. + proposedSolutions = appendFutureBuffer(proposedSolutions, meta); + + const splitWarnings = validateSplits(proposedSolutions, config.thresholds); + // Surface estimator-side truncation warnings as `recommendations[]` entries + // so the rendered plan shows them inline. These get the `error` type because + // a truncated input is more dangerous than a normal split-decision warning + // (the user can't tell anything's wrong from the recommendation alone). + const truncationRecs = (Array.isArray(estimate.truncationWarnings) ? estimate.truncationWarnings : []) + .map((message) => ({ + type: 'error', + message: `Estimator may be truncated: ${message} The split recommendation below could be wrong — investigate before approving.`, + })); + const recommendations = truncationRecs + .concat(buildRecommendations(estimate, strategy, config)) + .concat(splitWarnings); + + const appliedStrategies = [strategy.primary]; + if (strategy.additive) appliedStrategies.push('strategy-4-config-isolation'); + if (compositeSubPartitioned) appliedStrategies.push('composite-sub-partition'); + + return { + sizeAnalysis, + assetAdvisory, + splitStrategy: strategy.primary, + appliedStrategies, + compositeSubPartitioned, + proposedSolutions, + recommendations, + // Pass the canary fields through so plan-alm can surface them to the user + // and gate the "keep as single anyway" override on whether they're set. + truncationSuspected: !!estimate.truncationSuspected, + truncationWarnings: Array.isArray(estimate.truncationWarnings) ? estimate.truncationWarnings : [], + }; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + if (!args.estimate) { + process.stderr.write('Usage: compute-split-plan.js --estimate [--projectRoot ] [--publisherPrefix

] [--siteName ]\n'); + process.exit(1); + } + try { + const estimate = JSON.parse(fs.readFileSync(args.estimate, 'utf8')); + const config = loadConfig(args.projectRoot); + const baseName = args.siteName + ? args.siteName.replace(/[^A-Za-z0-9]/g, '') + : estimate.siteName + ? estimate.siteName.replace(/[^A-Za-z0-9]/g, '') + : 'Site'; + const meta = { + baseName, + siteName: args.siteName || estimate.siteName || 'Site', + publisherPrefix: args.publisherPrefix || estimate.publisherPrefix || '', + }; + const result = computeSplitPlan({ estimate, config, meta }); + process.stdout.write(JSON.stringify(result, null, 2)); + process.exit(0); + } catch (err) { + process.stderr.write(`compute-split-plan failed: ${err.message}\n`); + process.exit(1); + } +} + +module.exports = { + computeSplitPlan, + buildSizeAnalysis, + computeAssetAdvisory, + selectStrategy, + partitionBySingle, + partitionByLayer, + partitionByChangeFrequency, + partitionBySchema, + applyConfigIsolation, + appendFutureBuffer, + validateSplits, + buildRecommendations, + subPartitionIfOverCap, +}; diff --git a/plugins/power-pages/scripts/lib/create-deployment-environment.js b/plugins/power-pages/scripts/lib/create-deployment-environment.js new file mode 100644 index 000000000..8f3d2b156 --- /dev/null +++ b/plugins/power-pages/scripts/lib/create-deployment-environment.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +// Creates a deploymentenvironments record in the Pipelines host environment +// and polls until validationstatus reports Succeeded (or Failed). +// +// Uses the **unprefixed** field schema (canonical per power-pipeline-skill- +// reference.md and verified against msdyn_AppDeploymentAnchor v9.1.2026034 +// on 2026-04-28). The earlier msdyn_-prefixed shape we used was from an +// early-preview HAR; it is rejected ("Invalid property 'msdyn_name'") by the +// shipped Pipelines schema. +// +// Required body fields: +// name — display name of the deploymentenvironment record +// environmentid — BAP env GUID (NOT the env URL) +// environmenttype — 200000000 (Development) or 200000001 (Target) +// +// Usage: +// node create-deployment-environment.js \ +// --hostEnvUrl \ +// --token \ +// --name <"Display Name"> \ +// --bapEnvId \ +// --environmentType <200000000|200000001> \ +// [--environmentUrl ] (optional — only used in the output marker) +// +// Output (JSON to stdout): +// { "deploymentEnvironmentId": "...", +// "name": "...", +// "bapEnvId": "...", +// "environmentUrl": "...", +// "environmentType": 200000000, +// "validationStatus": 200000001 } +// +// Exit 0 on success, exit 1 on error (stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +const ENV_TYPE_DEV = 200000000; +const ENV_TYPE_TARGET = 200000001; + +const VALIDATION_STATUS_PENDING = 200000000; +const VALIDATION_STATUS_SUCCEEDED = 200000001; +const VALIDATION_STATUS_FAILED = 200000002; + +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 20; + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + hostEnvUrl: null, + token: null, + name: null, + bapEnvId: null, + environmentUrl: null, + environmentType: null, + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--hostEnvUrl' && args[i + 1]) out.hostEnvUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--name' && args[i + 1]) out.name = args[++i]; + else if (args[i] === '--bapEnvId' && args[i + 1]) out.bapEnvId = args[++i]; + else if (args[i] === '--environmentUrl' && args[i + 1]) out.environmentUrl = args[++i]; + else if (args[i] === '--environmentType' && args[i + 1]) out.environmentType = Number(args[++i]); + } + + return out; +} + +function extractGuidFromODataEntityId(header) { + if (!header) return null; + const match = header.match(/\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)/i); + return match ? match[1] : null; +} + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function findExistingByBapId({ cleanHostEnvUrl, token, bapEnvId }) { + const filter = encodeURIComponent(`environmentid eq '${bapEnvId}'`); + const url = `${cleanHostEnvUrl}/api/data/v9.1/deploymentenvironments?$filter=${filter}&$select=deploymentenvironmentid,name,environmentid,environmenttype,validationstatus`; + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + timeout: 15000, + }); + if (res.statusCode === 200 && res.body) { + try { + const data = JSON.parse(res.body); + if (Array.isArray(data.value) && data.value.length > 0) return data.value[0]; + } catch {} + } + return null; +} + +async function createDeploymentEnvironment({ + hostEnvUrl, + token, + name, + bapEnvId, + environmentUrl = null, + environmentType, +} = {}) { + if (!hostEnvUrl) throw new Error('--hostEnvUrl is required'); + if (!token) throw new Error('--token is required'); + if (!name) throw new Error('--name is required'); + if (!bapEnvId) throw new Error('--bapEnvId is required (the BAP environment GUID, e.g., 9f930375-571f-ee07-8b8f-d4a9e317c292)'); + if (environmentType !== ENV_TYPE_DEV && environmentType !== ENV_TYPE_TARGET) { + throw new Error(`--environmentType must be ${ENV_TYPE_DEV} (Development) or ${ENV_TYPE_TARGET} (Target)`); + } + + const cleanHostEnvUrl = hostEnvUrl.replace(/\/+$/, ''); + + // Idempotency: if a deploymentenvironment record already exists for this + // BAP env, return it instead of creating a duplicate. + const existing = await findExistingByBapId({ cleanHostEnvUrl, token, bapEnvId }); + if (existing) { + return { + deploymentEnvironmentId: existing.deploymentenvironmentid, + name: existing.name, + bapEnvId: existing.environmentid, + environmentUrl, + environmentType: existing.environmenttype, + validationStatus: existing.validationstatus, + reused: true, + }; + } + + const body = JSON.stringify({ + name, + environmentid: bapEnvId, + environmenttype: environmentType, + }); + + const createRes = await helpers.makeRequest({ + url: `${cleanHostEnvUrl}/api/data/v9.1/deploymentenvironments`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: 'return=representation', + }, + body, + includeHeaders: true, + timeout: 30000, + }); + + if (createRes.error) { + throw new Error(`Create deploymentenvironments failed: ${createRes.error}`); + } + + if (createRes.statusCode < 200 || createRes.statusCode >= 300) { + throw new Error( + `Create deploymentenvironments returned status ${createRes.statusCode}: ${createRes.body.slice(0, 500)}`, + ); + } + + const entityIdHeader = createRes.headers && (createRes.headers['odata-entityid'] || createRes.headers['OData-EntityId']); + let deploymentEnvironmentId = extractGuidFromODataEntityId(entityIdHeader); + if (!deploymentEnvironmentId && createRes.body) { + try { deploymentEnvironmentId = JSON.parse(createRes.body).deploymentenvironmentid || null; } catch {} + } + if (!deploymentEnvironmentId) { + throw new Error(`Could not extract deploymentEnvironmentId. headers=${JSON.stringify(createRes.headers || {}).slice(0, 200)}, body=${(createRes.body || '').slice(0, 200)}`); + } + + // Poll validationstatus until terminal + let attempts = 0; + let validationStatus = null; + while (attempts < MAX_POLL_ATTEMPTS) { + await sleep(POLL_INTERVAL_MS); + attempts++; + + const pollRes = await helpers.makeRequest({ + url: `${cleanHostEnvUrl}/api/data/v9.1/deploymentenvironments(${deploymentEnvironmentId})?$select=validationstatus,errormessage,name`, + method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + timeout: 15000, + }); + + if (pollRes.error) throw new Error(`Poll deploymentenvironment failed: ${pollRes.error}`); + if (pollRes.statusCode !== 200) { + throw new Error(`Poll deploymentenvironment returned ${pollRes.statusCode}: ${pollRes.body}`); + } + let pollData; + try { pollData = JSON.parse(pollRes.body); } catch (e) { + throw new Error(`Failed to parse poll response: ${e.message}`); + } + + validationStatus = pollData.validationstatus; + if (validationStatus === VALIDATION_STATUS_SUCCEEDED) { + return { + deploymentEnvironmentId, + name, + bapEnvId, + environmentUrl, + environmentType, + validationStatus, + reused: false, + }; + } + if (validationStatus === VALIDATION_STATUS_FAILED) { + const err = pollData.errormessage || 'No error details available'; + throw new Error(`Deployment environment validation failed: ${err}`); + } + // Pending — keep polling + } + + throw new Error( + `Deployment environment validation did not complete after ${MAX_POLL_ATTEMPTS} attempts. Last status: ${validationStatus}`, + ); +} + +if (require.main === module) { + const args = parseArgs(process.argv); + createDeploymentEnvironment(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + createDeploymentEnvironment, + ENV_TYPE_DEV, + ENV_TYPE_TARGET, + VALIDATION_STATUS_SUCCEEDED, + VALIDATION_STATUS_FAILED, +}; diff --git a/plugins/power-pages/scripts/lib/create-deployment-pipeline.js b/plugins/power-pages/scripts/lib/create-deployment-pipeline.js new file mode 100644 index 000000000..236e1558d --- /dev/null +++ b/plugins/power-pages/scripts/lib/create-deployment-pipeline.js @@ -0,0 +1,388 @@ +#!/usr/bin/env node + +// Creates a deploymentpipelines record, associates the source environment via +// the deploymentpipeline_deploymentenvironment M2M $ref, and creates +// deploymentstages records for each target environment. +// +// Uses the **unprefixed** field schema (canonical per +// power-pipeline-skill-reference.md and verified against +// msdyn_AppDeploymentAnchor v9.1.2026034 on 2026-04-28). The earlier +// msdyn_-prefixed format we used was from an early-preview HAR; the shipped +// schema rejects msdyn_-prefixed properties. +// +// Field mapping summary (vs the old msdyn_ format): +// msdyn_name → name +// msdyn_description → description +// msdyn_sourceenvironment (PUT) → deploymentpipeline_deploymentenvironment (POST $ref) +// msdyn_pipelineid@odata.bind → deploymentpipelineid@odata.bind +// msdyn_targetenvironmentid@odata → targetdeploymentenvironmentid@odata.bind +// msdyn_order → (omit; unprefixed schema doesn't use this field) +// +// Usage: +// node create-deployment-pipeline.js \ +// --hostEnvUrl \ +// --token \ +// --pipelineName \ +// --description \ +// --sourceDeploymentEnvironmentId \ +// --stagesJson '[{"name":"Deploy to Staging","targetDeploymentEnvironmentId":"..."}]' +// +// Output (JSON to stdout): +// { +// "pipelineId": "...", +// "pipelineName": "...", +// "stages": [{ "stageId": "...", "name": "...", "targetDeploymentEnvironmentId": "..." }] +// } +// +// Exit 0 on success, exit 1 on error (stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + hostEnvUrl: null, + token: null, + pipelineName: null, + description: '', + sourceDeploymentEnvironmentId: null, + stagesJson: null, + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--hostEnvUrl' && args[i + 1]) out.hostEnvUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--pipelineName' && args[i + 1]) out.pipelineName = args[++i]; + else if (args[i] === '--description' && args[i + 1]) out.description = args[++i]; + else if (args[i] === '--sourceDeploymentEnvironmentId' && args[i + 1]) out.sourceDeploymentEnvironmentId = args[++i]; + else if (args[i] === '--stagesJson' && args[i + 1]) out.stagesJson = args[++i]; + } + + return out; +} + +function extractGuid(header) { + if (!header) return null; + const m = header.match(/\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)/i); + return m ? m[1] : null; +} + +async function findExistingPipelineByName({ cleanHost, token, name }) { + const filter = encodeURIComponent(`name eq '${name.replace(/'/g, "''")}'`); + const url = `${cleanHost}/api/data/v9.1/deploymentpipelines?$filter=${filter}&$select=deploymentpipelineid,name`; + const res = await helpers.makeRequest({ + url, method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + timeout: 15000, + }); + if (res.statusCode === 200 && res.body) { + try { + const data = JSON.parse(res.body); + if (Array.isArray(data.value) && data.value.length > 0) return data.value[0].deploymentpipelineid; + } catch {} + } + return null; +} + +// Finds an existing pipeline that has the SAME source-env and target-env wiring +// as the request. This is the deduplication-by-wiring path: if the user creates +// a pipeline named "Pipeline A" with source X and target Y, and then asks for +// "Pipeline B" with the same source X and same target Y, this function returns +// Pipeline A's id (with its stage ids per target) so the caller can offer reuse +// instead of creating a duplicate. +// +// Match criteria: +// - source env: requestedSourceDeId is in the pipeline's +// deploymentpipeline_deploymentenvironment M2M +// - target envs: every requestedTargetDeId has a matching stage on the pipeline +// Returns the FIRST matching pipeline with its stage layout. Null if no match. +async function findExistingPipelineByWiring({ cleanHost, token, requestedSourceDeId, requestedTargetDeIds }) { + const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' }; + + // List all pipelines on the host (typically a small number). + const listRes = await helpers.makeRequest({ + url: `${cleanHost}/api/data/v9.1/deploymentpipelines?$select=deploymentpipelineid,name`, + method: 'GET', + headers, + timeout: 15000, + }); + if (listRes.statusCode !== 200) return null; + + let pipelines; + try { pipelines = JSON.parse(listRes.body).value || []; } catch { return null; } + if (pipelines.length === 0) return null; + + const targetSet = new Set(requestedTargetDeIds.map(String)); + + for (const p of pipelines) { + const pid = p.deploymentpipelineid; + + // Check source binding + const srcRes = await helpers.makeRequest({ + url: `${cleanHost}/api/data/v9.1/deploymentpipelines(${pid})/deploymentpipeline_deploymentenvironment?$select=deploymentenvironmentid`, + method: 'GET', headers, timeout: 15000, + }); + if (srcRes.statusCode !== 200) continue; + let srcs; + try { srcs = JSON.parse(srcRes.body).value || []; } catch { continue; } + const srcMatch = srcs.some((s) => String(s.deploymentenvironmentid) === String(requestedSourceDeId)); + if (!srcMatch) continue; + + // Check stage targets + const stageFilter = encodeURIComponent(`_deploymentpipelineid_value eq ${pid}`); + const stageRes = await helpers.makeRequest({ + url: `${cleanHost}/api/data/v9.1/deploymentstages?$filter=${stageFilter}&$select=deploymentstageid,name,_targetdeploymentenvironmentid_value`, + method: 'GET', headers, timeout: 15000, + }); + if (stageRes.statusCode !== 200) continue; + let stages; + try { stages = JSON.parse(stageRes.body).value || []; } catch { continue; } + const stageTargetSet = new Set(stages.map((s) => String(s._targetdeploymentenvironmentid_value))); + + // All requested targets must have a matching stage + const allTargetsCovered = [...targetSet].every((t) => stageTargetSet.has(t)); + if (!allTargetsCovered) continue; + + // Match. Return the pipeline + the stages for each requested target. + const stagesByTarget = {}; + stages.forEach((s) => { stagesByTarget[String(s._targetdeploymentenvironmentid_value)] = { stageId: s.deploymentstageid, name: s.name }; }); + return { + pipelineId: pid, + pipelineName: p.name, + stagesByTarget, + }; + } + + return null; +} + +async function findExistingStage({ cleanHost, token, pipelineId, stageName }) { + const filter = encodeURIComponent(`_deploymentpipelineid_value eq ${pipelineId} and name eq '${stageName.replace(/'/g, "''")}'`); + const url = `${cleanHost}/api/data/v9.1/deploymentstages?$filter=${filter}&$select=deploymentstageid,name`; + const res = await helpers.makeRequest({ + url, method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + timeout: 15000, + }); + if (res.statusCode === 200 && res.body) { + try { + const data = JSON.parse(res.body); + if (Array.isArray(data.value) && data.value.length > 0) return data.value[0].deploymentstageid; + } catch {} + } + return null; +} + +async function isSourceAlreadyAssociated({ cleanHost, token, pipelineId, sourceDeId }) { + const url = `${cleanHost}/api/data/v9.1/deploymentpipelines(${pipelineId})/deploymentpipeline_deploymentenvironment?$select=deploymentenvironmentid`; + const res = await helpers.makeRequest({ + url, method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + timeout: 15000, + }); + if (res.statusCode === 200 && res.body) { + try { + const data = JSON.parse(res.body); + return Array.isArray(data.value) && data.value.some((e) => e.deploymentenvironmentid === sourceDeId); + } catch {} + } + return false; +} + +async function createDeploymentPipeline({ + hostEnvUrl, + token, + pipelineName, + description = '', + sourceDeploymentEnvironmentId, + stagesJson, +} = {}) { + if (!hostEnvUrl) throw new Error('--hostEnvUrl is required'); + if (!token) throw new Error('--token is required'); + if (!pipelineName) throw new Error('--pipelineName is required'); + if (!sourceDeploymentEnvironmentId) throw new Error('--sourceDeploymentEnvironmentId is required'); + if (!stagesJson) throw new Error('--stagesJson is required'); + + const cleanHost = hostEnvUrl.replace(/\/+$/, ''); + + let stages; + try { stages = typeof stagesJson === 'string' ? JSON.parse(stagesJson) : stagesJson; } + catch (e) { throw new Error(`Failed to parse --stagesJson: ${e.message}`); } + if (!Array.isArray(stages)) throw new Error('--stagesJson must be a JSON array'); + + // Step 1: Pipeline (idempotent — reuse existing on name match OR wiring match) + let pipelineId = await findExistingPipelineByName({ cleanHost, token, name: pipelineName }); + let reusedByWiring = null; + + if (!pipelineId) { + // Try to find a pipeline with matching source + targets, regardless of name. + // This catches "the user created a pipeline before and is asking again with + // a different name" — we shouldn't create duplicate pipelines pointing at + // the same Stage-1→Stage-2 wiring. + const requestedTargetDeIds = stages.map((s) => s.targetDeploymentEnvironmentId); + reusedByWiring = await findExistingPipelineByWiring({ + cleanHost, token, + requestedSourceDeId: sourceDeploymentEnvironmentId, + requestedTargetDeIds, + }); + if (reusedByWiring) { + pipelineId = reusedByWiring.pipelineId; + } + } + + if (!pipelineId) { + const pipelineBody = JSON.stringify({ + name: pipelineName, + description: description || `Pipeline for ${pipelineName}`, + statuscode: 1, + statecode: 0, + enableaideploymentnotes: false, + }); + + const pipelineRes = await helpers.makeRequest({ + url: `${cleanHost}/api/data/v9.1/deploymentpipelines`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: 'return=representation', + }, + body: pipelineBody, + includeHeaders: true, + timeout: 30000, + }); + + if (pipelineRes.error) throw new Error(`Create deploymentpipelines failed: ${pipelineRes.error}`); + if (pipelineRes.statusCode < 200 || pipelineRes.statusCode >= 300) { + throw new Error(`Create deploymentpipelines returned ${pipelineRes.statusCode}: ${pipelineRes.body.slice(0, 500)}`); + } + pipelineId = extractGuid(pipelineRes.headers && (pipelineRes.headers['odata-entityid'] || pipelineRes.headers['OData-EntityId'])); + if (!pipelineId && pipelineRes.body) { + try { pipelineId = JSON.parse(pipelineRes.body).deploymentpipelineid || null; } catch {} + } + if (!pipelineId) throw new Error(`Could not extract pipelineId from response`); + } + + // Step 2: Associate source via deploymentpipeline_deploymentenvironment M2M $ref + const alreadyAssociated = await isSourceAlreadyAssociated({ cleanHost, token, pipelineId, sourceDeId: sourceDeploymentEnvironmentId }); + if (!alreadyAssociated) { + const refBody = JSON.stringify({ + '@odata.context': `${cleanHost}/api/data/v9.1/$metadata#$ref`, + '@odata.id': `deploymentenvironments(${sourceDeploymentEnvironmentId})`, + }); + + const refRes = await helpers.makeRequest({ + url: `${cleanHost}/api/data/v9.1/deploymentpipelines(${pipelineId})/deploymentpipeline_deploymentenvironment/$ref`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body: refBody, + timeout: 15000, + }); + + if (refRes.error) throw new Error(`Associate source via $ref failed: ${refRes.error}`); + if (refRes.statusCode < 200 || refRes.statusCode >= 300) { + throw new Error(`Associate source via $ref returned ${refRes.statusCode}: ${refRes.body.slice(0, 500)}`); + } + } + + // Step 3: Create stages (idempotent — reuse existing by pipelineId+name OR + // pipelineId+targetDeploymentEnvironmentId. The latter handles the case + // where the pipeline was reused by wiring: existing stage names may differ + // from the requested names but the target env IDs match exactly.) + const createdStages = []; + for (const stage of stages) { + const { name: stageName, targetDeploymentEnvironmentId, description: stageDesc } = stage; + if (!stageName) throw new Error('Each stage must have a "name" field'); + if (!targetDeploymentEnvironmentId) throw new Error('Each stage must have a "targetDeploymentEnvironmentId" field'); + + // Prefer the by-wiring lookup if we reused the pipeline by wiring + let stageId = null; + let reusedStageOriginalName = null; + if (reusedByWiring && reusedByWiring.stagesByTarget[String(targetDeploymentEnvironmentId)]) { + const m = reusedByWiring.stagesByTarget[String(targetDeploymentEnvironmentId)]; + stageId = m.stageId; + reusedStageOriginalName = m.name; + } + if (!stageId) stageId = await findExistingStage({ cleanHost, token, pipelineId, stageName }); + + if (!stageId) { + const stageBody = JSON.stringify({ + name: stageName, + description: stageDesc || `Deploy to ${stageName}`, + 'deploymentpipelineid@odata.bind': `/deploymentpipelines(${pipelineId})`, + 'targetdeploymentenvironmentid@odata.bind': `/deploymentenvironments(${targetDeploymentEnvironmentId})`, + }); + + const stageRes = await helpers.makeRequest({ + url: `${cleanHost}/api/data/v9.1/deploymentstages`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: 'return=representation', + }, + body: stageBody, + includeHeaders: true, + timeout: 30000, + }); + + if (stageRes.error) throw new Error(`Create deploymentstage "${stageName}" failed: ${stageRes.error}`); + if (stageRes.statusCode < 200 || stageRes.statusCode >= 300) { + throw new Error(`Create deploymentstage "${stageName}" returned ${stageRes.statusCode}: ${stageRes.body.slice(0, 500)}`); + } + + stageId = extractGuid(stageRes.headers && (stageRes.headers['odata-entityid'] || stageRes.headers['OData-EntityId'])); + if (!stageId && stageRes.body) { + try { stageId = JSON.parse(stageRes.body).deploymentstageid || null; } catch {} + } + if (!stageId) throw new Error(`Could not extract stageId for stage "${stageName}"`); + } + + createdStages.push({ + stageId, + name: reusedStageOriginalName || stageName, + targetDeploymentEnvironmentId, + reusedFromWiringMatch: !!reusedStageOriginalName, + }); + } + + return { + pipelineId, + pipelineName: reusedByWiring ? reusedByWiring.pipelineName : pipelineName, + stages: createdStages, + reused: !!reusedByWiring, + reusedByWiring: reusedByWiring ? { + originalName: reusedByWiring.pipelineName, + requestedName: pipelineName, + } : null, + }; +} + +if (require.main === module) { + const args = parseArgs(process.argv); + createDeploymentPipeline(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { createDeploymentPipeline }; diff --git a/plugins/power-pages/scripts/lib/create-env-var-definition.js b/plugins/power-pages/scripts/lib/create-env-var-definition.js new file mode 100644 index 000000000..a8b072d82 --- /dev/null +++ b/plugins/power-pages/scripts/lib/create-env-var-definition.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +// Creates an environmentvariabledefinition in Dataverse. +// Handles duplicate creation (409) by returning the existing definition's ID. +// +// Usage: node create-env-var-definition.js +// --envUrl --token +// --schemaName --displayName +// [--type 100000005] (100000000=String, 100000001=Number, 100000002=Boolean, +// 100000003=JSON, 100000004=DataSource, 100000005=Secret) +// [--defaultValue ""] +// +// Output (JSON to stdout): +// { "definitionId": "...", "schemaName": "...", "created": true|false } +// +// Exit 0 on success, exit 1 on failure. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; + +// Canonical Dataverse option-set values for environmentvariabledefinition.type. +// Verified against live tenant data — a Secret env var created via the Power +// Platform UI is stored as 100000005, not 100000003. Earlier revisions had +// Secret/JSON swapped (Secret=100000003, JSON=100000005), which caused this +// helper to silently create JSON-typed records when callers asked for Secret, +// and the discovery helper to render Secret records as "Json" in the plan. +// Keep in sync with discover-env-var-definitions.js TYPE_LABELS. +const ENV_VAR_TYPES = { + String: 100000000, + Number: 100000001, + Boolean: 100000002, + JSON: 100000003, + DataSource: 100000004, + Secret: 100000005, +}; + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { + envUrl: null, + token: null, + schemaName: null, + displayName: null, + type: ENV_VAR_TYPES.Secret, + defaultValue: '', + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) result.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--schemaName' && args[i + 1]) result.schemaName = args[++i]; + else if (args[i] === '--displayName' && args[i + 1]) result.displayName = args[++i]; + else if (args[i] === '--type' && args[i + 1]) result.type = parseInt(args[++i], 10); + else if (args[i] === '--defaultValue' && args[i + 1] !== undefined) result.defaultValue = args[++i]; + } + + return result; +} + +function extractGuidFromEntityId(entityIdHeader) { + const match = entityIdHeader && entityIdHeader.match( + /\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)/i + ); + return match ? match[1] : null; +} + +async function findExistingDefinition(envUrl, token, schemaName) { + const url = new URL(`${envUrl}/api/data/v9.2/environmentvariabledefinitions`); + url.searchParams.set('$filter', `schemaname eq '${schemaName}'`); + url.searchParams.set('$select', 'environmentvariabledefinitionid,schemaname'); + + const res = await helpers.makeRequest({ + url: url.toString(), + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + + if (res.statusCode !== 200) return null; + const data = JSON.parse(res.body); + if (!data.value || data.value.length === 0) return null; + return data.value[0].environmentvariabledefinitionid; +} + +async function createEnvVarDefinition({ envUrl, token, schemaName, displayName, type, defaultValue }) { + if (!envUrl || !schemaName || !displayName) { + throw new Error('--envUrl, --schemaName, and --displayName are required'); + } + + const resolvedToken = token || getAuthToken(envUrl); + if (!resolvedToken) throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + + const resolvedType = type !== undefined ? type : ENV_VAR_TYPES.Secret; + + const body = JSON.stringify({ + schemaname: schemaName, + displayname: displayName, + type: resolvedType, + defaultvalue: defaultValue || '', + }); + + const res = await helpers.makeRequest({ + url: `${envUrl}/api/data/v9.2/environmentvariabledefinitions`, + method: 'POST', + headers: { + Authorization: `Bearer ${resolvedToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body, + includeHeaders: true, + timeout: 30000, + }); + + if (res.error) throw new Error(`API request failed: ${res.error}`); + + if (res.statusCode === 204 || res.statusCode === 201) { + const entityIdHeader = res.headers && (res.headers['odata-entityid'] || res.headers['OData-EntityId']); + const definitionId = extractGuidFromEntityId(entityIdHeader); + if (!definitionId) { + throw new Error(`Created but could not extract definitionId from OData-EntityId: ${entityIdHeader}`); + } + return { definitionId, schemaName, created: true }; + } + + // 409: already exists — re-query + if (res.statusCode === 409) { + const existingId = await findExistingDefinition(envUrl, resolvedToken, schemaName); + if (existingId) return { definitionId: existingId, schemaName, created: false }; + throw new Error(`409 on creation but existing definition not found for schemaName: ${schemaName}`); + } + + throw new Error(`Unexpected response (${res.statusCode}): ${res.body}`); +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + createEnvVarDefinition(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { createEnvVarDefinition, ENV_VAR_TYPES }; diff --git a/plugins/power-pages/scripts/lib/create-solution.js b/plugins/power-pages/scripts/lib/create-solution.js new file mode 100644 index 000000000..49654f030 --- /dev/null +++ b/plugins/power-pages/scripts/lib/create-solution.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +// Creates a Dataverse solution (and optionally a publisher) via OData API. +// Handles "already exists" (409) by returning the existing record's ID. +// +// Usage: node create-solution.js --envUrl --token +// --uniqueName --friendlyName --version +// --publisherId [--description ] +// +// Output (JSON to stdout): +// { "solutionId": "...", "uniqueName": "...", "created": true|false } +// created=false means a solution with this uniqueName already existed. +// +// Exit 0 on success, exit 1 on failure. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { envUrl: null, token: null, uniqueName: null, friendlyName: null, version: null, publisherId: null, description: '' }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) result.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--uniqueName' && args[i + 1]) result.uniqueName = args[++i]; + else if (args[i] === '--friendlyName' && args[i + 1]) result.friendlyName = args[++i]; + else if (args[i] === '--version' && args[i + 1]) result.version = args[++i]; + else if (args[i] === '--publisherId' && args[i + 1]) result.publisherId = args[++i]; + else if (args[i] === '--description' && args[i + 1]) result.description = args[++i]; + } + + return result; +} + +function extractGuidFromEntityId(entityIdHeader) { + // OData-EntityId: https://org.crm.dynamics.com/api/data/v9.2/solutions(guid) + const match = entityIdHeader && entityIdHeader.match( + /\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)/i + ); + return match ? match[1] : null; +} + +async function createSolution({ envUrl, token, uniqueName, friendlyName, version, publisherId, description }) { + if (!envUrl || !uniqueName || !friendlyName || !version || !publisherId) { + throw new Error('--envUrl, --uniqueName, --friendlyName, --version, --publisherId are all required'); + } + + const resolvedToken = token || getAuthToken(envUrl); + if (!resolvedToken) { + throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + } + + const commonHeaders = { + Authorization: `Bearer ${resolvedToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }; + + const body = JSON.stringify({ + uniquename: uniqueName, + friendlyname: friendlyName, + version: version, + description: description || `Power Pages site components for ${friendlyName}`, + 'publisherid@odata.bind': `/publishers(${publisherId})`, + }); + + const res = await helpers.makeRequest({ + url: `${envUrl}/api/data/v9.2/solutions`, + method: 'POST', + headers: { ...commonHeaders, Prefer: 'return=representation' }, + body, + includeHeaders: true, + timeout: 30000, + }); + + if (res.error) throw new Error(`API request failed: ${res.error}`); + + // 204: created (no body), extract ID from OData-EntityId header + if (res.statusCode === 204 || res.statusCode === 201) { + const entityIdHeader = res.headers && (res.headers['odata-entityid'] || res.headers['OData-EntityId']); + const solutionId = extractGuidFromEntityId(entityIdHeader); + if (!solutionId) { + throw new Error(`Solution created but could not extract solutionId from OData-EntityId header: ${entityIdHeader}`); + } + return { solutionId, uniqueName, created: true }; + } + + // 409: duplicate — re-query to get existing ID + if (res.statusCode === 409) { + const { verifySolutionExists } = require('./verify-solution-exists'); + const existing = await verifySolutionExists({ envUrl, uniqueName, token: resolvedToken }); + if (existing.found) { + return { solutionId: existing.solutionId, uniqueName, created: false }; + } + throw new Error(`Solution creation returned 409 but existing solution not found on re-query.`); + } + + throw new Error(`Unexpected response (${res.statusCode}): ${res.body}`); +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + createSolution(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { createSolution }; diff --git a/plugins/power-pages/scripts/lib/create-solutions-batch.js b/plugins/power-pages/scripts/lib/create-solutions-batch.js new file mode 100644 index 000000000..225f33e04 --- /dev/null +++ b/plugins/power-pages/scripts/lib/create-solutions-batch.js @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +// Parallel bulk creation of Dataverse solutions sharing one publisher. +// +// Used by setup-solution Phase 4 Step 2 in MULTI_SOLUTION_MODE when the split +// plan (docs/alm/alm-split-plan.json) recommends N solutions. Each call is +// independent (distinct uniqueName, shared publisherId, no inter-solution +// dependency), so the batch fans out via Promise.allSettled — typical 5-6 +// solution splits complete in ~2s vs ~10s for a serial agent loop. +// +// Usage: node create-solutions-batch.js +// --envUrl +// --publisherId +// --solutionsFile +// [--token ] +// +// solutionsFile format (JSON array — `isFutureBuffer: true` entries are skipped): +// [ +// { "uniqueName": "MySite_Core", "friendlyName": "MySite — Core", +// "version": "1.0.0.0", "description": "..." }, +// { "uniqueName": "MySite_WebAssets", "friendlyName": "MySite — Web Assets", +// "version": "1.0.0.0", "description": "..." }, +// { "uniqueName": "MySite_Future", "isFutureBuffer": true, // skipped +// "friendlyName": "...", "version": "1.0.0.0", "description": "..." } +// ] +// +// Output (JSON to stdout): +// { +// "total": N, // entries in the input file +// "success": N, // created or already-existed +// "skipped": N, // isFutureBuffer: true +// "failed": N, // 409 with no existing record, or thrown error +// "results": [ +// { "uniqueName": "...", "solutionId": "...", "created": true|false }, +// { "uniqueName": "...", "skipped": true, "reason": "futureBuffer" }, +// { "uniqueName": "...", "error": "..." } +// ] +// } +// +// Progress goes to stderr so stdout stays clean for JSON capture. +// Exit 0 always (caller inspects failed/results); exit 1 on fatal setup errors. + +'use strict'; + +const fs = require('fs'); +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; +const { createSolution } = require('./create-solution'); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { envUrl: null, publisherId: null, solutionsFile: null, token: null }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--publisherId' && args[i + 1]) out.publisherId = args[++i]; + else if (args[i] === '--solutionsFile' && args[i + 1]) out.solutionsFile = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + } + return out; +} + +// Best-effort detection of a 401 surfaced by createSolution. The helper +// surfaces 401 via either "Authentication failed" (its dedicated branch) or +// "Unexpected response (401)" (the generic fall-through, used when the 401 +// path isn't taken). Match both so the retry path fires regardless of which +// shape Dataverse / the helper chose. +function isAuthFailure(err) { + const msg = err && err.message ? String(err.message) : ''; + return /Authentication failed/i.test(msg) || /Unexpected response \(401\)/.test(msg); +} + +async function createSolutionsBatch({ + envUrl, publisherId, solutionsFile, token, specs, + // Test seam: a function that returns a fresh token; defaults to + // getAuthToken(envUrl). Lets tests verify the 401-retry path without + // shelling out to Azure CLI. + refreshToken, +}) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!publisherId) throw new Error('--publisherId is required'); + + // Specs can be passed inline (preferred for tests / programmatic use) or + // loaded from a JSON file (preferred for CLI invocation from a skill). + let entries = specs; + if (!entries) { + if (!solutionsFile) throw new Error('--solutionsFile is required when specs not provided inline'); + entries = JSON.parse(fs.readFileSync(solutionsFile, 'utf8')); + } + if (!Array.isArray(entries)) throw new Error('solutions input must be a JSON array'); + + // Initial token. Solutions fan out in parallel and complete in ~2s — no + // need to re-acquire mid-batch in the happy path. When the cached token + // was near expiry on entry and we get a 401, the retry path below + // refreshes once and replays the failed entry. + const refresh = refreshToken || (() => getAuthToken(envUrl)); + let resolvedToken = token || refresh(); + if (!resolvedToken) throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + + const results = new Array(entries.length); + let success = 0; + let skipped = 0; + let failed = 0; + let tokenRefreshed = false; + // Refresh-token coordination across racing 401s: at most ONE refresh per + // batch invocation (refreshing twice would mean the second token is also + // immediately expired — environment problem, not retry-fixable). Subsequent + // 401s after one refresh are treated as terminal failures. + let refreshPromise = null; + + async function attemptCreate(spec, idx) { + if (process.env.DEBUG) { + process.stderr.write(`create-solutions-batch: starting ${spec.uniqueName}\n`); + } + try { + const res = await createSolution({ + envUrl, + token: resolvedToken, + uniqueName: spec.uniqueName, + friendlyName: spec.friendlyName, + version: spec.version, + publisherId, + description: spec.description || '', + }); + results[idx] = { uniqueName: res.uniqueName, solutionId: res.solutionId, created: res.created }; + success += 1; + return; + } catch (err) { + if (isAuthFailure(err) && !tokenRefreshed) { + // Coordinated single-refresh: first racer to hit 401 starts the + // refresh; all other racers await the same promise so we never + // double-refresh. + if (!refreshPromise) { + refreshPromise = Promise.resolve().then(() => { + const fresh = refresh(); + if (!fresh) throw new Error('Token refresh failed after 401. Run `az login` again.'); + resolvedToken = fresh; + tokenRefreshed = true; + return fresh; + }); + } + try { + await refreshPromise; + } catch (refreshErr) { + results[idx] = { uniqueName: spec.uniqueName, error: refreshErr.message }; + failed += 1; + return; + } + // Retry once with the fresh token. + try { + const res = await createSolution({ + envUrl, + token: resolvedToken, + uniqueName: spec.uniqueName, + friendlyName: spec.friendlyName, + version: spec.version, + publisherId, + description: spec.description || '', + }); + results[idx] = { uniqueName: res.uniqueName, solutionId: res.solutionId, created: res.created }; + success += 1; + return; + } catch (retryErr) { + results[idx] = { + uniqueName: spec.uniqueName, + error: `Retry after token refresh failed: ${retryErr.message || String(retryErr)}`, + }; + failed += 1; + return; + } + } + results[idx] = { uniqueName: spec.uniqueName, error: err && err.message ? err.message : String(err) }; + failed += 1; + } + } + + // Build the per-entry promise list. Future-buffer entries resolve + // immediately with a `skipped` result and never hit Dataverse. + const tasks = entries.map((spec, idx) => { + if (spec && spec.isFutureBuffer === true) { + results[idx] = { uniqueName: spec.uniqueName, skipped: true, reason: 'futureBuffer' }; + skipped += 1; + return Promise.resolve(); + } + if (!spec || !spec.uniqueName || !spec.friendlyName || !spec.version) { + results[idx] = { + uniqueName: spec && spec.uniqueName, + error: 'spec missing required fields (uniqueName, friendlyName, version)', + }; + failed += 1; + return Promise.resolve(); + } + return attemptCreate(spec, idx); + }); + + await Promise.all(tasks); + + return { total: entries.length, success, skipped, failed, results, tokenRefreshed }; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + createSolutionsBatch(args) + .then((result) => { + process.stdout.write(JSON.stringify(result, null, 2)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { createSolutionsBatch }; diff --git a/plugins/power-pages/scripts/lib/create-stage-run.js b/plugins/power-pages/scripts/lib/create-stage-run.js new file mode 100644 index 000000000..5795a5b10 --- /dev/null +++ b/plugins/power-pages/scripts/lib/create-stage-run.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +// Creates a deploymentstageruns record to initiate a Power Platform Pipeline deployment stage. +// +// Usage: node create-stage-run.js --hostEnvUrl --token +// --stageId --sourceDeploymentEnvironmentId +// --solutionId --artifactName +// [--pipelineId ] (optional — not used in body, kept for logging) +// +// Output (JSON to stdout): +// { "stageRunId": "..." } +// +// Exit 0 on success, exit 1 on failure (error on stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { + hostEnvUrl: null, + token: null, + pipelineId: null, + stageId: null, + sourceDeploymentEnvironmentId: null, + solutionId: null, + artifactName: null, + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--hostEnvUrl' && args[i + 1]) result.hostEnvUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--pipelineId' && args[i + 1]) result.pipelineId = args[++i]; + else if (args[i] === '--stageId' && args[i + 1]) result.stageId = args[++i]; + else if (args[i] === '--sourceDeploymentEnvironmentId' && args[i + 1]) result.sourceDeploymentEnvironmentId = args[++i]; + else if (args[i] === '--solutionId' && args[i + 1]) result.solutionId = args[++i]; + else if (args[i] === '--artifactName' && args[i + 1]) result.artifactName = args[++i]; + } + + return result; +} + +async function createStageRun({ hostEnvUrl, token, pipelineId, stageId, sourceDeploymentEnvironmentId, solutionId, artifactName }) { + if (!hostEnvUrl || !token || !stageId || !sourceDeploymentEnvironmentId || !solutionId || !artifactName) { + throw new Error( + 'Missing required arguments: --hostEnvUrl, --token, --stageId, --sourceDeploymentEnvironmentId, --solutionId, --artifactName' + ); + } + + // Uses v9.0 API with HAR-confirmed field names (no msdyn_ prefix). + // $select=deploymentstagerunid ensures the created ID is returned in the response body (201) + // or can be read from OData-EntityId header (204). + const url = `${hostEnvUrl.replace(/\/+$/, '')}/api/data/v9.0/deploymentstageruns?$select=deploymentstagerunid`; + const body = JSON.stringify({ + 'deploymentstageid@odata.bind': `/deploymentstages(${stageId})`, + 'devdeploymentenvironment@odata.bind': `/deploymentenvironments(${sourceDeploymentEnvironmentId})`, + 'artifactname': artifactName, // solution unique name for artifact lookup + 'solutionid': solutionId, // solution GUID for pipeline artifact resolution + 'makerainoteslanguagecode': 'en-US', + }); + + const res = await helpers.makeRequest({ + url, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body, + includeHeaders: true, + timeout: 30000, + }); + + if (res.error) { + throw new Error(`Request failed: ${res.error}`); + } + + if (res.statusCode === 400 || res.statusCode === 409) { + throw new Error(`Stage run creation failed (${res.statusCode}): ${res.body}`); + } + + if (res.statusCode !== 201 && res.statusCode !== 204) { + throw new Error(`Unexpected status ${res.statusCode}: ${res.body}`); + } + + let stageRunId = null; + + // 201: JSON body contains the record ID + if (res.statusCode === 201 && res.body) { + try { + const data = JSON.parse(res.body); + stageRunId = data.deploymentstagerunid || data.msdyn_deploymentstagerunid || null; + } catch { + // fall through to OData-EntityId header + } + } + + // 204 (or 201 without body ID): extract from OData-EntityId header + if (!stageRunId) { + const entityId = (res.headers && (res.headers['odata-entityid'] || res.headers['OData-EntityId'])) || ''; + const m = entityId.match(/deploymentstageruns\(([^)]+)\)/); + stageRunId = m ? m[1] : null; + } + + if (!stageRunId) { + throw new Error('Could not extract stageRunId from response body or OData-EntityId header'); + } + + return { stageRunId }; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + createStageRun(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { createStageRun }; diff --git a/plugins/power-pages/scripts/lib/detect-project-context.js b/plugins/power-pages/scripts/lib/detect-project-context.js new file mode 100644 index 000000000..2d74329fb --- /dev/null +++ b/plugins/power-pages/scripts/lib/detect-project-context.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +// Reads Power Pages project context files from the project root. +// Locates powerpages.config.json, .solution-manifest.json, and .datamodel-manifest.json. +// +// Usage: node detect-project-context.js [--projectRoot ] +// +// Options: +// --projectRoot Use this path as project root (default: auto-discover from cwd) +// +// Output (JSON to stdout): +// { +// "projectRoot": "...", +// "siteName": "...", +// "websiteRecordId": "...", +// "environmentUrl": "...", +// "solutionManifest": { ... } | null, +// "datamodelManifest": { ... } | null +// } +// +// Exit 0 on success, exit 1 if powerpages.config.json not found. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { findProjectRoot } = require('./validation-helpers'); + +function parseArgs(argv) { + const args = argv.slice(2); + let projectRoot = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--projectRoot' && args[i + 1]) projectRoot = args[++i]; + } + + return { projectRoot }; +} + +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function detectProjectContext(options = {}) { + const startDir = options.projectRoot || process.cwd(); + const projectRoot = options.projectRoot + ? path.resolve(options.projectRoot) + : findProjectRoot(startDir); + + if (!projectRoot) { + throw new Error( + 'powerpages.config.json not found. Run this command from a Power Pages project directory.' + ); + } + + const configPath = path.join(projectRoot, 'powerpages.config.json'); + if (!fs.existsSync(configPath)) { + throw new Error(`powerpages.config.json not found at: ${configPath}`); + } + + const config = readJsonFile(configPath); + if (!config) { + throw new Error(`Failed to parse powerpages.config.json at: ${configPath}`); + } + + const solutionManifest = readJsonFile(path.join(projectRoot, '.solution-manifest.json')); + const datamodelManifest = readJsonFile(path.join(projectRoot, '.datamodel-manifest.json')); + + return { + projectRoot, + siteName: config.siteName || null, + websiteRecordId: config.websiteRecordId || null, + environmentUrl: config.environmentUrl || null, + solutionManifest, + datamodelManifest, + }; +} + +// CLI entry point +if (require.main === module) { + const { projectRoot } = parseArgs(process.argv); + + try { + const result = detectProjectContext({ projectRoot }); + console.log(JSON.stringify(result)); + process.exit(0); + } catch (err) { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } +} + +module.exports = { detectProjectContext }; diff --git a/plugins/power-pages/scripts/lib/discover-component-types.js b/plugins/power-pages/scripts/lib/discover-component-types.js new file mode 100644 index 000000000..17d229c0c --- /dev/null +++ b/plugins/power-pages/scripts/lib/discover-component-types.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +// Discovers Dataverse solution component type integers at runtime by querying +// solutioncomponents for known object IDs. Never hardcodes component types. +// +// Usage: node discover-component-types.js +// --envUrl --token +// --websiteRecordId +// [--powerpageComponentId ] +// [--siteLanguageId ] +// [--objectIds ] (generic: returns array of { objectId, componentType }) +// +// Output (JSON to stdout): +// { +// "websiteComponentType": , // for `powerpagesite` root — typically 10427, observed as 10428 in some envs +// "subComponentType": , // for `powerpagecomponent` — typically 10426, observed as 10429; only if --powerpageComponentId provided +// "siteLanguageComponentType": , // for `powerpagesitelanguage` — typically 10428, observed as 10430; only if --siteLanguageId provided +// "resolved": [{ "objectId": "...", "componentType": }] // for --objectIds (generic; use this for connection refs, env vars, etc.) +// } +// +// Exit 0 on success, exit 1 on failure. +// +// Why three component types? Power Pages stores a single site as three sibling +// unified entities: `powerpagesite` (root), `powerpagecomponent` (sub-records), +// and `powerpagesitelanguage` (languages). Each maps to a distinct +// solutioncomponent.componenttype. All three must be added to the user +// solution; missing the language record silently breaks the target site +// post-auth. See references/solution-api-patterns.md. +// +// Why these values are env-specific: Dataverse assigns componenttype IDs to +// custom unified entities at install time, and the IDs are not stable across +// tenants. Hard-coded values (e.g. "10427 for powerpagesite") work in the +// test tenants we developed against but break in fresh tenants where the +// Power Pages package was installed in a different order. The same applies +// to other dynamic types — connection references have been observed as +// 10137 and 10160 in different tenants. Always resolve at runtime via this +// helper's `--objectIds` parameter. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { + envUrl: null, + token: null, + websiteRecordId: null, + powerpageComponentId: null, + siteLanguageId: null, + objectIds: [], + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) result.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--websiteRecordId' && args[i + 1]) result.websiteRecordId = args[++i]; + else if (args[i] === '--powerpageComponentId' && args[i + 1]) result.powerpageComponentId = args[++i]; + else if (args[i] === '--siteLanguageId' && args[i + 1]) result.siteLanguageId = args[++i]; + else if (args[i] === '--objectIds' && args[i + 1]) result.objectIds = args[++i].split(',').filter(Boolean); + } + + return result; +} + +async function resolveComponentType(envUrl, token, objectId) { + const url = new URL(`${envUrl}/api/data/v9.2/solutioncomponents`); + url.searchParams.set('$filter', `objectid eq '${objectId}'`); + url.searchParams.set('$select', 'componenttype'); + url.searchParams.set('$top', '1'); + + const res = await helpers.makeRequest({ + url: url.toString(), + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + + if (res.error) throw new Error(`solutioncomponents query failed for ${objectId}: ${res.error}`); + if (res.statusCode !== 200) { + throw new Error(`solutioncomponents query returned ${res.statusCode} for ${objectId}`); + } + + const data = JSON.parse(res.body); + if (!data.value || data.value.length === 0) return null; + return data.value[0].componenttype; +} + +async function discoverComponentTypes({ envUrl, token, websiteRecordId, powerpageComponentId, siteLanguageId, objectIds }) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!websiteRecordId) throw new Error('--websiteRecordId is required'); + + const resolvedToken = token || getAuthToken(envUrl); + if (!resolvedToken) throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + + const result = {}; + + // Always resolve website component type + const websiteType = await resolveComponentType(envUrl, resolvedToken, websiteRecordId); + if (websiteType === null) { + throw new Error( + `Website record (${websiteRecordId}) not found in solutioncomponents. ` + + 'The site must be deployed before it can be solutionized.' + ); + } + result.websiteComponentType = websiteType; + + // Optional: powerpagecomponent sub-type + if (powerpageComponentId) { + const subType = await resolveComponentType(envUrl, resolvedToken, powerpageComponentId); + result.subComponentType = subType; + } + + // Optional: site language type + if (siteLanguageId) { + const langType = await resolveComponentType(envUrl, resolvedToken, siteLanguageId); + result.siteLanguageComponentType = langType; + } + + // Optional: generic batch resolution + if (objectIds && objectIds.length > 0) { + const resolved = []; + for (const id of objectIds) { + const ct = await resolveComponentType(envUrl, resolvedToken, id); + resolved.push({ objectId: id, componentType: ct }); + } + result.resolved = resolved; + } + + return result; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + discoverComponentTypes(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { discoverComponentTypes, resolveComponentType }; diff --git a/plugins/power-pages/scripts/lib/discover-env-var-definitions.js b/plugins/power-pages/scripts/lib/discover-env-var-definitions.js new file mode 100644 index 000000000..730ed7742 --- /dev/null +++ b/plugins/power-pages/scripts/lib/discover-env-var-definitions.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +// Discovers environment variable definitions in a Power Pages site's solution +// and returns per-variable metadata in the shape that render-alm-plan.js +// expects for its envVars[] array (so the Env Variables tab renders rows +// instead of just a count). +// +// Two passes: +// 1. environmentvariabledefinitions filtered by `startswith(schemaname,'_')` +// — same query the size estimator uses, so the count and the enumeration +// agree on which definitions belong to this site. +// 2. mspp_sitesettings filtered by website + mspp_source eq 1 — returns every +// site setting bound to an env var. We then index by env var definition id +// to attach the bound site setting name to each definition. +// +// Per-environment values (Dev / Staging / Production) are NOT collected here — +// for an ALM plan generated from dev, only the dev value is observable, and +// staging/prod values come from deployment-settings.json (which deploy-pipeline +// will collect later). The renderer handles a missing `values` map by showing +// just the defaultValue column. +// +// Usage: +// node discover-env-var-definitions.js +// --envUrl +// --publisherPrefix (e.g. "cr5fe" — no trailing _) +// --websiteRecordId (used to find bound site settings) +// [--solutionId ] (when provided, results are filtered to env vars +// that belong to this solution — preferred for plans +// with an existing solution so cross-project env vars +// sharing the publisher prefix don't bleed in) +// [--token ] (otherwise acquired via az CLI) +// +// Output (JSON to stdout): +// { +// "envVars": [ +// { +// "schemaName": "cr5fe_LocalLoginEnabled", +// "displayName": "Local Login Enabled", +// "type": "Boolean", +// "defaultValue": "true", +// "description": "Toggles the username/password sign-in form on the…", +// "siteSetting": "Authentication/Local/Enabled" +// }, +// ... +// ], +// "count": 5 +// } +// +// `displayName` and `description` come from environmentvariabledefinition's +// `displayname` and `description` columns. Both are surfaced unchanged so the +// renderer can show a friendly heading + the design rationale for each var +// without re-querying. +// +// Exit 0 always — empty envVars[] when nothing matches the prefix or auth +// fails. Exit 1 on argparse / fatal error so the caller can degrade +// gracefully. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; + +// Canonical Dataverse option-set values for environmentvariabledefinition.type +// (verified against live tenant data — a Secret env var created via the Power +// Platform UI returns 100000005, not 100000003). Earlier revisions of this +// map (and create-env-var-definition.js) had 100000003 ↔ 100000005 swapped — +// the symptom was a Secret env var rendering as "Json" in the plan, and +// create-env-var-definition.js silently producing JSON-typed records when +// callers asked for Secret. Keep in sync with create-env-var-definition.js +// ENV_VAR_TYPES. +const TYPE_LABELS = { + 100000000: 'String', + 100000001: 'Number', + 100000002: 'Boolean', + 100000003: 'JSON', + 100000004: 'DataSource', + 100000005: 'Secret', +}; + +function typeLabel(code) { + if (code === null || code === undefined) return 'String'; + return TYPE_LABELS[code] || 'String'; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + envUrl: null, + token: null, + publisherPrefix: null, + websiteRecordId: null, + solutionId: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--publisherPrefix' && args[i + 1]) out.publisherPrefix = args[++i]; + else if (args[i] === '--websiteRecordId' && args[i + 1]) out.websiteRecordId = args[++i]; + else if (args[i] === '--solutionId' && args[i + 1]) out.solutionId = args[++i]; + } + return out; +} + +// Page size + Prefer header pattern matches estimate-solution-size.js and +// discover-site-components.js. The previous `$top=2000` plain query silently +// capped at 2000 results on tenants with more matching definitions; this +// version paginates via @odata.nextLink until exhausted. +const ODATA_MAX_PAGE_SIZE = 5000; + +async function fetchPaginated(url, token) { + const aggregated = []; + let next = url; + let safety = 100; // ~500K rows; pathological cases bail early + while (next && safety > 0) { + const res = await helpers.makeRequest({ + url: next, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: `odata.maxpagesize=${ODATA_MAX_PAGE_SIZE}`, + }, + timeout: 20000, + }); + if (!res || res.error || res.statusCode !== 200 || !res.body) return aggregated; + let parsed; + try { + parsed = JSON.parse(res.body); + } catch { + return aggregated; + } + if (Array.isArray(parsed.value)) aggregated.push(...parsed.value); + next = parsed['@odata.nextLink'] || null; + safety -= 1; + } + return aggregated; +} + +async function fetchAllDefinitions(envUrl, publisherPrefix, token) { + if (!publisherPrefix) return []; + const base = envUrl.replace(/\/+$/, ''); + const url = + `${base}/api/data/v9.2/environmentvariabledefinitions` + + `?$select=environmentvariabledefinitionid,schemaname,displayname,description,type,defaultvalue` + + `&$filter=startswith(schemaname,'${publisherPrefix}_')` + + `&$top=${ODATA_MAX_PAGE_SIZE}`; + return fetchPaginated(url, token); +} + +// Returns the set of `objectid` values (lowercased) from `solutioncomponents` +// for the target solution's Environment Variable Definition rows +// (componenttype=380). Used to intersect with publisher-prefix-matched env var +// defs so the result reflects "env vars in THIS solution", not "env vars +// matching THIS prefix tenant-wide" — important when the publisher prefix is +// shared across multiple projects in the same tenant. +async function fetchSolutionEnvVarDefIds(envUrl, solutionId, token) { + if (!solutionId) return null; + const base = envUrl.replace(/\/+$/, ''); + // componenttype 380 = Environment Variable Definition (well-known value). + const url = + `${base}/api/data/v9.2/solutioncomponents` + + `?$select=objectid` + + `&$filter=_solutionid_value eq ${solutionId} and componenttype eq 380` + + `&$top=${ODATA_MAX_PAGE_SIZE}`; + const rows = await fetchPaginated(url, token); + return new Set(rows.map((r) => (r.objectid || '').toLowerCase()).filter(Boolean)); +} + +async function fetchSiteSettingBindings(envUrl, websiteRecordId, token) { + if (!websiteRecordId) return new Map(); + const base = envUrl.replace(/\/+$/, ''); + const url = + `${base}/api/data/v9.2/mspp_sitesettings` + + `?$select=mspp_name,mspp_source,_mspp_environmentvariable_value` + + `&$filter=_mspp_websiteid_value eq ${websiteRecordId} and mspp_source eq 1` + + `&$top=${ODATA_MAX_PAGE_SIZE}`; + const rows = await fetchPaginated(url, token); + const map = new Map(); + for (const row of rows) { + const defId = row._mspp_environmentvariable_value; + if (!defId) continue; + // First binding wins. A given env var should be bound to exactly one + // site setting, but defend against duplicate bindings by keeping the first. + if (!map.has(defId)) map.set(defId, row.mspp_name); + } + return map; +} + +async function discoverEnvVarDefinitions({ envUrl, token, publisherPrefix, websiteRecordId, solutionId }) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!publisherPrefix) { + // No prefix → nothing to enumerate. Return empty rather than scanning + // the whole tenant (would be slow and contaminated by cross-site defs). + return { envVars: [], count: 0, scope: 'none' }; + } + + const resolvedToken = token || getAuthToken(envUrl); + if (!resolvedToken) { + // Match the caller-degrades-gracefully contract: empty result, exit 0. + return { envVars: [], count: 0, scope: 'none' }; + } + + // Pull the three datasets in parallel: + // 1. Env var definitions matching the publisher prefix (tenant-wide) + // 2. Site setting bindings for this site (env var def ID → site setting name) + // 3. (optional) solution membership: env var def IDs in the target solution + // The third only fires when solutionId is set; without it we return the + // tenant-wide prefix match (the legacy behavior, preserved for fresh + // projects that don't yet have a solution). + const [definitions, bindings, solutionEnvVarDefIds] = await Promise.all([ + fetchAllDefinitions(envUrl, publisherPrefix, resolvedToken), + fetchSiteSettingBindings(envUrl, websiteRecordId, resolvedToken), + fetchSolutionEnvVarDefIds(envUrl, solutionId, resolvedToken), + ]); + + // Solution-scope filter: keep only definitions whose ID appears as an + // `objectid` in the target solution's componenttype-380 set. This eliminates + // the over-count regression where a publisher prefix shared across projects + // (e.g. `new_`, `cr5fe_`) inflated the env-var stat. + let scoped = definitions; + let scope = 'publisher-prefix'; + if (solutionEnvVarDefIds && solutionEnvVarDefIds.size > 0) { + scoped = definitions.filter((def) => { + const id = (def.environmentvariabledefinitionid || '').toLowerCase(); + return id && solutionEnvVarDefIds.has(id); + }); + scope = 'solution'; + } else if (solutionId && solutionEnvVarDefIds && solutionEnvVarDefIds.size === 0) { + // Caller asked for solution scope and the solution has zero env var defs. + // Honor that — return empty rather than falling back to the wider scope + // that would suggest the solution contains env vars when it doesn't. + scoped = []; + scope = 'solution'; + } + + const envVars = scoped.map((def) => ({ + schemaName: def.schemaname, + displayName: def.displayname || def.schemaname, + type: typeLabel(def.type), + defaultValue: def.defaultvalue == null ? '' : String(def.defaultvalue), + description: def.description || '', + siteSetting: bindings.get(def.environmentvariabledefinitionid) || '', + })); + + return { envVars, count: envVars.length, scope }; +} + +if (require.main === module) { + const args = parseArgs(process.argv); + discoverEnvVarDefinitions(args) + .then((result) => { + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(0); + }) + .catch((err) => { + process.stderr.write('discover-env-var-definitions: ' + err.message + '\n'); + process.exit(1); + }); +} + +module.exports = { discoverEnvVarDefinitions, typeLabel, TYPE_LABELS }; diff --git a/plugins/power-pages/scripts/lib/discover-pipelines-host.js b/plugins/power-pages/scripts/lib/discover-pipelines-host.js new file mode 100644 index 000000000..0b72f4d32 --- /dev/null +++ b/plugins/power-pages/scripts/lib/discover-pipelines-host.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +// Discovers the tenant-level default Power Platform Pipelines host environment URL. +// +// Calls RetrieveSetting on the dev/source environment to find the host: +// POST {envUrl}/api/data/v9.1/RetrieveSetting +// Body: { "SettingName": "DefaultCustomPipelinesHostEnvForTenant", "CallerObjectId": "{userId}" } +// +// Usage: node discover-pipelines-host.js --envUrl --token --userId +// +// Output (JSON to stdout): +// { "found": true, "hostEnvUrl": "https://..." } +// { "found": false, "hostEnvUrl": null } +// +// Exit 0 on success (including "not found"), exit 1 on error (stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +function parseArgs(argv) { + const args = argv.slice(2); + let envUrl = null; + let token = null; + let userId = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) token = args[++i]; + else if (args[i] === '--userId' && args[i + 1]) userId = args[++i]; + } + + return { envUrl, token, userId }; +} + +async function discoverPipelinesHost({ envUrl, token, userId } = {}) { + if (!envUrl) { + throw new Error('--envUrl is required'); + } + if (!token) { + throw new Error('--token is required'); + } + if (!userId) { + throw new Error('--userId is required'); + } + + const cleanEnvUrl = envUrl.replace(/\/+$/, ''); + + const body = JSON.stringify({ + SettingName: 'DefaultCustomPipelinesHostEnvForTenant', + CallerObjectId: userId, + }); + + const res = await helpers.makeRequest({ + url: `${cleanEnvUrl}/api/data/v9.1/RetrieveSetting`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body, + timeout: 15000, + }); + + if (res.error) { + throw new Error(`RetrieveSetting request failed: ${res.error}`); + } + + // 404 means setting not found or not supported — treat as not configured + if (res.statusCode === 404) { + return { found: false, hostEnvUrl: null }; + } + + if (res.statusCode !== 200) { + throw new Error(`RetrieveSetting returned unexpected status ${res.statusCode}: ${res.body}`); + } + + let data; + try { + data = JSON.parse(res.body); + } catch (e) { + throw new Error(`Failed to parse RetrieveSetting response: ${e.message}`); + } + + const settingValue = data.SettingValue || data.settingvalue || null; + + if (!settingValue || settingValue.trim() === '') { + return { found: false, hostEnvUrl: null }; + } + + return { found: true, hostEnvUrl: settingValue.trim() }; +} + +// CLI entry point +if (require.main === module) { + const { envUrl, token, userId } = parseArgs(process.argv); + + discoverPipelinesHost({ envUrl, token, userId }) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { discoverPipelinesHost }; diff --git a/plugins/power-pages/scripts/lib/discover-site-components.js b/plugins/power-pages/scripts/lib/discover-site-components.js new file mode 100644 index 000000000..fdd1cf421 --- /dev/null +++ b/plugins/power-pages/scripts/lib/discover-site-components.js @@ -0,0 +1,426 @@ +#!/usr/bin/env node + +// Discovers all components associated with a Power Pages site for solution packaging. +// Returns a structured inventory so setup-solution / plan-alm can show gaps and +// bulk-add missing components. +// +// Usage: +// node discover-site-components.js --envUrl --siteId [--token ] +// [--publisherPrefix

] [--solutionId ] +// +// Output (JSON to stdout): +// { +// siteId: "...", +// powerpagecomponents: { +// total: N, +// byType: { "": [{ id, name, type, typeLabel }, ...] }, +// typeLabels: { "1": "Publishing State", ... } +// }, +// siteLanguages: [{ id, name, languageCode, lcid }], +// cloudFlows: [{ id, name, state, category }], +// envVars: [{ id, schemaName, displayName, type, defaultValue }], +// customTables: [{ logicalName, schemaName, displayName }], +// inSolution: { // only present when --solutionId passed +// total, objectIds: Set, byType: { : N } +// }, +// missing: { // only present when --solutionId passed +// powerpagecomponents: [...], +// siteLanguages: [...], +// cloudFlows: [...], +// envVars: [...], +// customTables: [...] +// } +// } +// +// Exit 0 on success, exit 1 on failure. +// +// Power Pages site model uses three sibling unified entities, each with its own +// solutioncomponent.componenttype. The discovery flow MUST query all three or +// it will undercount the site and the solution that ships it: +// - powerpagecomponent (componenttype env-specific; typically 10426, +// also observed as 10429 — query at runtime) +// - powerpagesite (componenttype env-specific; typically 10427, +// also observed as 10428 — query at runtime) +// - powerpagesitelanguage (componenttype env-specific; typically 10428, +// also observed as 10430 — query at runtime) +// +// Use scripts/lib/discover-component-types.js to resolve these for a specific +// environment before any AddSolutionComponent call. +// +// Authoritative powerpagecomponenttype enum (picklist values) from +// https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/powerpagecomponent + +'use strict'; + +const helpers = require('./validation-helpers'); + +/** Authoritative picklist labels for powerpagecomponenttype. */ +const PPC_TYPE_LABELS = Object.freeze({ + 1: 'Publishing State', + 2: 'Web Page', + 3: 'Web File', + 4: 'Web Link Set', + 5: 'Web Link', + 6: 'Page Template', + 7: 'Content Snippet', + 8: 'Web Template', + 9: 'Site Setting', + 10: 'Web Page Access Control Rule', + 11: 'Web Role', + 12: 'Website Access', + 13: 'Site Marker', + 15: 'Basic Form', + 16: 'Basic Form Metadata', + 17: 'List', + 18: 'Table Permission', + 19: 'Advanced Form', + 20: 'Advanced Form Step', + 21: 'Advanced Form Metadata', + 24: 'Poll Placement', + 26: 'Ad Placement', + 27: 'Bot Consumer', + 28: 'Column Permission Profile', + 29: 'Column Permission', + 30: 'Redirect', + 31: 'Publishing State Transition Rule', + 32: 'Shortcut', + 33: 'Cloud Flow', + 34: 'UX Component', + 35: 'Server Logic', +}); + +/** + * Default inclusion policy per powerpagecomponenttype. + * `true` — include by default. + * `false` — exclude by default (none currently; we include everything site-scoped). + * Callers can override via their own UX before bulk-adding. + */ +const PPC_DEFAULT_INCLUDE = Object.freeze( + Object.fromEntries(Object.keys(PPC_TYPE_LABELS).map((k) => [k, true])) +); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + envUrl: null, + token: null, + siteId: null, + publisherPrefix: null, + solutionId: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--siteId' && args[i + 1]) out.siteId = args[++i]; + else if (args[i] === '--publisherPrefix' && args[i + 1]) out.publisherPrefix = args[++i]; + else if (args[i] === '--solutionId' && args[i + 1]) out.solutionId = args[++i]; + } + return out; +} + +/** GET helper that throws on non-200 with a useful message. */ +async function odataGet(url, token, makeRequest = helpers.makeRequest) { + const res = await makeRequest({ + url, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: 'odata.maxpagesize=5000', + }, + timeout: 30000, + }); + if (res.error) throw new Error(`Request failed: ${res.error}`); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`HTTP ${res.statusCode} from ${url}: ${(res.body || '').slice(0, 400)}`); + } + return JSON.parse(res.body); +} + +/** Follows @odata.nextLink to aggregate all pages into one value[] array. */ +async function odataGetAll(url, token, makeRequest = helpers.makeRequest) { + const aggregated = []; + let next = url; + while (next) { + const page = await odataGet(next, token, makeRequest); + if (Array.isArray(page.value)) aggregated.push(...page.value); + next = page['@odata.nextLink'] || null; + } + return aggregated; +} + +/** + * Main discovery entry point. + * @param {object} args + * @param {string} args.envUrl - Source environment URL + * @param {string} args.token - Bearer token for envUrl + * @param {string} args.siteId - powerpagesite GUID + * @param {string} [args.publisherPrefix] - when provided, filters env vars + custom tables + * @param {string} [args.solutionId] - when provided, computes `inSolution` and `missing` diff + * @param {Function} [args.makeRequest] - injected for tests + */ +async function discoverSiteComponents({ + envUrl, + token, + siteId, + publisherPrefix = null, + solutionId = null, + makeRequest = helpers.makeRequest, +} = {}) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!token) throw new Error('--token is required'); + if (!siteId) throw new Error('--siteId is required'); + + // Validate publisherPrefix once, up front. Dataverse publisher prefixes are + // alphanumeric + underscore; reject anything else so a typo surfaces as a + // clear error rather than silently matching a shortened prefix (and returning + // unrelated results) downstream. + if (publisherPrefix !== null && !/^[A-Za-z0-9_]+$/.test(String(publisherPrefix).trim())) { + throw new Error( + `Invalid publisherPrefix "${publisherPrefix}" — only alphanumeric and underscore characters are allowed.` + ); + } + + const baseUrl = envUrl.replace(/\/+$/, ''); + + // 1) All site components (the primary inventory) + const ppcUrl = + `${baseUrl}/api/data/v9.2/powerpagecomponents` + + `?$filter=_powerpagesiteid_value eq ${siteId}` + + `&$select=powerpagecomponentid,name,powerpagecomponenttype,modifiedon,statecode` + + `&$top=5000`; + const ppcRows = await odataGetAll(ppcUrl, token, makeRequest); + + const ppcByType = {}; + for (const row of ppcRows) { + const type = row.powerpagecomponenttype; + const key = String(type); + if (!ppcByType[key]) ppcByType[key] = []; + ppcByType[key].push({ + id: row.powerpagecomponentid, + name: row.name, + type, + typeLabel: PPC_TYPE_LABELS[type] || `Unknown (${type})`, + modifiedOn: row.modifiedon, + statecode: row.statecode, + }); + } + + // 2) Site languages — sibling unified entity (componenttype 10428). Every site + // has at least one (the default). Without these in the solution, an imported + // site has no language to render in and silently fails to load post-auth. + const siteLanguages = await discoverSiteLanguages({ + baseUrl, + token, + siteId, + makeRequest, + }); + + // 3) Cloud flows cross-linked through ppc type 33 (Cloud Flow binding) — BYOC sites + // don't use type 33, so we also enumerate flows by category when a solution scope + // lets us hang them on the publisher (otherwise we return only ppc-linked ones). + const cloudFlows = await discoverCloudFlows({ + baseUrl, + token, + ppcRows, + makeRequest, + }); + + // 4) Env vars filtered by publisher prefix (optional) + const envVars = publisherPrefix + ? await discoverEnvVars({ baseUrl, token, publisherPrefix, makeRequest }) + : []; + + // 5) Custom tables filtered by publisher prefix (optional) + const customTables = publisherPrefix + ? await discoverCustomTables({ baseUrl, token, publisherPrefix, makeRequest }) + : []; + + const result = { + siteId, + powerpagecomponents: { + total: ppcRows.length, + byType: ppcByType, + typeLabels: { ...PPC_TYPE_LABELS }, + }, + siteLanguages, + cloudFlows, + envVars, + customTables, + }; + + // 5) Optional: diff against an existing solution + if (solutionId) { + const solutionUrl = + `${baseUrl}/api/data/v9.2/solutioncomponents` + + `?$filter=_solutionid_value eq ${solutionId}` + + `&$select=objectid,componenttype`; + const solComps = await odataGetAll(solutionUrl, token, makeRequest); + + const inSolutionIds = new Set( + solComps.map((c) => (c.objectid || '').toLowerCase()).filter(Boolean) + ); + const byComponentType = {}; + for (const c of solComps) { + byComponentType[c.componenttype] = (byComponentType[c.componenttype] || 0) + 1; + } + + result.inSolution = { + total: solComps.length, + objectIds: Array.from(inSolutionIds), + byComponentType, + }; + + const missingPpc = []; + for (const typeKey of Object.keys(ppcByType)) { + for (const c of ppcByType[typeKey]) { + if (!inSolutionIds.has((c.id || '').toLowerCase())) missingPpc.push(c); + } + } + const missingLanguages = siteLanguages.filter( + (l) => !inSolutionIds.has((l.id || '').toLowerCase()) + ); + const missingFlows = cloudFlows.filter( + (f) => !inSolutionIds.has((f.id || '').toLowerCase()) + ); + const missingEnvVars = envVars.filter( + (e) => !inSolutionIds.has((e.id || '').toLowerCase()) + ); + const missingTables = customTables.filter( + (t) => !inSolutionIds.has((t.id || '').toLowerCase()) + ); + + result.missing = { + powerpagecomponents: missingPpc, + siteLanguages: missingLanguages, + cloudFlows: missingFlows, + envVars: missingEnvVars, + customTables: missingTables, + }; + } + + return result; +} + +/** + * Discovers powerpagesitelanguage records for the given site. These are NOT + * powerpagecomponent rows — they live in a sibling unified entity and use + * solutioncomponent.componenttype 10428 (vs 10426 for PPCs and 10427 for the + * site root). Every site has at least one default-language record; without it + * in the user solution, an imported site silently breaks post-auth because + * powerpagesite.content.defaultlanguage points at an ID that doesn't exist + * in the target environment. + */ +async function discoverSiteLanguages({ baseUrl, token, siteId, makeRequest }) { + const url = + `${baseUrl}/api/data/v9.2/powerpagesitelanguages` + + `?$filter=_powerpagesiteid_value eq ${siteId}` + + `&$select=powerpagesitelanguageid,name,languagecode,lcid,statecode` + + `&$top=5000`; + try { + const rows = await odataGetAll(url, token, makeRequest); + return rows.map((row) => ({ + id: row.powerpagesitelanguageid, + name: row.name, + languageCode: row.languagecode, + lcid: row.lcid, + statecode: row.statecode, + })); + } catch (e) { + // Older Power Pages installs may not have the unified powerpagesitelanguage + // entity. Return empty so callers fall back to whatever they had before + // rather than failing the entire discovery pass. + if (/^HTTP\s+404\b/.test(String(e && e.message))) return []; + throw e; + } +} + +async function discoverCloudFlows({ baseUrl, token, ppcRows, makeRequest }) { + const typeThreeThreeIds = new Set( + ppcRows + .filter((r) => r.powerpagecomponenttype === 33) + .map((r) => (r.powerpagecomponentid || '').toLowerCase()) + .filter(Boolean) + ); + + // Return only unmanaged cloud flows (category 5). System + managed flows aren't + // user-owned and would be noise in a "missing from your solution" prompt. + // BYOC sites don't use type-33 bindings, so we don't narrow by ppc — callers + // can still filter further by publisher scope if they want. + const url = + `${baseUrl}/api/data/v9.2/workflows` + + `?$filter=category eq 5 and _parentworkflowid_value eq null and ismanaged eq false` + + `&$select=workflowid,name,statecode,category,ismanaged` + + `&$top=5000`; + const rows = await odataGetAll(url, token, makeRequest); + return rows.map((r) => ({ + id: r.workflowid, + name: r.name, + state: r.statecode, + category: r.category, + isManaged: r.ismanaged, + linkedViaPpc: typeThreeThreeIds.has((r.workflowid || '').toLowerCase()), + })); +} + +async function discoverEnvVars({ baseUrl, token, publisherPrefix, makeRequest }) { + // publisherPrefix validated at the entry point of discoverSiteComponents. + const prefix = String(publisherPrefix).trim(); + const url = + `${baseUrl}/api/data/v9.2/environmentvariabledefinitions` + + `?$filter=startswith(schemaname,'${prefix}_')` + + `&$select=environmentvariabledefinitionid,schemaname,displayname,type,defaultvalue` + + `&$top=5000`; + const rows = await odataGetAll(url, token, makeRequest); + return rows.map((r) => ({ + id: r.environmentvariabledefinitionid, + schemaName: r.schemaname, + displayName: r.displayname, + type: r.type, + defaultValue: r.defaultvalue, + })); +} + +async function discoverCustomTables({ baseUrl, token, publisherPrefix, makeRequest }) { + // The $metadata/EntityDefinitions endpoint doesn't support `startswith` (0x8006088a), + // so we fetch all custom tables and filter client-side. Custom-entity sets are small + // enough that a single request is fine. MetadataId is included so callers can diff + // against solutioncomponents.objectid (componenttype 1 = Entity). + // publisherPrefix validated at the entry point of discoverSiteComponents. + const prefixLower = String(publisherPrefix).trim().toLowerCase(); + const url = + `${baseUrl}/api/data/v9.2/EntityDefinitions` + + `?$filter=IsCustomEntity eq true` + + `&$select=LogicalName,SchemaName,DisplayName,MetadataId`; + const rows = await odataGetAll(url, token, makeRequest); + return rows + .filter((r) => (r.LogicalName || '').toLowerCase().startsWith(`${prefixLower}_`)) + .map((r) => ({ + id: r.MetadataId, + logicalName: r.LogicalName, + schemaName: r.SchemaName, + displayName: + (r.DisplayName && r.DisplayName.UserLocalizedLabel && r.DisplayName.UserLocalizedLabel.Label) || + r.SchemaName, + })); +} + +if (require.main === module) { + const args = parseArgs(process.argv); + discoverSiteComponents(args) + .then((result) => { + process.stdout.write(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + discoverSiteComponents, + PPC_TYPE_LABELS, + PPC_DEFAULT_INCLUDE, +}; diff --git a/plugins/power-pages/scripts/lib/download-export-data.js b/plugins/power-pages/scripts/lib/download-export-data.js new file mode 100644 index 000000000..553a7f401 --- /dev/null +++ b/plugins/power-pages/scripts/lib/download-export-data.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +// Downloads solution zip after a successful async export via DownloadSolutionExportData. +// +// Usage: +// node download-export-data.js --envUrl --asyncOperationId --outputPath [--token ] +// +// Options: +// --envUrl Dataverse environment URL +// --asyncOperationId AsyncOperationId returned by ExportSolutionAsync +// --outputPath Destination path for the solution zip (e.g. MySolution_managed.zip) +// --token Azure CLI Bearer token (optional; acquired via helpers.getAuthToken if omitted) +// +// Output (JSON to stdout): +// { "zipPath": "...", "fileSizeBytes": N } +// +// Exit 0 on success, exit 1 on failure (error on stderr). + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const helpers = require('./validation-helpers'); + +function parseArgs(argv) { + const args = argv.slice(2); + let envUrl = null; + let asyncOperationId = null; + let outputPath = null; + let token = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) envUrl = args[++i]; + else if (args[i] === '--asyncOperationId' && args[i + 1]) asyncOperationId = args[++i]; + else if (args[i] === '--outputPath' && args[i + 1]) outputPath = args[++i]; + else if (args[i] === '--token' && args[i + 1]) token = args[++i]; + } + + return { envUrl, asyncOperationId, outputPath, token }; +} + +async function downloadExportData({ envUrl, asyncOperationId, outputPath, token } = {}) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!asyncOperationId) throw new Error('--asyncOperationId is required'); + if (!outputPath) throw new Error('--outputPath is required'); + + const cleanEnvUrl = envUrl.replace(/\/+$/, ''); + const resolvedOutputPath = path.resolve(outputPath); + + // Acquire token if not provided + const authToken = token || helpers.getAuthToken(cleanEnvUrl); + if (!authToken) { + throw new Error( + 'Azure CLI token acquisition failed. Run `az login` and retry, or pass --token explicitly.' + ); + } + + // Step 1: POST DownloadSolutionExportData + const requestBody = JSON.stringify({ ExportJobId: asyncOperationId }); + + const res = await helpers.makeRequest({ + url: `${cleanEnvUrl}/api/data/v9.2/DownloadSolutionExportData`, + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body: requestBody, + timeout: 60000, + }); + + if (res.error) { + throw new Error(`DownloadSolutionExportData request failed: ${res.error}`); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error( + `DownloadSolutionExportData returned HTTP ${res.statusCode}: ${res.body}` + ); + } + + // Step 2: Parse response body for ExportSolutionFile (base64) + let responseData; + try { + responseData = JSON.parse(res.body); + } catch { + throw new Error(`DownloadSolutionExportData returned non-JSON body: ${res.body}`); + } + + const base64Encoded = responseData.ExportSolutionFile; + if (!base64Encoded) { + throw new Error( + 'DownloadSolutionExportData response is missing ExportSolutionFile. ' + + 'The export job may have failed or the ExportJobId is incorrect.' + ); + } + + // Step 3: Decode base64 and write zip to disk + const zipBuffer = Buffer.from(base64Encoded, 'base64'); + const fileSizeBytes = zipBuffer.length; + + // Ensure output directory exists + const outputDir = path.dirname(resolvedOutputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(resolvedOutputPath, zipBuffer); + + return { zipPath: resolvedOutputPath, fileSizeBytes }; +} + +// CLI entry point +if (require.main === module) { + const { envUrl, asyncOperationId, outputPath, token } = parseArgs(process.argv); + + downloadExportData({ envUrl, asyncOperationId, outputPath, token }) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { downloadExportData }; diff --git a/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js b/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js new file mode 100644 index 000000000..0de382881 --- /dev/null +++ b/plugins/power-pages/scripts/lib/ensure-pipelines-host-detect.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +// Detection-only wrapper around the ensure-pipelines-host workflow. +// Runs Phases 1.0 (cache fast-path) + 2 (resolution order: org-setting → BAP env GET +// → tenant default custom → tenant-wide enumeration) + 5 (verify if host found). +// NEVER enters Phase 3 (decision tree) or Phase 4 (provisioning). Always exits with +// actionTaken: "none". Used by plan-alm Phase 1 step 12 and other orchestrators that +// want to inspect host state without inviting user prompts. +// +// Resolution order (mirrors ProjectHostProvider.tsx): +// 1. Check docs/alm/last-host-check.json cache → probe finalHostEnvUrl → reuse if reachable. +// 2. GetOrgDbOrgSetting('ProjectHostEnvironmentId') on source env. +// - If bound → BAP env GET to resolve URL/sku. +// - If sku === 'Platform' → check tenant default custom host (discover-pipelines-host). +// - default !== orgSettingHostEnvId → CannotRedirect. +// - else → AvailableUsing(PlatformHost|CustomHostByAdminDefault). +// - else → AvailableUsingCustomHost. +// 3. If unbound → tenant-wide list-tenant-envs with --firstHitWins. +// - 1 custom host found → AvailableUnboundCustomHost. +// - >1 → MultipleUnboundCustomHosts. +// - 0 + PE found → PlatformHostExistsUnbound. +// - none → NoHost. +// 4. Verify host (verify-host-readiness) if any final URL is set. +// +// Usage: +// node ensure-pipelines-host-detect.js +// --envUrl --token --userId +// --bapToken +// [--projectRoot ] [--cacheMaxAgeHours 24] [--no-cache] +// [--includeName ] [--maxEnvsToProbe N] [--skus Production,Sandbox] +// [--minPipelinesVersion 9.0.0.0] +// +// Output (JSON to stdout): matches docs/alm/last-host-check.json schemaVersion 2. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const helpers = require('./validation-helpers'); +const { almPath } = require('./alm-paths'); +const { checkEnvHostBinding } = require('./check-env-host-binding'); +const { resolveEnvById } = require('./resolve-env-by-id'); +const { discoverPipelinesHost } = require('./discover-pipelines-host'); +const { listTenantEnvs } = require('./list-tenant-envs'); +const { verifyHostReadiness } = require('./verify-host-readiness'); + +const DEFAULT_CACHE_MAX_AGE_HOURS = 24; + +function parseArgs(argv) { + const args = argv.slice(2); + const opts = { + envUrl: null, + token: null, + userId: null, + bapToken: null, + projectRoot: process.cwd(), + cacheMaxAgeHours: DEFAULT_CACHE_MAX_AGE_HOURS, + noCache: false, + includeName: null, + maxEnvsToProbe: null, + skus: null, + minPipelinesVersion: null, + source: 'auto', + }; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const next = args[i + 1]; + if (a === '--envUrl' && next) opts.envUrl = args[++i]; + else if (a === '--token' && next) opts.token = args[++i]; + else if (a === '--userId' && next) opts.userId = args[++i]; + else if (a === '--bapToken' && next) opts.bapToken = args[++i]; + else if (a === '--projectRoot' && next) opts.projectRoot = args[++i]; + else if (a === '--cacheMaxAgeHours' && next) opts.cacheMaxAgeHours = Number(args[++i]) || DEFAULT_CACHE_MAX_AGE_HOURS; + else if (a === '--no-cache') opts.noCache = true; + else if (a === '--includeName' && next) opts.includeName = args[++i]; + else if (a === '--maxEnvsToProbe' && next) opts.maxEnvsToProbe = Number(args[++i]); + else if (a === '--skus' && next) opts.skus = args[++i].split(',').map((s) => s.trim()).filter(Boolean); + else if (a === '--minPipelinesVersion' && next) opts.minPipelinesVersion = args[++i]; + else if (a === '--source' && next) opts.source = args[++i]; + } + return opts; +} + +function originOf(url) { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}`; + } catch { + return null; + } +} + +function getDataverseToken(originUrl, getTokenImpl) { + if (typeof getTokenImpl === 'function') return getTokenImpl(originUrl); + try { + return execSync(`az account get-access-token --resource "${originUrl}" --query accessToken -o tsv`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (e) { + throw new Error(`az token acquisition failed for ${originUrl}: ${e.message || e.stderr?.toString() || 'unknown'}`); + } +} + +async function tryCacheFastPath({ projectRoot, cacheMaxAgeHours, getTokenImpl }) { + const cachePath = almPath(projectRoot, 'lastHostCheck'); + if (!fs.existsSync(cachePath)) return null; + let cached; + try { + cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + } catch { + return null; + } + if (!cached.checkedAt || !cached.finalHostEnvUrl || cached.ready !== true) return null; + + const ageMs = Date.now() - Date.parse(cached.checkedAt); + if (!isFinite(ageMs) || ageMs < 0) return null; + if (ageMs > cacheMaxAgeHours * 3600 * 1000) return null; + + // Probe with a fresh token. + let token; + try { + token = getDataverseToken(originOf(cached.finalHostEnvUrl), getTokenImpl); + } catch { + return null; + } + + const verify = await verifyHostReadiness({ + hostEnvUrl: cached.finalHostEnvUrl, + hostToken: token, + skipWhoAmI: false, + }); + + if (!verify.ready) return null; + + return { + ...cached, + schemaVersion: 2, + cacheHit: true, + cacheAgeMs: ageMs, + pipelinesSolutionVersion: verify.pipelinesSolutionVersion || cached.pipelinesSolutionVersion, + warnings: verify.warnings || [], + }; +} + +async function detect(opts = {}) { + const { + envUrl, + token, + userId, + bapToken, + projectRoot = process.cwd(), + cacheMaxAgeHours = DEFAULT_CACHE_MAX_AGE_HOURS, + noCache = false, + includeName = null, + maxEnvsToProbe = null, + skus = null, + minPipelinesVersion = null, + source = 'auto', + // Test injection points: + getTokenImpl = null, + listImpl = null, + verifyImpl = null, + pacExecImpl = null, + } = opts; + + if (!envUrl) throw new Error('--envUrl is required'); + if (!token) throw new Error('--token (dev env Dataverse token) is required'); + if (!userId) throw new Error('--userId is required'); + // BAP token is only required for source=bap. In source=pac or source=auto-with-PAC-fallback, + // detection works without BAP — the shim uses PAC CLI for env list/get. + if (source === 'bap' && !bapToken) throw new Error('--bapToken is required when --source bap'); + + const startedAt = Date.now(); + const baseOut = { + schemaVersion: 2, + checkedAt: new Date().toISOString(), + sourceEnvUrl: envUrl, + sourceEnvId: null, + actionTaken: 'none', + finalHostEnvUrl: null, + finalHostEnvId: null, + finalHostEnvName: null, // BAP env displayName — surfaces in plan-alm host card so reviewers see "Supplier Portal Host" instead of just the GUID-y instance URL + finalHostInstanceApiUrl: null, + isPlatformHost: false, + tenantDefaultCustomHostEnvId: null, + pipelinesSolutionVersion: null, + ready: false, + warnings: [], + candidates: { + existingCustomHosts: [], + existingPlatformHost: null, + eligibleForAppInstall: [], + inaccessibleEnvs: [], + }, + telemetry: { correlationId: null }, + detectionDurationMs: 0, + cacheHit: false, + }; + + // Phase 1.0 — cache fast-path + if (!noCache) { + const hit = await tryCacheFastPath({ projectRoot, cacheMaxAgeHours, getTokenImpl }); + if (hit) { + hit.detectionDurationMs = Date.now() - startedAt; + hit.checkedAt = new Date().toISOString(); + return hit; + } + } + + // Phase 2.1 — org-setting probe + const binding = await checkEnvHostBinding({ envUrl, token }); + + if (binding.bound) { + baseOut.sourceEnvId = binding.hostEnvId; // hostEnvId here is the env GUID stored in the org setting + + // Phase 2.2 — resolve via BAP (or PAC fallback) + const env = await resolveEnvById({ bapToken, envId: binding.hostEnvId, source, pacExecImpl }); + if (!env.found) { + // 404-ambiguous: source env's binding points at an env we can't see. + baseOut.resolutionStatus = 'OrgSettingStale'; + baseOut.warnings.push(`ProjectHostEnvironmentId points at env ${binding.hostEnvId} which is not visible — may be deleted, disabled, or the caller lacks access.`); + baseOut.detectionDurationMs = Date.now() - startedAt; + return baseOut; + } + + baseOut.finalHostEnvId = env.envId; + baseOut.finalHostEnvUrl = env.instanceUrl; + baseOut.finalHostEnvName = env.displayName || null; + baseOut.finalHostInstanceApiUrl = env.instanceApiUrl; + baseOut.isPlatformHost = env.environmentSku === 'Platform'; + + // Phase 2.3 — if PE, check tenant default custom host (CannotRedirect detection) + if (baseOut.isPlatformHost) { + const def = await discoverPipelinesHost({ envUrl, token, userId }); + if (def.found && def.hostEnvUrl) { + baseOut.tenantDefaultCustomHostEnvId = def.hostEnvUrl; + // The org setting and tenant default are both env GUIDs. Compare them. + const orgSettingValue = binding.hostEnvId.toLowerCase(); + const tenantDefaultValue = String(def.hostEnvUrl).toLowerCase(); + if (orgSettingValue !== tenantDefaultValue) { + baseOut.resolutionStatus = 'CannotRedirect'; + baseOut.warnings.push( + `CannotRedirect: source env's ProjectHostEnvironmentId (${binding.hostEnvId}) points at PE, but tenant DefaultCustomPipelinesHostEnvForTenant (${def.hostEnvUrl}) points elsewhere. Resolution requires Power Platform admin.`, + ); + baseOut.detectionDurationMs = Date.now() - startedAt; + return baseOut; + } + baseOut.resolutionStatus = 'AvailableUsingCustomHostByAdminDefault'; + } else { + baseOut.resolutionStatus = 'AvailableUsingPlatformHost'; + } + } else { + baseOut.resolutionStatus = 'AvailableUsingCustomHost'; + } + } else { + // Phase 2.5 — no org binding. Tenant-wide enumeration. + const list = await listTenantEnvs({ + bapToken, + // Default to Production+Sandbox so trial-license tenants (Sandbox-only) + // still see eligible existing envs in the env-first menu. See + // list-tenant-envs.js DEFAULT_SKUS for rationale. + skus: skus || ['Production', 'Sandbox'], + maxEnvsToProbe: maxEnvsToProbe || undefined, + firstHitWins: true, + includeName, + source, + listImpl, + getTokenImpl, + verifyImpl, + pacExecImpl, + }); + + baseOut.candidates = { + existingCustomHosts: list.existingCustomHosts, + existingPlatformHost: list.existingPlatformHost, + eligibleForAppInstall: list.eligibleForAppInstall, + inaccessibleEnvs: list.inaccessibleEnvs, + }; + + if (list.existingCustomHosts.length === 1) { + const h = list.existingCustomHosts[0]; + baseOut.resolutionStatus = 'AvailableUnboundCustomHost'; + baseOut.finalHostEnvId = h.envId; + baseOut.finalHostEnvUrl = h.instanceUrl; + baseOut.finalHostEnvName = h.displayName || null; + baseOut.finalHostInstanceApiUrl = h.instanceApiUrl; + baseOut.isPlatformHost = false; + baseOut.pipelinesSolutionVersion = h.pipelinesSolutionVersion || null; + } else if (list.existingCustomHosts.length > 1) { + baseOut.resolutionStatus = 'MultipleUnboundCustomHosts'; + // No finalHostEnvUrl — orchestrator decides which to pick at execution time. + } else if (list.existingPlatformHost) { + const h = list.existingPlatformHost; + baseOut.resolutionStatus = 'PlatformHostExistsUnbound'; + baseOut.finalHostEnvId = h.envId; + baseOut.finalHostEnvUrl = h.instanceUrl; + baseOut.finalHostEnvName = h.displayName || null; + baseOut.finalHostInstanceApiUrl = h.instanceApiUrl; + baseOut.isPlatformHost = true; + baseOut.pipelinesSolutionVersion = h.pipelinesSolutionVersion || null; + } else { + baseOut.resolutionStatus = 'NoHost'; + } + } + + // Phase 5 — verify host (only if finalHostEnvUrl was set) + if (baseOut.finalHostEnvUrl) { + let hostToken; + try { + hostToken = getDataverseToken(originOf(baseOut.finalHostEnvUrl), getTokenImpl); + } catch (e) { + baseOut.warnings.push(`Token acquisition failed for host: ${e.message}`); + baseOut.detectionDurationMs = Date.now() - startedAt; + return baseOut; + } + + const verify = await verifyHostReadiness({ + hostEnvUrl: baseOut.finalHostEnvUrl, + hostToken, + skipWhoAmI: false, + minPipelinesVersion, + }); + + baseOut.ready = verify.ready; + baseOut.pipelinesSolutionVersion = verify.pipelinesSolutionVersion || baseOut.pipelinesSolutionVersion; + baseOut.warnings = baseOut.warnings.concat(verify.warnings || []); + if (!verify.ready) { + baseOut.warnings.push('Verification failed — host did not pass deploymentpipelines / solutions check.'); + } + } + + baseOut.detectionDurationMs = Date.now() - startedAt; + return baseOut; +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + detect(opts) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { detect, tryCacheFastPath }; diff --git a/plugins/power-pages/scripts/lib/estimate-solution-size.js b/plugins/power-pages/scripts/lib/estimate-solution-size.js new file mode 100644 index 000000000..f38a6e844 --- /dev/null +++ b/plugins/power-pages/scripts/lib/estimate-solution-size.js @@ -0,0 +1,1119 @@ +#!/usr/bin/env node + +// Estimates solution size + component counts by querying Dataverse metadata. +// Output feeds compute-split-plan.js. +// +// Usage: node estimate-solution-size.js +// --envUrl +// --websiteRecordId +// [--token ] +// [--publisherPrefix ] +// [--siteName ] +// [--datamodelManifest ] +// +// Output (JSON to stdout): +// { +// totalSizeMB, componentCount, tableCount, schemaAttrCount, +// webFilesAggregateMB, webFilesIndividual[], +// cloudFlowCount, botCount, envVarCount, mediaRatio, +// siteType, tables[], estimationMethod, estimationAccuracyPct +// } +// +// Exit 0 on success, exit 1 on any error (including auth failure). Callers that +// redirect stdout to a file should use the tmp-file pattern (write to `.tmp`, move +// on success) so a failed run doesn't clobber a prior good estimate. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; +// `makeRequest` is accessed via `helpers.makeRequest` (not destructured) so +// tests can inject a mock by mutating `helpers.makeRequest` before calling +// the top-level `estimateSolutionSize`. See estimate-solution-size.test.js for +// the pagination integration test that depends on this. + +// Approximate bytes-per-component for metadata-based estimation. +// Calibrated against managed solution exports at typical sizes. +const BYTES_PER = Object.freeze({ + table: 48 * 1024, // schema + forms + views per table + attribute: 2 * 1024, // per column (some are larger, averaged) + sitesetting: 512, + webrole: 256, + tablepermission: 1024, + cloudflow: 2.2 * 1024 * 1024, // flows carry embedded JSON + bot: 512 * 1024, + envvarDef: 256, + webpage: 6 * 1024, + webtemplate: 4 * 1024, + pagetemplate: 2 * 1024, + contentsnippet: 1024, + sitemarker: 256, + other: 512, +}); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + envUrl: null, + token: null, + websiteRecordId: null, + publisherPrefix: null, + siteName: null, + datamodelManifest: null, + solutionId: null, + projectRoot: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--websiteRecordId' && args[i + 1]) out.websiteRecordId = args[++i]; + else if (args[i] === '--publisherPrefix' && args[i + 1]) out.publisherPrefix = args[++i]; + else if (args[i] === '--siteName' && args[i + 1]) out.siteName = args[++i]; + else if (args[i] === '--datamodelManifest' && args[i + 1]) out.datamodelManifest = args[++i]; + else if (args[i] === '--solutionId' && args[i + 1]) out.solutionId = args[++i]; + else if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + } + return out; +} + +// Page size for paginated OData queries. Dataverse caps `Prefer: odata.maxpagesize` +// at 5000 — requesting more is silently downgraded. Using the cap minimizes +// roundtrips for large sites. +const ODATA_MAX_PAGE_SIZE = 5000; + +// Safety upper bound on pagination iterations. At 5000 rows/page this allows up +// to 500,000 records before we bail — well above any realistic Power Pages site. +// The cap exists only to prevent runaway loops in pathological response loops +// where `@odata.nextLink` cycles. Hitting this is the signal of a server bug, +// not a normal-case truncation. +const PAGINATION_SAFETY_CAP = 100; + +async function odataGet(envUrl, path, token) { + const url = path.startsWith('http') ? path : `${envUrl}/api/data/v9.2/${path.replace(/^\//, '')}`; + const res = await helpers.makeRequest({ + url, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + // `Prefer: odata.maxpagesize=N` is what makes Dataverse emit + // `@odata.nextLink` when there are more rows than fit in one page. + // Without it, a `$top=N` query returns at most N rows AND NO continuation + // link — even when more rows exist. That was the cause of webFileCount + // capping at 500 on stress-test sites with 6000+ web files. + Prefer: `odata.maxpagesize=${ODATA_MAX_PAGE_SIZE}`, + }, + timeout: 30000, + }); + if (res.error) throw new Error(`API request failed: ${res.error}`); + if (res.statusCode === 401) { + const err = new Error('Authentication failed'); + err.code = 'AUTH'; + throw err; + } + if (res.statusCode !== 200) { + throw new Error(`Unexpected response (${res.statusCode}): ${res.body}`); + } + return JSON.parse(res.body); +} + +// Follows `@odata.nextLink` until exhausted, aggregating all pages. +// `maxPages` is a safety cap — leave at the default unless you know the +// remote endpoint can return more than ~500K rows. +async function collectPaginated(envUrl, path, token, maxPages = PAGINATION_SAFETY_CAP) { + let next = path; + const items = []; + let pagesFetched = 0; + for (let p = 0; p < maxPages && next; p++) { + const page = await odataGet(envUrl, next, token); + if (Array.isArray(page.value)) items.push(...page.value); + next = page['@odata.nextLink'] || null; + pagesFetched += 1; + } + if (next) { + // We hit the safety cap with more pages remaining. This is a strong signal + // of either a bug in the remote endpoint or an unrealistic dataset size. + // Stamp a warning into stderr so the caller can see it; the canary in the + // top-level estimator output will also flag the truncation. + process.stderr.write( + `estimate-solution-size: WARN — collectPaginated hit the safety cap of ${maxPages} pages (~${maxPages * ODATA_MAX_PAGE_SIZE} rows) with more remaining. Path: ${path.slice(0, 200)}\n`, + ); + } + return items; +} + +/** + * Discovers bots + bot components linked to the site. + * + * Power Pages bot linkage: each site has `powerpagecomponent` rows of type 27 + * (Bot Consumer). Each consumer carries the bot schemaname in its `content` + * JSON (the `name` column is literally the string "Bot Consumer"). We scope + * the bot query by those schemanames so env-wide bots from other projects + * don't inflate this site's count. + * + * Each bot has child `botcomponent` rows (topics, entities, gpt defs). Both + * bots and bot components become separate `solutioncomponents` rows when + * added to a solution (the Bot and BotComponent types; integer values are + * dynamic per tenant — resolve via `discover-component-types.js` before any + * mutation). Observed values in current tenants are 10192 for Bot and 10193 + * for BotComponent; 10137 is Connection Reference (not a bot type), which + * earlier comments here had swapped. Counting bots + bot components here + * closes the siteTotal gap that previously made orphansOnSite look + * artificially small. + * + * Pagination: uses the shared `collectPaginated` helper with the default + * `PAGINATION_SAFETY_CAP` (100 pages × `ODATA_MAX_PAGE_SIZE` = ~500K rows). + * Hitting that cap is so unusual in real tenants that we log a WARN via the + * helper rather than paginating forever. + */ +async function discoverBotsAndComponents(envUrl, botConsumerPpcs, token) { + if (!botConsumerPpcs || botConsumerPpcs.length === 0) { + return { bots: [], botComponents: [] }; + } + + // Bot schemaname lives in the ppc `content` JSON (the `name` field is the + // literal string "Bot Consumer" — not useful). We re-query the consumers + // with content included, parse, and collect unique schema names. + const consumerIds = botConsumerPpcs + .map((c) => c.powerpagecomponentid) + .filter(Boolean); + if (consumerIds.length === 0) return { bots: [], botComponents: [] }; + + const idFilter = consumerIds.map((id) => `powerpagecomponentid eq ${id}`).join(' or '); + const withContentPath = + `powerpagecomponents?$filter=${idFilter}&$select=powerpagecomponentid,content&$top=${ODATA_MAX_PAGE_SIZE}`; + let enriched; + try { + enriched = await collectPaginated(envUrl, withContentPath, token); + } catch { + return { bots: [], botComponents: [] }; + } + + const consumerNames = []; + for (const row of enriched) { + let schema = null; + try { + const parsed = JSON.parse(row.content || '{}'); + schema = parsed.botschemaname || parsed.botSchemaName || null; + } catch { + // Malformed content — skip this consumer. + } + if (schema) consumerNames.push(schema); + } + + const unique = [...new Set(consumerNames)]; + if (unique.length === 0) return { bots: [], botComponents: [] }; + + // Fetch bots by schema-name match. OR-chaining several equality predicates + // stays well inside URL-length limits for realistic consumer counts (<50). + const safeNames = unique.map((n) => n.replace(/'/g, "''")); + const botFilter = safeNames.map((n) => `schemaname eq '${n}'`).join(' or '); + const botsPath = + `bots?$filter=${botFilter}&$select=botid,name,schemaname&$top=${ODATA_MAX_PAGE_SIZE}`; + let bots = []; + try { + bots = await collectPaginated(envUrl, botsPath, token); + } catch { + // Bots may be unavailable in some tenants (privilege / feature gating). + // Don't fail the whole estimate — surface as zero and move on. + return { bots: [], botComponents: [] }; + } + if (bots.length === 0) return { bots: [], botComponents: [] }; + + const botIds = bots.map((b) => b.botid).filter(Boolean); + const compFilter = botIds.map((id) => `_parentbotid_value eq ${id}`).join(' or '); + const compsPath = + `botcomponents?$filter=${compFilter}&$select=botcomponentid&$top=${ODATA_MAX_PAGE_SIZE}`; + let botComponents = []; + try { + botComponents = await collectPaginated(envUrl, compsPath, token); + } catch { + botComponents = []; + } + return { bots, botComponents }; +} + +async function discoverPowerPageComponents(envUrl, websiteRecordId, token) { + // Verified 2026-04-21 against org1e98cc97 (v9.2 endpoint): both quoted and + // unquoted GUID forms return identical results. Keeping quoted because it's + // the historically safer form and tests against this codebase assume it. + // See memory/project_pr107_deferred_validation.md (Check 1) for evidence. + const path = + `powerpagecomponents` + + `?$filter=_powerpagesiteid_value eq '${websiteRecordId}'` + + `&$select=powerpagecomponentid,name,powerpagecomponenttype` + + `&$top=${ODATA_MAX_PAGE_SIZE}`; + return collectPaginated(envUrl, path, token); +} + +// Returns the server's `@odata.count` for an entity + optional filter — cheap +// ground-truth check (one round-trip; payload is a single row plus the count +// annotation). Used by the truncation canary: if the row-fetch returned fewer +// items than `@odata.count` reports, pagination is broken upstream. Returns +// null on query failure so the canary can degrade gracefully. +async function countOData(envUrl, entity, filter, token) { + try { + const filterPart = filter ? `&$filter=${filter}` : ''; + const countPath = `${entity}?$count=true&$top=1${filterPart}`; + const page = await odataGet(envUrl, countPath, token); + const n = page['@odata.count']; + return typeof n === 'number' ? n : null; + } catch { + return null; + } +} + +async function discoverPowerPageSiteLanguages(envUrl, websiteRecordId, token) { + // Site languages are a sibling unified entity (`powerpagesitelanguage`) + // with its own solutioncomponent.componenttype (10428). They MUST be added + // to the user solution alongside powerpagecomponents — without them the + // target site silently fails to render post-auth. See + // references/solution-api-patterns.md for the 3-entity model. + // Older Power Pages installs without the unified entity return 404; we + // swallow that and return [] so the estimator stays usable. + const path = + `powerpagesitelanguages` + + `?$filter=_powerpagesiteid_value eq '${websiteRecordId}'` + + `&$select=powerpagesitelanguageid,name,languagecode` + + `&$top=${ODATA_MAX_PAGE_SIZE}`; + try { + return await collectPaginated(envUrl, path, token); + } catch (e) { + if (/HTTP\s+404\b/.test(String(e && e.message))) return []; + throw e; + } +} + +async function discoverTables(envUrl, publisherPrefix, token, manifestPath) { + // Try manifest first + const fs = require('fs'); + let manifestTables = []; + if (manifestPath && fs.existsSync(manifestPath)) { + try { + const man = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const entries = man.entities || man.tables || []; + manifestTables = entries.map((e) => ({ + logicalName: e.logicalName || e.LogicalName || e.name, + metadataId: e.metadataId || e.MetadataId, + })); + } catch {} + } + + // Query EntityDefinitions for custom unmanaged tables. + // Verified 2026-04-22 against org1e98cc97 (v9.2): EntityDefinitions does NOT + // support `$top` (returns 400 "The query parameter $top is not supported"). + // We filter server-side to IsCustomEntity=true to keep the payload bounded — + // there's still no client-side pagination needed for typical tenants. + const path = + `EntityDefinitions` + + `?$filter=IsCustomEntity eq true` + + `&$select=LogicalName,MetadataId,IsManaged,IsCustomEntity`; + const all = await collectPaginated(envUrl, path, token, 10); + const custom = all.filter((e) => e.IsCustomEntity === true && e.IsManaged === false); + const matchingPrefix = publisherPrefix + ? custom.filter((e) => (e.LogicalName || '').toLowerCase().startsWith(`${publisherPrefix.toLowerCase()}_`)) + : custom; + + const byName = new Map(); + for (const t of [...manifestTables, ...matchingPrefix.map((e) => ({ + logicalName: e.LogicalName, + metadataId: e.MetadataId, + }))]) { + if (t.logicalName && !byName.has(t.logicalName)) byName.set(t.logicalName, t); + } + return Array.from(byName.values()); +} + +async function countAttributesForTables(envUrl, tables, token) { + let total = 0; + for (const t of tables) { + try { + const page = await odataGet( + envUrl, + `EntityDefinitions(LogicalName='${t.logicalName}')/Attributes?$select=LogicalName&$top=1000`, + token, + ); + const n = Array.isArray(page.value) ? page.value.length : 0; + total += n; + t.attributeCount = n; + } catch { + t.attributeCount = 0; + } + } + return total; +} + +async function countEnvVarDefinitions(envUrl, publisherPrefix, token) { + const filter = publisherPrefix + ? `&$filter=startswith(schemaname,'${publisherPrefix}_')` + : ''; + const path = + `environmentvariabledefinitions?$select=schemaname,displayname,type${filter}&$top=${ODATA_MAX_PAGE_SIZE}`; + const items = await collectPaginated(envUrl, path, token); + return items.length; +} + +// Detects Vite/Rollup/Webpack code-bundle chunks emitted by +// `pac pages upload-code-site`. Each rebuild uploads new hash-suffixed files +// and leaves the prior batch behind — so the total accumulates even though +// only the latest batch is referenced by index.html. For plan-alm purposes, +// these dead entries are noise, not real site inventory. +// +// Patterns matched: +// Home-BPuZZDcA.js (Vite dynamic chunks) +// index-DyzztwOp.js (main entry) +// chunk-RxR9EgHz.js (generic chunk) +// vendor.a1b2c3d4.js (older Webpack pattern) +// style.Z0qHD57j.css +// +// Heuristic: name contains `-` or `.` separator followed by 7–14 chars of +// [A-Za-z0-9_-] followed by a `.js`/`.mjs`/`.cjs`/`.css`/`.map` extension. +// Includes sourcemaps since those also accumulate. Keeps static assets like +// `logo.svg`, `favicon.ico`, `hero.jpg` — no hash suffix. +const BUNDLE_CHUNK_NAME = /[-.][A-Za-z0-9_-]{7,14}\.(?:js|mjs|cjs|css)(?:\.map)?$/; +function isProbablyBundleChunk(name) { + if (!name) return false; + return BUNDLE_CHUNK_NAME.test(String(name)); +} + +function classifyPPCs(ppcs) { + const byType = new Map(); + for (const c of ppcs) { + const t = c.powerpagecomponenttype; + if (!byType.has(t)) byType.set(t, []); + byType.get(t).push(c); + } + + // Canonical `powerpagecomponenttype` picklist values (authoritative: MS Learn, + // cross-checked against the PPC_TYPE_LABELS enum in discover-site-components.js). + // Earlier versions of this file had swapped constants (WEB_FILE=2, WEB_PAGE=4, + // WEB_TEMPLATE=11) which actually pointed at Web Page, Web Link Set, and Web + // Role respectively — making webFileCount / webFilesAggregateMB catastrophically + // wrong on any site. Fixed 2026-04-22. + const PUBLISHING_STATE = 1; + const WEB_PAGE = 2; + const WEB_FILE = 3; + const WEB_LINK_SET = 4; + const WEB_LINK = 5; + const PAGE_TEMPLATE = 6; + const CONTENT_SNIPPET = 7; + const WEB_TEMPLATE = 8; + const SITE_SETTING = 9; + const WEB_ROLE = 11; + const SITE_MARKER = 13; + const BOT_CONSUMER = 27; + const CLOUD_FLOW_LINK = 33; + const TABLE_PERMISSION = 18; // note: 18 is Table Permission per the docs + + const rawWebFiles = byType.get(WEB_FILE) || []; + const bundleChunks = rawWebFiles.filter((f) => isProbablyBundleChunk(f.name)); + const liveWebFiles = rawWebFiles.filter((f) => !isProbablyBundleChunk(f.name)); + + return { + siteSettings: byType.get(SITE_SETTING) || [], + webRoles: byType.get(WEB_ROLE) || [], + tablePermissions: byType.get(TABLE_PERMISSION) || [], + botConsumers: byType.get(BOT_CONSUMER) || [], + cloudFlowLinks: byType.get(CLOUD_FLOW_LINK) || [], + // webFiles now excludes bundle chunks — the real "content" web files only + // (images, fonts, static assets). Bundle chunks are surfaced separately so + // they can be reported (and optionally cleaned up) but not counted as + // meaningful site inventory for planning purposes. + webFiles: liveWebFiles, + bundleChunks, + webPages: byType.get(WEB_PAGE) || [], + webTemplates: byType.get(WEB_TEMPLATE) || [], + publishingStates: byType.get(PUBLISHING_STATE) || [], + webLinks: byType.get(WEB_LINK) || [], + webLinkSets: byType.get(WEB_LINK_SET) || [], + pageTemplates: byType.get(PAGE_TEMPLATE) || [], + contentSnippets: byType.get(CONTENT_SNIPPET) || [], + siteMarkers: byType.get(SITE_MARKER) || [], + all: ppcs, + byType, + }; +} + +async function measureWebFiles(envUrl, webFiles, token) { + // Uses odataGet directly (single-row fetch each, no pagination needed). + const individual = []; + let aggregateBytes = 0; + let imgOrFontBytes = 0; + + for (const wf of webFiles) { + const id = wf.powerpagecomponentid; + try { + const rec = await odataGet( + envUrl, + `powerpagecomponents(${id})?$select=name,powerpagecomponentid,content`, + token, + ); + const name = rec.name || wf.name || id; + const content = rec.content || ''; + // content is base64; decoded size = floor(len * 3/4) + const bytes = Math.max(0, Math.floor((content.length * 3) / 4)); + aggregateBytes += bytes; + const sizeMB = bytes / (1024 * 1024); + if (sizeMB >= 0.05) { + individual.push({ name, sizeMB: Math.round(sizeMB * 100) / 100, currentPath: `/${name}` }); + } + if (/\.(png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$/i.test(name)) { + imgOrFontBytes += bytes; + } + } catch { + // Skip unreadable web file — estimate from metadata only + aggregateBytes += BYTES_PER.other; + } + } + + individual.sort((a, b) => b.sizeMB - a.sizeMB); + return { + aggregateBytes, + individual, + sampleSize: webFiles.length, + mediaRatio: aggregateBytes > 0 ? imgOrFontBytes / aggregateBytes : 0, + }; +} + +// Stratified sample over a list of web files. The goal: cover the full id-range +// so a hot spot of large files in the long tail can't dominate or get missed. +// - <= cap → measure everything +// - > cap → take first 50 + last 50 + 50 evenly-spaced middles (deterministic) +// `WEB_FILE_SAMPLE_CAP` is the upper bound; bumped from 80 to 150 after field +// reports of underestimated size on sites with large media biased to one end of +// the ppc id range. +const WEB_FILE_SAMPLE_CAP = 150; +function stratifiedWebFileSample(webFiles) { + const len = webFiles.length; + if (len <= WEB_FILE_SAMPLE_CAP) return webFiles.slice(); + const first = webFiles.slice(0, 50); + const last = webFiles.slice(len - 50, len); + const middle = []; + for (let i = 0; i < 50; i++) { + // Map i ∈ [0,50) to an index in the middle region (50, len-50). + const idx = Math.floor((i * (len - 100)) / 50) + 50; + middle.push(webFiles[idx]); + } + // Dedupe by id in the rare edge case where regions overlap on small bumps. + const seen = new Set(); + const out = []; + for (const wf of [...first, ...middle, ...last]) { + const key = wf && wf.powerpagecomponentid; + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(wf); + } + return out; +} + +// Walks a directory recursively and sums file byte sizes. Skips node_modules, +// .git, and any hidden directory (name starts with `.`). Synchronous on +// purpose — small directories complete instantly; for larger directories we'd +// rather block briefly than juggle async state inside the estimator's main +// flow. Returns null on any error (permission, missing path) so callers can +// degrade gracefully. +// +// Symlink-loop protection: tracks visited inode-device pairs in `seenInodes`. +// A symlink that points back into the walked tree (or into the project root +// itself) would otherwise recurse forever. We use `lstatSync` to NOT follow +// the link, then conditionally `statSync` to read the target's size — so we +// always count the bytes once and never re-walk the same physical directory. +function walkDirectoryBytes(rootPath) { + const fs = require('fs'); + const path = require('path'); + try { + const st = fs.statSync(rootPath); + if (!st.isDirectory()) return null; + } catch { + return null; + } + let totalBytes = 0; + let fileCount = 0; + const seenInodes = new Set(); + const stack = [rootPath]; + while (stack.length) { + const dir = stack.pop(); + try { + const dst = fs.statSync(dir); + const key = `${dst.dev}:${dst.ino}`; + if (seenInodes.has(key)) continue; // already walked (cycle or hard link) + seenInodes.add(key); + } catch { + continue; + } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const ent of entries) { + const name = ent.name; + if (!name) continue; + const full = path.join(dir, name); + // Use lstatSync to NOT follow the link itself — we only follow when the + // target is a real directory we haven't visited. + let lst; + try { lst = fs.lstatSync(full); } catch { continue; } + if (lst.isSymbolicLink()) { + try { + const target = fs.statSync(full); // resolves the symlink + if (target.isDirectory()) { + if (name === 'node_modules' || name.startsWith('.')) continue; + stack.push(full); + } else if (target.isFile()) { + totalBytes += target.size; + fileCount += 1; + } + } catch { + // broken symlink — skip + } + continue; + } + if (ent.isDirectory()) { + if (name === 'node_modules' || name.startsWith('.')) continue; + stack.push(full); + } else if (ent.isFile()) { + try { + const s = fs.statSync(full); + totalBytes += s.size; + fileCount += 1; + } catch { + // skip unreadable file + } + } + } + } + return { totalBytes, fileCount }; +} + +// Detects the build-output directory for a Power Pages code site. Order +// matches the conventional outputs across supported frameworks (Vite, Astro, +// Angular CLI, Nuxt-static fallback). Returns null if none of the candidates +// exist as directories. +function detectBuildOutputDir(projectRoot) { + if (!projectRoot) return null; + const fs = require('fs'); + const path = require('path'); + const candidates = ['dist', 'public-output', 'build', '.output']; + for (const name of candidates) { + const full = path.join(projectRoot, name); + try { + const st = fs.statSync(full); + if (st.isDirectory()) return full; + } catch { + // try next + } + } + return null; +} + +function estimateTotalSize({ classified, tables, schemaAttrCount, webFilesAggregateBytes, envVarCount }) { + const tb = BYTES_PER; + const total = + tables.length * tb.table + + schemaAttrCount * tb.attribute + + (classified.siteSettings.length * tb.sitesetting) + + (classified.webRoles.length * tb.webrole) + + (classified.tablePermissions.length * tb.tablepermission) + + (classified.cloudFlowLinks.length * tb.cloudflow) + + (classified.botConsumers.length * tb.bot) + + (classified.webPages.length * tb.webpage) + + (classified.webTemplates.length * tb.webtemplate) + + (envVarCount * tb.envvarDef) + + webFilesAggregateBytes; + return total / (1024 * 1024); +} + +/** + * Queries solutioncomponents for a specific solution and aggregates counts by + * componenttype so the caller can distinguish "site-total" from "in-solution" + * numbers. Used to fix the common confusion where the site has 908 ppcs but + * only 361 are actually owned by the solution being planned. + * + * When `sitePpcIdSet` is provided (the set of powerpagecomponent ids actually + * linked to the target site), the returned object also includes a + * `crossSitePpcs` warning — type-10373 rows in the solution that do NOT belong + * to the expected site. Safety check for solutions that accidentally contain + * ppcs from multiple sites. + */ +async function countSolutionMembership(envUrl, solutionId, token, sitePpcIdSet = null) { + const url = `${envUrl}/api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq ${solutionId}&$select=objectid,componenttype&$top=5000`; + const res = await helpers.makeRequest({ + url, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: 'odata.maxpagesize=5000', + }, + timeout: 30000, + }); + if (res.error || res.statusCode < 200 || res.statusCode >= 300) { + // Don't fail the whole estimate — just omit the inSolution block. + return null; + } + const parsed = JSON.parse(res.body); + const rows = parsed.value || []; + const byType = {}; + for (const r of rows) { + byType[r.componenttype] = (byType[r.componenttype] || 0) + 1; + } + + // Cross-site safety check: if the caller gave us the set of ppc ids on the + // target site, flag any type-10373 row in the solution whose objectid isn't + // in that set. 100% overlap is the healthy case; any miss means this + // solution contains ppcs from a different site (rare, but possible when a + // user manually adds components across sites). + let crossSitePpcs = []; + if (sitePpcIdSet && sitePpcIdSet.size > 0) { + const solPpcs = rows + .filter((r) => r.componenttype === 10373) + .map((r) => (r.objectid || '').toLowerCase()); + crossSitePpcs = solPpcs.filter((id) => id && !sitePpcIdSet.has(id)); + } + + return { + total: rows.length, + byComponentType: byType, + objectIds: rows.map((r) => (r.objectid || '').toLowerCase()), + crossSitePpcs, + }; +} + +async function estimateSolutionSize({ envUrl, websiteRecordId, token, publisherPrefix, siteName, datamodelManifest, solutionId, projectRoot }) { + if (!envUrl || !websiteRecordId) { + throw new Error('--envUrl and --websiteRecordId are required'); + } + const resolved = token || getAuthToken(envUrl); + if (!resolved) { + throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + } + + const ppcs = await discoverPowerPageComponents(envUrl, websiteRecordId, resolved); + const classified = classifyPPCs(ppcs); + + // Truncation canary — ask Dataverse for the authoritative row count and + // compare against what discoverPowerPageComponents returned. If they disagree + // by more than a small margin, pagination is broken (or the data changed + // mid-scan, which is rare for code-site inventory). Cheap: one extra + // round-trip with `$count=true&$top=1`. + const ppcGroundTruthCount = await countOData( + envUrl, + 'powerpagecomponents', + `_powerpagesiteid_value eq '${websiteRecordId}'`, + resolved, + ); + + // Site-language records are a sibling unified entity, NOT powerpagecomponent + // rows. Enumerate them so the site total reconciles with the solution total + // (which includes them under componenttype 10428). + const siteLanguages = await discoverPowerPageSiteLanguages(envUrl, websiteRecordId, resolved); + + const tables = await discoverTables(envUrl, publisherPrefix, resolved, datamodelManifest); + const schemaAttrCount = await countAttributesForTables(envUrl, tables, resolved); + + // Tenant-wide env var defs matching the publisher prefix. This is the + // fallback used when no solution is set up yet (fresh project); for sites + // with a solution, we refine to in-solution scope below using + // `inSolution.byComponentType[380]` (Environment Variable Definition). + // Without that refinement, sites whose publisher prefix is shared across + // tenants (e.g. `new_`, `cr5fe_`) over-count by including env vars from + // unrelated projects. See plan-alm + MEMORY.md for the regression context. + const envVarCountTenantWide = await countEnvVarDefinitions(envUrl, publisherPrefix, resolved); + + // Bot + bot components — scoped to bots referenced by this site's + // type-27 bot consumer ppcs so env-wide bots don't inflate the count. + const botsAndComponents = await discoverBotsAndComponents( + envUrl, + classified.botConsumers, + resolved, + ); + + // Stratified sample over the full web-file list — cap at 150 (bumped from + // an earlier 80). Field reports showed the old `slice(0, 80)` undercount + // when large media lived in the long tail of the powerpagecomponentid range. + // See `stratifiedWebFileSample` for the first-50 + middle-50 + last-50 layout. + const webFileSample = stratifiedWebFileSample(classified.webFiles); + const webMeasure = await measureWebFiles(envUrl, webFileSample, resolved); + const sampleSize = webMeasure.sampleSize; + + // Scale measured bytes to full web file count if we sampled + const scaleFactor = + classified.webFiles.length > 0 && webFileSample.length > 0 + ? classified.webFiles.length / webFileSample.length + : 1; + const webFilesAggregateBytes = webMeasure.aggregateBytes * scaleFactor; + + // Optional disk-measurement cross-check. When the caller passes + // `--projectRoot`, walk the build-output directory and sum file bytes. We + // never replace `webFilesAggregateBytes` with this — the Dataverse-measured + // bytes are what will actually ship in the solution zip — but the disk + // total is useful as a sanity check for the undercount canary below. + let webFilesDiskMeasuredMB = null; + let webFilesDiskMeasuredPath = null; + let webFilesDiskFileCount = null; + if (projectRoot) { + const buildDir = detectBuildOutputDir(projectRoot); + if (buildDir) { + const walk = walkDirectoryBytes(buildDir); + if (walk) { + webFilesDiskMeasuredMB = round1(walk.totalBytes / (1024 * 1024)); + webFilesDiskMeasuredPath = buildDir; + webFilesDiskFileCount = walk.fileCount; + } else { + process.stderr.write( + `estimate-solution-size: WARN — disk-measurement walk of ${buildDir} failed; webFilesDiskMeasuredMB unavailable.\n`, + ); + } + } + } + + // Optional: when caller passes --solutionId, also report what's actually + // in the solution vs. site-total. Reported raw — every solutioncomponents + // row counts, including bundle-chunk ppcs that were explicitly added to the + // solution. Matches the Power Platform Maker UI's solution breakdown + // (e.g. 311 site components + 11 tables + 1 site record + 1 site language + // + 4 connection references + 2 cloud flows + 2 agents + 30 agent + // components = 362). An earlier revision subtracted bundle chunks from + // inSolution.total on the theory they were "noise", but bundle chunks that + // made it into the solution ship as managed components — they're real + // members, not noise. Noise-filtering belongs only to the on-site orphan + // heuristic below, not the in-solution count. + // + // NOTE: this block was moved BEFORE the estimateTotalSize call so the + // refined `envVarCount` below can use `inSolution.byComponentType[380]` + // when a solution is set up. estimateTotalSize uses envVarCount in its + // size calculation, so the input MUST be the solution-scoped figure + // whenever possible — otherwise the size for sites with shared publisher + // prefixes is inflated by tenant-wide env var defs. + const sitePpcIdSet = new Set( + ppcs.map((p) => (p.powerpagecomponentid || '').toLowerCase()).filter(Boolean), + ); + const inSolution = solutionId + ? await countSolutionMembership(envUrl, solutionId, resolved, sitePpcIdSet) + : null; + + // Refine env var count: prefer solution-scoped membership when available. + // `inSolution.byComponentType[380]` is the count of `solutioncomponents` + // rows of type 380 (Environment Variable Definition) for the target solution + // — exactly what we want for plan-alm's "today's env vars" stat. When no + // solution is set up, fall back to the publisher-prefix tenant-wide count + // (the only useful number when there's nothing else to scope by). + const envVarCountInSolution = inSolution && inSolution.byComponentType + ? (inSolution.byComponentType[380] || 0) + : null; + const envVarCount = envVarCountInSolution != null ? envVarCountInSolution : envVarCountTenantWide; + const envVarCountScope = envVarCountInSolution != null ? 'solution' : 'publisher-prefix'; + + const totalSizeMB = estimateTotalSize({ + classified, + tables, + schemaAttrCount, + webFilesAggregateBytes, + envVarCount, + }); + + // Tag how many of the solution's ppc rows are bundle-chunk files, purely as + // metadata — we do NOT subtract this from inSolution.total. Useful for + // downstream cleanup tooling and for the plan banner that says "your + // solution contains N superseded bundle chunks — consider a cleanup pass". + let bundleChunksInSolution = 0; + if (inSolution && classified.bundleChunks.length > 0) { + const chunkIdSet = new Set( + classified.bundleChunks.map((c) => (c.powerpagecomponentid || '').toLowerCase()), + ); + const inSolIds = new Set(inSolution.objectIds || []); + for (const id of chunkIdSet) { + if (inSolIds.has(id)) bundleChunksInSolution += 1; + } + } + + // Component count must match what Dataverse `solutioncomponents` counts — + // each table is ONE component (attributes ride along, not counted separately). + // Earlier versions added `schemaAttrCount` which inflated the total by 3–5× + // on schema-heavy sites (e.g. 503 attrs pushed the count from 405 → 908). + // + // Each term in the sum below maps to a category of `solutioncomponents` row + // that would be created if the site's artifacts were added to a solution. + // + // On componenttype integers: the Dataverse `solutioncomponent.componenttype` + // picklist is officially **dynamic per tenant** — AddSolutionComponent + // expects the caller to resolve values at runtime, which is what + // `scripts/lib/discover-component-types.js` does. `countSolutionMembership` + // in this file is deliberately resolver-free: it tallies whatever values + // Dataverse returns in `byComponentType`, no hardcoded integers. Observed + // values in current tenants (2026-04-22) are + // 1=Entity, 29=Workflow, 380=EnvVarDef, 10137=ConnectionReference, + // 10192=Bot, 10193=BotComponent, 10373=PowerPageComponent, 10374=Website + // but callers MUST NOT rely on those in mutation paths — use the resolver. + // + // Site-inventory terms: + // ppcs.length — rows in powerpagecomponents for this website. + // Already contains type-27 bot consumers and + // type-33 cloud flow bindings (they're all ppcs). + // When exported to a solution they become the + // umbrella PowerPageComponent solutioncomponents + // type — one row each. + // tables.length — custom tables matching publisherPrefix. + // envVarCount — envvar definitions matching publisherPrefix. + // cloudFlowLinks — classified.cloudFlowLinks is type-33 ppcs but + // we're using its length as a 1:1 proxy for the + // Workflow entity count. Not a double-count with + // ppcs.length: that sum covers the ppc binding, + // this term covers the distinct Workflow record. + // bots / botComponents — resolved by schema-name match through the + // site's type-27 ppcs; adds the env-level Bot + + // BotComponent entity rows. + // + // For the live SIP reference site in dev (org1e98cc97), this sum evaluates + // to 393 + 11 + 1 + 2 + 2 + 30 = 439. Connection references (4) and the + // website record itself (1) are NOT included — they're env-/site-level + // artifacts and not derivable without separate queries. + // + // Raw site inventory — every ppc and related artifact, no filtering. Matches + // the Dataverse view of the site. Bundle-chunk noise is surfaced separately + // (bundleChunkCount) so consumers can reason about it without us silently + // subtracting it here. Earlier revisions subtracted chunks to get an + // "actionable" count, but that made the siteTotal non-comparable to the + // solution count in Dataverse (which does include chunk members). + const bundleChunkCount = classified.bundleChunks.length; + // Power Pages 3-entity site model: ppcs (10426) + 1 site root (10427) + + // siteLanguages (10428). All three live in the user solution, so the site + // total must include all three for parity with componentCountInSolution. + const websiteRootCount = 1; + const siteLanguageCount = siteLanguages.length; + const siteTotalComponents = + ppcs.length + + websiteRootCount + + siteLanguageCount + + tables.length + + envVarCount + + classified.cloudFlowLinks.length + + (botsAndComponents.bots.length || 0) + + (botsAndComponents.botComponents.length || 0); + + // "Actionable" site inventory — excludes bundle-chunk ppcs that are stale + // leftovers from prior `pac pages upload-code-site` runs. Useful when the + // user wants to know "how many real components do I have" vs. "how many + // rows exist in Dataverse". + const siteActionableComponents = siteTotalComponents - bundleChunkCount; + + // ── Truncation canary ─────────────────────────────────────────────────── + // Evaluate signals that suggest pagination silently truncated the inventory. + // Three independent checks; any one being true sets `truncationSuspected`. + // (a) The Dataverse `@odata.count` for powerpagecomponents disagrees with + // what we actually fetched (>5% gap). Strongest possible signal. + // (b) ppcs.length lands on an exact multiple of the page size (5000/10000/ + // 15000/...). Could be coincidence but is very rare for real sites. + // (c) ppcs.length is exactly at one of the historical legacy paging + // boundaries (500/1000/2000) — guards against future code that + // accidentally drops the `Prefer: odata.maxpagesize` header again. + // Each true signal contributes a string to `truncationWarnings[]` so + // compute-split-plan + plan-alm can surface the specific reason. + const truncationWarnings = []; + if ( + typeof ppcGroundTruthCount === 'number' && + ppcGroundTruthCount > 0 && + Math.abs(ppcGroundTruthCount - ppcs.length) > Math.max(5, ppcGroundTruthCount * 0.05) + ) { + truncationWarnings.push( + `Dataverse reports ${ppcGroundTruthCount} powerpagecomponent rows for this site, but the discovery query returned ${ppcs.length}. Pagination is truncating — estimator size/component counts WILL be wrong.`, + ); + } + const PAGE_SIZE_MULTIPLES = [ + ODATA_MAX_PAGE_SIZE, ODATA_MAX_PAGE_SIZE * 2, ODATA_MAX_PAGE_SIZE * 3, + ODATA_MAX_PAGE_SIZE * 4, ODATA_MAX_PAGE_SIZE * 5, + ]; + if (PAGE_SIZE_MULTIPLES.includes(ppcs.length)) { + truncationWarnings.push( + `ppcs.length is exactly ${ppcs.length} (= ${ppcs.length / ODATA_MAX_PAGE_SIZE} full page${ppcs.length === ODATA_MAX_PAGE_SIZE ? '' : 's'} of size ${ODATA_MAX_PAGE_SIZE}). Verify the next page wasn't dropped — compare against \`$count=true\` for the same filter.`, + ); + } + const LEGACY_BOUNDARIES = [500, 1000, 2000]; + if (LEGACY_BOUNDARIES.includes(ppcs.length)) { + truncationWarnings.push( + `ppcs.length is exactly ${ppcs.length} — a historical paging boundary from an older $top value. Suggests the \`Prefer: odata.maxpagesize\` header has regressed; verify odataGet still sends it.`, + ); + } + + // ── Web-file undercount canaries ──────────────────────────────────────── + // Two independent signals that the Dataverse-measured web-file size is + // likely an undercount. The classic failure mode: `mspp_webfile` payload + // bytes live in a file-typed column (e.g. `documentbody`) whose contents + // are NOT returned by `$select=content`. The estimator scales up a + // metadata-only response and reports a number much smaller than reality. + const webFilesAggregateMB = webFilesAggregateBytes / (1024 * 1024); + const webFileCount = classified.webFiles.length; + if ( + webFilesDiskMeasuredMB != null && + webFilesDiskMeasuredMB > 5 && + webFilesAggregateMB < 0.5 * webFilesDiskMeasuredMB + ) { + // The disk total is the byte sum of EVERY file under the build output dir — + // including HTML pages, source maps, and other artifacts that Power Pages + // doesn't ship as `powerpagecomponents` type-3 web files (HTML uploads as + // type-2 web pages instead). For HTML-dominated code sites, the disk total + // can legitimately exceed the Dataverse web-files total without indicating + // an undercount. The strong signal is when disk ≫ Dataverse AND the + // dominant disk content is media/assets (image/font/binary extensions), + // which is the file-typed-column case. The warning copy below reflects the + // dominant-case interpretation but reviewers should sanity-check by + // inspecting `webFilesDiskMeasuredPath` for HTML-heavy content. + truncationWarnings.push( + `Web-file content from Dataverse (${webFilesAggregateMB.toFixed(1)} MB across ${webFileCount} files) is much smaller than the local build output (${webFilesDiskMeasuredMB.toFixed(1)} MB at ${webFilesDiskMeasuredPath}). Most likely cause: the site's web file payloads live in a file-typed column whose bytes are not returned via $select=content — solution size is under-estimated and you should trust the disk-measured number. Alternate explanation if the disk content is HTML-dominated: HTML files are uploaded as type-2 web pages (not type-3 web files), so disk > Dataverse can be legitimate; inspect the directory if the disk total looks higher than expected for your media assets.`, + ); + } + if ( + classified.webFiles.length > 20 && + sampleSize > 0 && + (webMeasure.aggregateBytes / sampleSize) < 1024 + ) { + truncationWarnings.push( + `Sampled ${sampleSize} of ${webFileCount} web files, but the average measured size is suspiciously small (<1 KB/file). The site's web file payloads may live in file-typed columns whose bytes are not returned via $select=content. The estimator's webFilesAggregateMB is likely an undercount.`, + ); + } + const truncationSuspected = truncationWarnings.length > 0; + + return { + siteName: siteName || null, + publisherPrefix: publisherPrefix || null, + solutionId: solutionId || null, + totalSizeMB: round1(totalSizeMB), + // componentCountSiteTotal is the RAW site inventory — one count per + // Dataverse row. Matches what the Power Platform Maker UI would show + // if the whole site were added to a solution. Bundle chunks are included + // here because they're real rows in the site's `powerpagecomponents`. + componentCountSiteTotal: siteTotalComponents, + // Sub-count that strips bundle-chunk noise (stale .js/.css from prior + // `pac pages upload-code-site` runs) for people who want the + // "actionable content" view. + componentCountSiteActionable: siteActionableComponents, + // componentCountInSolution matches the raw solutioncomponents row count + // for the target solution — i.e. what the Maker UI "Objects" page shows. + // Bundle chunks that were added to the solution count as members here; + // they ship with the managed solution when exported. + componentCountInSolution: inSolution ? inSolution.total : null, + // Orphans = ppcs on the site that the solution does not own. Bundle + // chunks are excluded from orphans since they're stale upload artifacts, + // not content gaps. If you want the strict diff, compare + // componentCountSiteTotal - componentCountInSolution yourself. + orphansOnSite: inSolution + ? Math.max(siteActionableComponents - inSolution.total, 0) + : null, + botCountScoped: botsAndComponents.bots.length || 0, + botComponentCountScoped: botsAndComponents.botComponents.length || 0, + bundleChunkCount, + bundleChunkNote: bundleChunkCount > 0 + ? `${bundleChunkCount} hashed bundle chunks (Vite/Rollup) on the site — ${bundleChunksInSolution} are in the solution, ${bundleChunkCount - bundleChunksInSolution} are orphans from prior pac pages upload-code-site runs. Cleanable via dedicated cleanup pass.` + : null, + inSolution: inSolution + ? { + total: inSolution.total, + byComponentType: inSolution.byComponentType, + bundleChunksInSolution, + crossSitePpcCount: (inSolution.crossSitePpcs || []).length, + crossSitePpcWarning: + inSolution.crossSitePpcs && inSolution.crossSitePpcs.length > 0 + ? `⚠ ${inSolution.crossSitePpcs.length} powerpagecomponent row(s) in this solution do not belong to site ${websiteRecordId}. The solution may contain components from a different site. Re-check the site scope before exporting.` + : null, + // objectIds intentionally omitted from JSON output to keep it small; + // callers that need diffing should use discover-site-components.js. + } + : null, + tableCount: tables.length, + schemaAttrCount, + webFilesAggregateMB: round1(webFilesAggregateBytes / (1024 * 1024)), + webFilesIndividual: webMeasure.individual, + webFileCount: classified.webFiles.length, + // Stratified sample bookkeeping — surfaces how many ppcs the per-file + // content fetch actually visited. Compared with webFileCount this tells + // callers how aggressively the aggregate-bytes number was extrapolated. + webFileSampleSize: sampleSize, + // Disk-measurement cross-check fields. Null unless `--projectRoot` was + // passed AND a build-output directory was found. Surfaced for callers to + // sanity-check `webFilesAggregateMB`; we intentionally do NOT substitute + // this value into `webFilesAggregateMB` because the Dataverse-measured + // number is still the authoritative size for what ships in the solution. + webFilesDiskMeasuredMB, + webFilesDiskMeasuredPath, + webFilesDiskFileCount, + cloudFlowCount: classified.cloudFlowLinks.length, + botCount: classified.botConsumers.length, + // envVarCount is the count consumers should drive display + decision logic + // off of. It reflects the most accurate scope available: solution-scoped + // when --solutionId was provided, publisher-prefix tenant-wide otherwise. + envVarCount, + // envVarCountScope explains where the number came from. 'solution' is the + // accurate path; 'publisher-prefix' is the fallback for fresh projects + // where no solution exists yet and is necessarily a wider scope. + envVarCountScope, + // envVarCountTenantWide preserves the prefix-wide count for diagnostic + // purposes — e.g. flagging cases where the tenant has 500x more env vars + // matching the prefix than the solution actually contains (common when + // the prefix is shared across projects). Always surfaced regardless of + // scope so reviewers can spot the divergence. + envVarCountTenantWide, + mediaRatio: Math.round(webMeasure.mediaRatio * 100) / 100, + siteType: 'code-site', + tables: tables.map((t) => ({ logicalName: t.logicalName, attributeCount: t.attributeCount || 0 })), + breakdown: { + tables: round1((tables.length * BYTES_PER.table + schemaAttrCount * BYTES_PER.attribute) / (1024 * 1024)), + webFiles: round1(webFilesAggregateBytes / (1024 * 1024)), + siteSettings: round1((classified.siteSettings.length * BYTES_PER.sitesetting) / (1024 * 1024)), + cloudFlows: round1((classified.cloudFlowLinks.length * BYTES_PER.cloudflow) / (1024 * 1024)), + webRolesAndPermissions: round1( + ((classified.webRoles.length * BYTES_PER.webrole) + + (classified.tablePermissions.length * BYTES_PER.tablepermission)) / + (1024 * 1024), + ), + envVars: round1((envVarCount * BYTES_PER.envvarDef) / (1024 * 1024)), + otherMetadata: round1( + (((classified.webPages.length * BYTES_PER.webpage) + + (classified.webTemplates.length * BYTES_PER.webtemplate) + + (classified.botConsumers.length * BYTES_PER.bot))) / + (1024 * 1024), + ), + }, + estimationMethod: 'metadata-based', + estimationAccuracyPct: 15, + // Truncation canary — see the canary block above. Consumers + // (compute-split-plan, plan-alm) MUST surface these warnings rather than + // silently producing recommendations from possibly-truncated inputs. + truncationSuspected, + truncationWarnings, + // Ground-truth row count from Dataverse — null when the count probe + // failed (auth lapse, server transient error). Compute-split-plan uses + // this to confirm estimator inputs make sense. + ppcGroundTruthCount, + }; +} + +function round1(n) { + return Math.round((Number(n) || 0) * 10) / 10; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + estimateSolutionSize(args) + .then((result) => { + process.stdout.write(JSON.stringify(result, null, 2)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + estimateSolutionSize, + estimateTotalSize, + classifyPPCs, + countSolutionMembership, + isProbablyBundleChunk, + BYTES_PER, +}; diff --git a/plugins/power-pages/scripts/lib/export-solution-async.js b/plugins/power-pages/scripts/lib/export-solution-async.js new file mode 100644 index 000000000..3f338c853 --- /dev/null +++ b/plugins/power-pages/scripts/lib/export-solution-async.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +// Triggers async Dataverse solution export and polls until complete. +// +// Usage: +// node export-solution-async.js --envUrl --solutionName --managed [--token ] +// +// Options: +// --envUrl Dataverse environment URL +// --solutionName Unique name of the solution to export +// --managed Export as managed (true) or unmanaged (false) +// --token Azure CLI Bearer token (optional; acquired via helpers.getAuthToken if omitted) +// +// Output (JSON to stdout): +// { "asyncOperationId": "...", "solutionName": "...", "managed": true/false } +// +// Exit 0 on success, exit 1 on failure (error on stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +const POLL_INTERVAL_MS = 5000; +const MAX_ATTEMPTS = 60; + +// statecode 3 = Succeeded, statecode 4 = Failed/Canceled +const TERMINAL_SUCCEEDED = 3; +const TERMINAL_FAILED = 4; + +function parseArgs(argv) { + const args = argv.slice(2); + let envUrl = null; + let solutionName = null; + let managed = null; + let token = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) envUrl = args[++i]; + else if (args[i] === '--solutionName' && args[i + 1]) solutionName = args[++i]; + else if (args[i] === '--managed' && args[i + 1]) managed = args[++i]; + else if (args[i] === '--token' && args[i + 1]) token = args[++i]; + } + + return { envUrl, solutionName, managed, token }; +} + +async function exportSolutionAsync({ envUrl, solutionName, managed, token } = {}) { + if (!envUrl) throw new Error('--envUrl is required'); + if (!solutionName) throw new Error('--solutionName is required'); + if (managed === null || managed === undefined) throw new Error('--managed is required'); + + const cleanEnvUrl = envUrl.replace(/\/+$/, ''); + const managedBool = String(managed).toLowerCase() === 'true'; + + // Acquire token if not provided + const authToken = token || helpers.getAuthToken(cleanEnvUrl); + if (!authToken) { + throw new Error( + 'Azure CLI token acquisition failed. Run `az login` and retry, or pass --token explicitly.' + ); + } + + const authHeaders = { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }; + + // Step 1: POST ExportSolutionAsync + const exportBody = JSON.stringify({ + SolutionName: solutionName, + Managed: managedBool, + }); + + const exportRes = await helpers.makeRequest({ + url: `${cleanEnvUrl}/api/data/v9.2/ExportSolutionAsync`, + method: 'POST', + headers: authHeaders, + body: exportBody, + timeout: 30000, + }); + + if (exportRes.error) { + throw new Error(`ExportSolutionAsync request failed: ${exportRes.error}`); + } + if (exportRes.statusCode < 200 || exportRes.statusCode >= 300) { + throw new Error( + `ExportSolutionAsync returned HTTP ${exportRes.statusCode}: ${exportRes.body}` + ); + } + + let exportData; + try { + exportData = JSON.parse(exportRes.body); + } catch { + throw new Error(`ExportSolutionAsync returned non-JSON body: ${exportRes.body}`); + } + + const asyncOperationId = exportData.AsyncOperationId; + if (!asyncOperationId) { + throw new Error( + `ExportSolutionAsync response did not contain AsyncOperationId. Body: ${exportRes.body}` + ); + } + + // Step 2: Poll asyncoperations until terminal state + const pollUrl = + `${cleanEnvUrl}/api/data/v9.2/asyncoperations(${asyncOperationId})` + + `?$select=statecode,statuscode,message`; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + // Refresh token on long-running polls (every ~20 attempts = ~100s) + const currentToken = attempt % 20 === 0 + ? (helpers.getAuthToken(cleanEnvUrl) || authToken) + : authToken; + + const pollRes = await helpers.makeRequest({ + url: pollUrl, + method: 'GET', + headers: { + Authorization: `Bearer ${currentToken}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + + if (pollRes.error) { + throw new Error(`Polling asyncoperations failed: ${pollRes.error}`); + } + if (pollRes.statusCode !== 200) { + throw new Error( + `Polling asyncoperations returned HTTP ${pollRes.statusCode}: ${pollRes.body}` + ); + } + + let pollData; + try { + pollData = JSON.parse(pollRes.body); + } catch { + throw new Error(`Polling response was non-JSON: ${pollRes.body}`); + } + + const { statecode, message } = pollData; + + if (statecode === TERMINAL_SUCCEEDED) { + return { asyncOperationId, solutionName, managed: managedBool }; + } + + if (statecode === TERMINAL_FAILED) { + throw new Error( + `Export job failed (asyncOperationId: ${asyncOperationId}): ${message || '(no message)'}` + ); + } + + // Not yet terminal — wait and retry + if (attempt < MAX_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + + throw new Error( + `Export job timed out after ${MAX_ATTEMPTS} poll attempts (asyncOperationId: ${asyncOperationId}). ` + + 'The export may still be running — check the Power Platform admin center.' + ); +} + +// CLI entry point +if (require.main === module) { + const { envUrl, solutionName, managed, token } = parseArgs(process.argv); + + exportSolutionAsync({ envUrl, solutionName, managed, token }) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { exportSolutionAsync }; diff --git a/plugins/power-pages/scripts/lib/fix-blocked-attachments.js b/plugins/power-pages/scripts/lib/fix-blocked-attachments.js new file mode 100644 index 000000000..d298c228d --- /dev/null +++ b/plugins/power-pages/scripts/lib/fix-blocked-attachments.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +// Checks and optionally fixes the blockedattachments setting on a +// Dataverse environment. Power Pages code sites use .js files in their +// compiled output; environments with .js in blockedattachments reject +// uploads (pac pages upload-code-site) and solution imports (deploy-pipeline). +// +// Strategy: uses `pac env list-settings` / `pac env update-settings` so +// this works without requiring a separate Dataverse OData call, and correctly +// handles the PAC auth session already established by the caller. +// +// The blocked-attachment list is semicolon-separated (e.g., +// "exe;dll;js;vbs;..."). This script removes only the extensions that are +// needed by Power Pages code sites (.js; optionally .css if blocked). +// +// Usage: +// node fix-blocked-attachments.js +// [--envUrl ] target env (default: current PAC active env) +// [--extensions js,css] extensions to unblock (default: js) +// [--dry-run] report what would change, don't apply +// [--quiet] suppress informational output +// +// Output (JSON to stdout): +// { +// "envUrl": "https://...", +// "wasBlocked": ["js"], +// "removed": ["js"], +// "unchanged": [], +// "newValue": "exe;dll;...", +// "changed": true, +// "dryRun": false +// } +// +// Exit 0 on success (including when nothing changed), exit 1 on error. + +'use strict'; + +const { execSync } = require('child_process'); + +function parseArgs(argv) { + const args = argv.slice(2); + const opts = { + envUrl: null, + extensions: ['js'], + dryRun: false, + quiet: false, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) opts.envUrl = args[++i]; + else if (args[i] === '--extensions' && args[i + 1]) { + opts.extensions = args[++i].split(',').map(s => s.trim().toLowerCase()).filter(Boolean); + } + else if (args[i] === '--dry-run') opts.dryRun = true; + else if (args[i] === '--quiet') opts.quiet = true; + } + return opts; +} + +function log(msg, quiet) { + if (!quiet) process.stderr.write(`[fix-blocked-attachments] ${msg}\n`); +} + +function makePacRunner(execImpl) { + const exec = execImpl || execSync; + return function runPac(cmd) { + try { + const out = exec(`pac ${cmd}`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { ok: true, stdout: typeof out === 'string' ? out : (out || '') }; + } catch (e) { + return { ok: false, stdout: e.stdout || '', stderr: e.stderr || '', error: e.message }; + } + }; +} + +function parseBlockedAttachmentsFromPacOutput(pacOutput) { + // pac env list-settings outputs: + // Setting Value + // blockedattachments ade;adp;...;js;... + const lines = pacOutput.split('\n'); + for (const line of lines) { + if (/^blockedattachments\s+/i.test(line.trim())) { + const parts = line.trim().split(/\s+/); + // "blockedattachments" is first token, rest is the value + return parts.slice(1).join(' ').trim(); + } + } + return null; +} + +async function fixBlockedAttachments({ envUrl, extensions, dryRun, quiet, execImpl } = {}) { + const runPac = makePacRunner(execImpl); + // Build pac command args for env targeting + const envArg = envUrl ? `--environment "${envUrl}"` : ''; + + log(`Reading blockedattachments from ${envUrl || '(current active env)'}`, quiet); + const listResult = runPac(`env list-settings ${envArg} --filter blockedattachments`); + if (!listResult.ok) { + throw new Error(`pac env list-settings failed: ${listResult.stderr || listResult.error}`); + } + + const currentValue = parseBlockedAttachmentsFromPacOutput(listResult.stdout); + if (currentValue === null) { + throw new Error(`Could not parse blockedattachments from pac output: ${listResult.stdout.slice(0, 300)}`); + } + + log(`Current blockedattachments value (${currentValue.split(';').length} entries)`, quiet); + + const currentSet = new Set(currentValue.split(';').map(e => e.trim().toLowerCase()).filter(Boolean)); + const wasBlocked = extensions.filter(ext => currentSet.has(ext)); + const unchanged = extensions.filter(ext => !currentSet.has(ext)); + + if (wasBlocked.length === 0) { + log(`Extensions [${extensions.join(', ')}] are not blocked — nothing to change`, quiet); + return { + envUrl: envUrl || '(current active env)', + wasBlocked: [], + removed: [], + unchanged: extensions, + newValue: currentValue, + changed: false, + dryRun, + }; + } + + // Build new value with extensions removed + const newSet = new Set(currentSet); + wasBlocked.forEach(ext => newSet.delete(ext)); + const newValue = [...newSet].join(';'); + + log(`Will remove [${wasBlocked.join(', ')}] from blockedattachments`, quiet); + + if (!dryRun) { + const updateResult = runPac(`env update-settings ${envArg} --name blockedattachments --value "${newValue}"`); + if (!updateResult.ok) { + throw new Error(`pac env update-settings failed: ${updateResult.stderr || updateResult.error}`); + } + log(`Applied: removed [${wasBlocked.join(', ')}]`, quiet); + } else { + log(`DRY RUN — would have removed [${wasBlocked.join(', ')}]`, quiet); + } + + return { + envUrl: envUrl || '(current active env)', + wasBlocked, + removed: dryRun ? [] : wasBlocked, + unchanged, + newValue, + changed: !dryRun && wasBlocked.length > 0, + dryRun, + }; +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + fixBlockedAttachments(opts) + .then(result => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch(err => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { fixBlockedAttachments }; diff --git a/plugins/power-pages/scripts/lib/force-link-environment.js b/plugins/power-pages/scripts/lib/force-link-environment.js new file mode 100644 index 000000000..7d2806830 --- /dev/null +++ b/plugins/power-pages/scripts/lib/force-link-environment.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +// Force-links an existing deploymentenvironments record (in a Pipelines host +// env) to take over the source environment's host association. This is the +// API behind the "Force Link" button in the Deployment Pipeline Configuration +// app and is the documented remediation when creating an environment record +// fails with "this environment is already associated with another pipelines +// host". +// +// Endpoint: POST {hostEnvUrl}/api/data/v9.0/ManageEnvironmentStamp +// Body: { "DeploymentEnvironmentId": "{UPPER-CASE-GUID-IN-BRACES}" } +// Success: 204 No Content +// +// Required headers (HAR-verified against AppDeploymentConfiguration UI on the +// supplierportalpipelineshostch.crm17 host, 2026-05-11): +// Authorization: Bearer +// Content-Type: application/json +// Accept: application/json +// clienthost: Browser +// prefer: odata.include-annotations="*" +// x-ms-app-name: AppDeploymentConfiguration +// +// Side effects (per Microsoft Learn `custom-host-pipelines#using-force-link…`): +// - The previous host's deploymentenvironments row for this BAP env is +// delinked (its validationstatus is left stale until refreshed). +// - Makers lose access to any pipelines in the previous host that ran +// against this environment. +// - Reversible by performing Force Link again from the previous host. +// +// After the action returns 204, this script re-polls validationstatus on the +// new host's record until it reaches a terminal state. Force Link is success- +// ful when validationstatus flips to Succeeded (200000001). +// +// Usage: +// node force-link-environment.js \ +// --hostEnvUrl \ +// --token \ +// --deploymentEnvironmentId \ +// [--intervalMs 3000] [--maxAttempts 20] +// +// Output (JSON to stdout): +// { "deploymentEnvironmentId": "...", +// "hostEnvUrl": "...", +// "validationStatus": 200000001, +// "forcedAt": "" } +// +// Exit 0 on success, exit 1 on error (stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +const VALIDATION_STATUS_PENDING = 200000000; +const VALIDATION_STATUS_SUCCEEDED = 200000001; +const VALIDATION_STATUS_FAILED = 200000002; + +const DEFAULT_POLL_INTERVAL_MS = 3000; +const DEFAULT_MAX_POLL_ATTEMPTS = 20; + +const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + hostEnvUrl: null, + token: null, + deploymentEnvironmentId: null, + intervalMs: DEFAULT_POLL_INTERVAL_MS, + maxAttempts: DEFAULT_MAX_POLL_ATTEMPTS, + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--hostEnvUrl' && args[i + 1]) out.hostEnvUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--deploymentEnvironmentId' && args[i + 1]) out.deploymentEnvironmentId = args[++i]; + else if (args[i] === '--intervalMs' && args[i + 1]) out.intervalMs = parseInt(args[++i], 10); + else if (args[i] === '--maxAttempts' && args[i + 1]) out.maxAttempts = parseInt(args[++i], 10); + } + + return out; +} + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +// The Deployment Pipeline Configuration app posts the GUID as +// `{UPPERCASE-GUID}`. Match that shape exactly — Dataverse parses both forms +// today but the bracketed form is the only one observed in production. +function formatGuidForStamp(guid) { + return `{${guid.toUpperCase()}}`; +} + +async function forceLinkEnvironment({ + hostEnvUrl, + token, + deploymentEnvironmentId, + intervalMs = DEFAULT_POLL_INTERVAL_MS, + maxAttempts = DEFAULT_MAX_POLL_ATTEMPTS, +} = {}) { + if (!hostEnvUrl) throw new Error('--hostEnvUrl is required'); + if (!token) throw new Error('--token is required'); + if (!deploymentEnvironmentId) throw new Error('--deploymentEnvironmentId is required'); + if (!GUID_REGEX.test(deploymentEnvironmentId)) { + throw new Error(`--deploymentEnvironmentId is not a valid GUID: ${deploymentEnvironmentId}`); + } + + const cleanHostEnvUrl = hostEnvUrl.replace(/\/+$/, ''); + const body = JSON.stringify({ + DeploymentEnvironmentId: formatGuidForStamp(deploymentEnvironmentId), + }); + + const res = await helpers.makeRequest({ + url: `${cleanHostEnvUrl}/api/data/v9.0/ManageEnvironmentStamp`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + clienthost: 'Browser', + prefer: 'odata.include-annotations="*"', + 'x-ms-app-name': 'AppDeploymentConfiguration', + }, + body, + includeHeaders: true, + timeout: 30000, + }); + + if (res.error) { + throw new Error(`ManageEnvironmentStamp request failed: ${res.error}`); + } + if (res.statusCode !== 204) { + // 403 typically means the caller lacks Deployment Pipeline Administrator + // role on the host. 404 = the deployment env record doesn't exist on this + // host (caller must create it first). Pass through the full body so the + // skill can surface remediation. + throw new Error( + `ManageEnvironmentStamp returned status ${res.statusCode}: ${(res.body || '').slice(0, 500)}`, + ); + } + + // Re-poll validationstatus until Succeeded or Failed. The action itself is + // synchronous (204 = stamp move accepted) but the record's validation flag + // re-runs asynchronously after the stamp moves. + // + // API version note: we use v9.0 here (not v9.1) because the entire Force + // Link flow — ManageEnvironmentStamp action + its post-action validation + // probe — was HAR-captured against v9.0 in the AppDeploymentConfiguration + // UI. Dataverse's OData surface is backwards-compatible across versions so + // mixing is functionally fine; we just keep this script aligned with what + // production actually ships. + let validationStatus = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await sleep(intervalMs); + + const pollRes = await helpers.makeRequest({ + url: `${cleanHostEnvUrl}/api/data/v9.0/deploymentenvironments(${deploymentEnvironmentId})?$select=validationstatus,errormessage,name`, + method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + timeout: 15000, + }); + + if (pollRes.error) { + throw new Error(`Poll deploymentenvironment failed: ${pollRes.error}`); + } + if (pollRes.statusCode !== 200) { + throw new Error( + `Poll deploymentenvironment returned ${pollRes.statusCode}: ${(pollRes.body || '').slice(0, 500)}`, + ); + } + + let pollData; + try { pollData = JSON.parse(pollRes.body); } catch (e) { + throw new Error(`Failed to parse poll response: ${e.message}`); + } + + validationStatus = pollData.validationstatus; + + if (validationStatus === VALIDATION_STATUS_SUCCEEDED) { + return { + deploymentEnvironmentId, + hostEnvUrl: cleanHostEnvUrl, + validationStatus, + forcedAt: new Date().toISOString(), + }; + } + if (validationStatus === VALIDATION_STATUS_FAILED) { + const errMsg = pollData.errormessage || '(no error details)'; + throw new Error( + `Force Link succeeded (stamp moved) but post-link validation failed: ${errMsg}`, + ); + } + } + + throw new Error( + `Force Link post-validation did not complete after ${maxAttempts} attempts. Last validationstatus: ${validationStatus}`, + ); +} + +if (require.main === module) { + const args = parseArgs(process.argv); + forceLinkEnvironment(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + forceLinkEnvironment, + formatGuidForStamp, + VALIDATION_STATUS_PENDING, + VALIDATION_STATUS_SUCCEEDED, + VALIDATION_STATUS_FAILED, +}; diff --git a/plugins/power-pages/scripts/lib/generate-env-var-schema-name.js b/plugins/power-pages/scripts/lib/generate-env-var-schema-name.js new file mode 100644 index 000000000..f4c2990e9 --- /dev/null +++ b/plugins/power-pages/scripts/lib/generate-env-var-schema-name.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +// Generates a canonical environmentvariabledefinition schema name from a +// site-setting name. Single source of truth so setup-solution and +// configure-env-variables (and any future skill that creates env var +// definitions from site settings) emit the SAME schema name for a given +// (publisherPrefix, settingName) input — required because setup-solution +// creates the definition and configure-env-variables / deploy-pipeline must +// reference it by the exact same schema name later. +// +// Canonical rule: +// schemaName = `${publisherPrefix}_${sanitizedSettingName}` +// sanitizedSettingName = settingName.replace(/[^A-Za-z0-9]/g, '_').toLowerCase() +// +// - Replace any non-alphanumeric character with `_` (covers `/`, `-`, ` `, +// `.`, etc. that may appear in mspp_sitesettings names). +// - Lowercase the result so `Authentication/.../LocalLoginEnabled` and +// `authentication/.../localloginenabled` produce the same schema name. +// - Collapse runs of underscores so `Foo//Bar` doesn't become `foo___bar`. +// - Trim leading/trailing underscores after the prefix join. +// +// Usage as a CLI: +// node generate-env-var-schema-name.js \ +// --publisherPrefix ids \ +// --settingName "Authentication/Registration/LocalLoginEnabled" +// → {"schemaName":"ids_authentication_registration_localloginenabled","sanitized":"authentication_registration_localloginenabled"} +// +// As a module (typical): +// const { generateSchemaName } = require('./generate-env-var-schema-name'); +// generateSchemaName({ settingName, publisherPrefix }) +// → { schemaName, sanitized } + +'use strict'; + +function sanitize(settingName) { + if (typeof settingName !== 'string' || settingName.length === 0) { + throw new Error('settingName must be a non-empty string'); + } + // Replace any non-alphanumeric with `_`, collapse runs of `_`, trim ends. + const replaced = settingName.replace(/[^A-Za-z0-9]+/g, '_'); + const trimmed = replaced.replace(/^_+|_+$/g, ''); + return trimmed.toLowerCase(); +} + +function generateSchemaName({ settingName, publisherPrefix } = {}) { + if (typeof publisherPrefix !== 'string' || publisherPrefix.length === 0) { + throw new Error('publisherPrefix must be a non-empty string'); + } + // Publisher prefixes in Dataverse are typically 2–8 lowercase chars; defend + // against callers passing the whole `prefix_` form by stripping a trailing _. + const prefix = publisherPrefix.toLowerCase().replace(/_+$/g, ''); + const sanitized = sanitize(settingName); + if (sanitized.length === 0) { + throw new Error(`settingName "${settingName}" sanitized to an empty string — cannot build schema name`); + } + return { + schemaName: `${prefix}_${sanitized}`, + sanitized, + }; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { settingName: null, publisherPrefix: null }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--settingName' && args[i + 1]) out.settingName = args[++i]; + else if (args[i] === '--publisherPrefix' && args[i + 1]) out.publisherPrefix = args[++i]; + } + return out; +} + +if (require.main === module) { + const { settingName, publisherPrefix } = parseArgs(process.argv); + try { + const result = generateSchemaName({ settingName, publisherPrefix }); + console.log(JSON.stringify(result)); + process.exit(0); + } catch (err) { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } +} + +module.exports = { + generateSchemaName, + sanitize, +}; diff --git a/plugins/power-pages/scripts/lib/install-pipelines-app.js b/plugins/power-pages/scripts/lib/install-pipelines-app.js new file mode 100644 index 000000000..2a4fdee9c --- /dev/null +++ b/plugins/power-pages/scripts/lib/install-pipelines-app.js @@ -0,0 +1,515 @@ +#!/usr/bin/env node + +// Installs the Power Platform Pipelines application package on an existing +// Dataverse environment. Replaces the manual PPAC click-through that +// ensure-pipelines-host Phase 4.B used to render. Same shape as the existing +// provision-* helpers (env-scoped POST + Location poll), with a PAC CLI +// fallback when the BAP path fails (e.g. token-audience mismatch in tenants +// where Az → BAP is rejected — the same scenario `pac-bap-shim.js` handles +// for env enumeration). +// +// Resolution order: +// 1. BAP applicationPackages list — discover the Pipelines package by +// uniqueName matching /msdyn_AppDeploymentAnchor|msdyn.*pipeline/i. +// If found and `properties.state === 'Installed'` → already installed, +// return alreadyInstalled=true (idempotent). +// 2. BAP applicationPackages install POST — submit the install. 200 sync, +// 202 + Location poll. Same polling pattern as provision-custom-host.js. +// 3. On 401/403/5xx (or transport error after retry), fall through to PAC: +// `pac application install --environment-id {envId} --application-list +// msdyn_AppDeploymentAnchor`. Same package, different client; PAC's +// first-party client ID has different BAP-RP grants than Az CLI. +// 4. Final verification: GET {instanceApiUrl}/api/data/v9.0/solutions +// ?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$top=1 — confirms +// the solution actually landed in Dataverse, not just that the BAP op +// succeeded. +// +// POST {bapBase}/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/{envId}/applicationPackages/{uniqueName}/install?api-version={apiVersion} +// Headers: +// Authorization: Bearer {bapToken} +// Content-Type: application/json +// x-ms-correlation-id: {uuid v4} +// +// Usage: +// node install-pipelines-app.js +// --bapToken --envId --instanceApiUrl +// [--hostToken ] // for verification step (Dataverse) +// [--allowPacFallback] // try PAC CLI on BAP failure (default: true) +// [--correlationId ] +// [--timeoutSec 600] +// [--apiVersion 2022-03-01-preview] +// [--bapBase ] +// +// Output (JSON to stdout): +// { +// status: 'Succeeded', +// alreadyInstalled: true | false, // 200 (idempotent) vs 202 (newly installed) vs PAC path +// installPath: 'bap' | 'pac' | 'cached', // which route succeeded +// packageUniqueName: 'msdyn_AppDeploymentAnchor', +// pipelinesSolutionVersion: '9.x.y.z' | null, +// durationSec: , +// correlationId: '', +// pollAttempts: , +// locationHeader: '' | null, +// pacFallbackReason: '' | null, // populated when installPath === 'pac' +// } +// +// Exit 0 on success, exit 1 on error (stderr includes status + body). + +'use strict'; + +const crypto = require('crypto'); +const { execSync } = require('child_process'); +const helpers = require('./validation-helpers'); + +const DEFAULT_API_VERSION = '2022-03-01-preview'; +const DEFAULT_BAP_BASE = 'https://api.bap.microsoft.com'; +const DEFAULT_TIMEOUT_SEC = 600; +const DEFAULT_RETRY_AFTER_SEC = 10; +const POST_TIMEOUT_MS = 60000; +const POLL_TIMEOUT_MS = 30000; + +// The Power Platform Pipelines application package's solution uniqueName. +// PPAC's "Install app" picker filters on this value; the BAP /applicationPackages +// list uses the same uniqueName. Listed in priority order — discovery uses the +// first match. +const PIPELINES_PACKAGE_UNIQUE_NAMES = ['msdyn_AppDeploymentAnchor']; +// Defensive secondary filter — if the catalog only exposes a package by +// displayName (some tenants), match these substrings (case-insensitive). +const PIPELINES_PACKAGE_DISPLAY_PATTERNS = [/power platform pipelines/i, /pipelines deployment/i]; + +const PIPELINES_SOLUTION_UNIQUE_NAME = 'msdyn_AppDeploymentAnchor'; + +function parseArgs(argv) { + const args = argv.slice(2); + const opts = { + bapToken: null, + envId: null, + instanceApiUrl: null, + hostToken: null, + allowPacFallback: true, + correlationId: null, + timeoutSec: DEFAULT_TIMEOUT_SEC, + apiVersion: DEFAULT_API_VERSION, + bapBase: DEFAULT_BAP_BASE, + }; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const next = args[i + 1]; + if (a === '--bapToken' && next) opts.bapToken = args[++i]; + else if (a === '--envId' && next) opts.envId = args[++i]; + else if (a === '--instanceApiUrl' && next) opts.instanceApiUrl = args[++i]; + else if (a === '--hostToken' && next) opts.hostToken = args[++i]; + else if (a === '--no-pac-fallback') opts.allowPacFallback = false; + else if (a === '--correlationId' && next) opts.correlationId = args[++i]; + else if (a === '--timeoutSec' && next) opts.timeoutSec = Number(args[++i]) || DEFAULT_TIMEOUT_SEC; + else if (a === '--apiVersion' && next) opts.apiVersion = args[++i]; + else if (a === '--bapBase' && next) opts.bapBase = args[++i]; + } + + return opts; +} + +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function extractProvisioningState(data) { + if (!data || typeof data !== 'object') return null; + if (data.properties && typeof data.properties.provisioningState === 'string') { + return data.properties.provisioningState; + } + if (typeof data.state === 'string') return data.state; + if (data.status && typeof data.status === 'object' && typeof data.status.code === 'string') { + return data.status.code; + } + if (typeof data.status === 'string') return data.status; + return null; +} + +function isTerminalSucceeded(state) { + if (!state) return false; + const s = String(state).toLowerCase(); + return s === 'succeeded' || s === 'succeeded.' || s === 'installed'; +} + +function isTerminalFailed(state) { + if (!state) return false; + const s = String(state).toLowerCase(); + return s === 'failed' || s === 'canceled' || s === 'cancelled'; +} + +function readRetryAfterSec(headers) { + if (!headers) return null; + const v = headers['retry-after'] || headers['Retry-After']; + if (!v) return null; + const n = Number(v); + return isFinite(n) && n > 0 ? n : null; +} + +// Discover the Pipelines application package on this env. Returns the +// canonical package object (name + state) or null if no Pipelines package +// is exposed for this env (rare — tenant policy can hide packages). +async function discoverPackage({ bapToken, envId, apiVersion, bapBase, correlationId }) { + const cleanBase = bapBase.replace(/\/+$/, ''); + const url = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/${encodeURIComponent(envId)}/applicationPackages?api-version=${encodeURIComponent(apiVersion)}`; + + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { + Authorization: `Bearer ${bapToken}`, + Accept: 'application/json', + 'x-ms-correlation-id': correlationId, + }, + timeout: POST_TIMEOUT_MS, + includeHeaders: true, + }); + + if (res.error) throw new Error(`BAP applicationPackages list failed: ${res.error}`); + if (res.statusCode === 401) throw new Error('BAP applicationPackages list returned 401 — refresh BAP token.'); + if (res.statusCode === 403) { + const e = new Error(`BAP applicationPackages list returned 403 — caller lacks Power Platform admin or tenant policy denies discovery. Body: ${(res.body || '').slice(0, 300)}`); + e.statusCode = 403; + throw e; + } + if (res.statusCode !== 200) { + throw new Error(`BAP applicationPackages list returned unexpected status ${res.statusCode}: ${(res.body || '').slice(0, 500)}`); + } + + let data = null; + try { data = JSON.parse(res.body); } catch { data = null; } + const items = Array.isArray(data?.value) ? data.value : []; + + // Try uniqueName match first (deterministic). + for (const target of PIPELINES_PACKAGE_UNIQUE_NAMES) { + const hit = items.find((p) => (p?.properties?.uniqueName || p?.name) === target); + if (hit) return normalizePackage(hit); + } + // Fall back to displayName / localizedDescription substring match. + for (const pat of PIPELINES_PACKAGE_DISPLAY_PATTERNS) { + const hit = items.find((p) => { + const dn = p?.properties?.localizedDescription || p?.properties?.applicationName || p?.properties?.displayName || ''; + return pat.test(dn); + }); + if (hit) return normalizePackage(hit); + } + return null; +} + +function normalizePackage(pkg) { + const props = pkg?.properties || {}; + return { + uniqueName: props.uniqueName || pkg?.name || null, + displayName: props.localizedDescription || props.applicationName || props.displayName || null, + state: props.state || null, + raw: pkg, + }; +} + +// PAC fallback path: shells out to `pac application install`. Used when the +// BAP install POST returns 401/403/5xx. +function tryPacFallback({ envId, packageUniqueName }) { + // Best-effort. PAC's argument names have varied across versions, so we try + // the modern form first and fall through to legacy on stderr signals. + const candidates = [ + ['application', 'install', '--environment-id', envId, '--application-list', packageUniqueName], + ['application', 'install', '--environment', envId, '--application-list', packageUniqueName], + ['admin', 'application', 'install', '--environment-id', envId, '--application-name-list', packageUniqueName], + ]; + let lastErr = null; + for (const argv of candidates) { + const cmd = ['pac', ...argv].map((a) => (/[\s"']/.test(a) ? `"${a}"` : a)).join(' '); + try { + const out = execSync(cmd, { encoding: 'utf8', timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] }); + return { ok: true, command: cmd, stdout: out }; + } catch (err) { + lastErr = err; + // Try next candidate if PAC reports an unrecognized arg / subcommand. + const stderr = (err.stderr || err.message || '').toLowerCase(); + if (!/unrecognized|unknown|invalid argument/i.test(stderr)) break; + } + } + return { ok: false, error: (lastErr && (lastErr.stderr || lastErr.message)) || 'pac fallback failed' }; +} + +// Verify the Pipelines solution actually landed in Dataverse after install. +// BAP can report success while the solution is still propagating. This is the +// single round-trip that confirms the install is real. +async function verifySolutionInstalled({ instanceApiUrl, hostToken }) { + if (!instanceApiUrl || !hostToken) { + return { ok: false, reason: 'instanceApiUrl or hostToken not provided — caller should verify separately' }; + } + const url = `${instanceApiUrl.replace(/\/+$/, '')}/api/data/v9.0/solutions?$filter=uniquename eq '${PIPELINES_SOLUTION_UNIQUE_NAME}'&$select=uniquename,version&$top=1`; + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { + Authorization: `Bearer ${hostToken}`, + Accept: 'application/json', + 'OData-Version': '4.0', + 'OData-MaxVersion': '4.0', + }, + timeout: POLL_TIMEOUT_MS, + }); + if (res.error) return { ok: false, reason: `solutions probe failed: ${res.error}` }; + if (res.statusCode !== 200) return { ok: false, reason: `solutions probe returned ${res.statusCode}` }; + let data = null; + try { data = JSON.parse(res.body); } catch { data = null; } + const row = Array.isArray(data?.value) && data.value.length > 0 ? data.value[0] : null; + if (!row) return { ok: false, reason: 'solution not found post-install — propagation may still be in progress' }; + return { ok: true, version: row.version || null }; +} + +async function installPipelinesApp(opts = {}) { + const { + bapToken, + envId, + instanceApiUrl, + hostToken = null, + allowPacFallback = true, + correlationId, + timeoutSec = DEFAULT_TIMEOUT_SEC, + apiVersion = DEFAULT_API_VERSION, + bapBase = DEFAULT_BAP_BASE, + sleepImpl = null, + nowImpl = null, + pacFallbackImpl = null, + } = opts; + + if (!bapToken) throw new Error('--bapToken is required'); + if (!envId) throw new Error('--envId is required'); + + const sleep = sleepImpl || defaultSleep; + const now = nowImpl || (() => Date.now()); + const pacFallback = pacFallbackImpl || tryPacFallback; + + const cleanBase = bapBase.replace(/\/+$/, ''); + const cid = correlationId || crypto.randomUUID(); + const startedAt = now(); + + // Discovery — also catches the idempotent "already installed" path. + let pkg; + try { + pkg = await discoverPackage({ bapToken, envId, apiVersion, bapBase, correlationId: cid }); + } catch (err) { + // 403 on the LIST endpoint usually means the BAP audience isn't the right + // one for this tenant. Skip straight to PAC fallback when permitted. + if (err.statusCode === 403 && allowPacFallback) { + pkg = null; + } else { + throw err; + } + } + + if (pkg && isTerminalSucceeded(pkg.state)) { + // Already installed — idempotent path. + let verifyResult = null; + if (instanceApiUrl && hostToken) { + verifyResult = await verifySolutionInstalled({ instanceApiUrl, hostToken }); + } + return { + status: 'Succeeded', + alreadyInstalled: true, + installPath: 'cached', + packageUniqueName: pkg.uniqueName || PIPELINES_PACKAGE_UNIQUE_NAMES[0], + pipelinesSolutionVersion: verifyResult?.version || null, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts: 0, + locationHeader: null, + pacFallbackReason: null, + }; + } + + // BAP install POST + const packageUniqueName = pkg?.uniqueName || PIPELINES_PACKAGE_UNIQUE_NAMES[0]; + const postUrl = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/${encodeURIComponent(envId)}/applicationPackages/${encodeURIComponent(packageUniqueName)}/install?api-version=${encodeURIComponent(apiVersion)}`; + const postRes = await helpers.makeRequest({ + url: postUrl, + method: 'POST', + headers: { + Authorization: `Bearer ${bapToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-ms-correlation-id': cid, + }, + body: '{}', + timeout: POST_TIMEOUT_MS, + includeHeaders: true, + }); + + // PAC fallback on transport error / 401 / 403 / 5xx + if ( + postRes.error + || postRes.statusCode === 401 + || postRes.statusCode === 403 + || (postRes.statusCode >= 500 && postRes.statusCode < 600) + ) { + if (!allowPacFallback) { + throw new Error(`BAP applicationPackages install failed (status ${postRes.statusCode || 'transport-error'}): ${(postRes.body || postRes.error || '').toString().slice(0, 500)}`); + } + const pacRes = pacFallback({ envId, packageUniqueName }); + if (!pacRes.ok) { + throw new Error(`BAP install failed and PAC fallback failed: BAP=${postRes.statusCode || postRes.error}; PAC=${pacRes.error}`); + } + let verifyResult = null; + if (instanceApiUrl && hostToken) { + // PAC returns once the install is committed; still verify in Dataverse. + verifyResult = await verifySolutionInstalled({ instanceApiUrl, hostToken }); + } + return { + status: 'Succeeded', + alreadyInstalled: false, + installPath: 'pac', + packageUniqueName, + pipelinesSolutionVersion: verifyResult?.version || null, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts: 0, + locationHeader: null, + pacFallbackReason: `BAP returned ${postRes.statusCode || postRes.error}`, + }; + } + + if (postRes.statusCode === 409) { + // Idempotent — install already in progress or already installed. + let verifyResult = null; + if (instanceApiUrl && hostToken) { + verifyResult = await verifySolutionInstalled({ instanceApiUrl, hostToken }); + } + return { + status: 'Succeeded', + alreadyInstalled: true, + installPath: 'cached', + packageUniqueName, + pipelinesSolutionVersion: verifyResult?.version || null, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts: 0, + locationHeader: null, + pacFallbackReason: null, + }; + } + + if (postRes.statusCode !== 200 && postRes.statusCode !== 202) { + throw new Error(`BAP applicationPackages install returned unexpected status ${postRes.statusCode}: ${(postRes.body || '').slice(0, 500)}`); + } + + let respBody = null; + if (postRes.body) { + try { respBody = JSON.parse(postRes.body); } catch { respBody = null; } + } + let provisioningState = extractProvisioningState(respBody) || 'Installing'; + const locationHeader = postRes.headers?.location || postRes.headers?.Location || null; + let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC; + + if (postRes.statusCode === 200 && isTerminalSucceeded(provisioningState)) { + let verifyResult = null; + if (instanceApiUrl && hostToken) { + verifyResult = await verifySolutionInstalled({ instanceApiUrl, hostToken }); + } + return { + status: 'Succeeded', + alreadyInstalled: false, + installPath: 'bap', + packageUniqueName, + pipelinesSolutionVersion: verifyResult?.version || null, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts: 0, + locationHeader, + pacFallbackReason: null, + }; + } + + if (!locationHeader) { + throw new Error('BAP applicationPackages install returned 202 but no Location header — cannot poll for completion.'); + } + + let pollAttempts = 0; + const deadline = startedAt + timeoutSec * 1000; + + while (now() < deadline) { + if (isTerminalSucceeded(provisioningState) || isTerminalFailed(provisioningState)) break; + await sleep(retryAfterSec * 1000); + pollAttempts++; + const pollRes = await helpers.makeRequest({ + url: locationHeader, + method: 'GET', + headers: { + Authorization: `Bearer ${bapToken}`, + Accept: 'application/json', + 'x-ms-correlation-id': cid, + }, + timeout: POLL_TIMEOUT_MS, + includeHeaders: true, + }); + + if (pollRes.error) continue; + if (pollRes.statusCode === 401) { + throw new Error('Polling returned 401 mid-install — token expired. Re-run after re-authenticating; the install may still be in progress.'); + } + if (pollRes.statusCode >= 500) continue; + if (pollRes.statusCode !== 200 && pollRes.statusCode !== 202) { + throw new Error(`Polling returned unexpected status ${pollRes.statusCode}: ${(pollRes.body || '').slice(0, 500)}`); + } + + let pollData = null; + try { pollData = JSON.parse(pollRes.body || '{}'); } catch { pollData = null; } + const newState = extractProvisioningState(pollData); + if (newState) provisioningState = newState; + const newRetryAfter = readRetryAfterSec(pollRes.headers); + if (newRetryAfter) retryAfterSec = newRetryAfter; + } + + if (isTerminalSucceeded(provisioningState)) { + let verifyResult = null; + if (instanceApiUrl && hostToken) { + verifyResult = await verifySolutionInstalled({ instanceApiUrl, hostToken }); + } + return { + status: 'Succeeded', + alreadyInstalled: false, + installPath: 'bap', + packageUniqueName, + pipelinesSolutionVersion: verifyResult?.version || null, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts, + locationHeader, + pacFallbackReason: null, + }; + } + + if (isTerminalFailed(provisioningState)) { + throw new Error(`Pipelines app install ended with state "${provisioningState}" after ${pollAttempts} poll(s). Inspect lifecycle op ${locationHeader} for details.`); + } + + throw new Error(`Pipelines app install timed out after ${timeoutSec}s (${pollAttempts} polls); last state: ${provisioningState}.`); +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + installPipelinesApp(opts) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + installPipelinesApp, + discoverPackage, + verifySolutionInstalled, + extractProvisioningState, + isTerminalSucceeded, + isTerminalFailed, + readRetryAfterSec, + PIPELINES_PACKAGE_UNIQUE_NAMES, + PIPELINES_PACKAGE_DISPLAY_PATTERNS, + PIPELINES_SOLUTION_UNIQUE_NAME, +}; diff --git a/plugins/power-pages/scripts/lib/link-site-setting-to-env-var.js b/plugins/power-pages/scripts/lib/link-site-setting-to-env-var.js new file mode 100644 index 000000000..a21fe9cf6 --- /dev/null +++ b/plugins/power-pages/scripts/lib/link-site-setting-to-env-var.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +// Links an mspp_sitesetting record to an environmentvariabledefinition via OData PATCH. +// Uses the v9.0 API (not v9.2) with specific headers required by the Power Pages Management app. +// +// HAR-confirmed pattern: Must use v9.0, not v9.2. Navigation property is "EnvironmentValue" +// (not "mspp_environmentvariable"). Headers "if-match: *" and "clienthost: Browser" are required +// — omitting them causes 400. This pattern was validated against a live environment. +// +// Usage: node link-site-setting-to-env-var.js +// --envUrl --token +// --siteSettingId +// --definitionId +// --schemaName +// +// Output (JSON to stdout): +// { "ok": true, "verified": true, "siteSettingId": "...", "definitionId": "..." } +// +// Exit 0 on success, exit 1 on failure. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { getAuthToken } = helpers; + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { envUrl: null, token: null, siteSettingId: null, definitionId: null, schemaName: null }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--envUrl' && args[i + 1]) result.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--siteSettingId' && args[i + 1]) result.siteSettingId = args[++i]; + else if (args[i] === '--definitionId' && args[i + 1]) result.definitionId = args[++i]; + else if (args[i] === '--schemaName' && args[i + 1]) result.schemaName = args[++i]; + } + + return result; +} + +async function linkSiteSettingToEnvVar({ envUrl, token, siteSettingId, definitionId, schemaName }) { + if (!envUrl || !siteSettingId || !definitionId || !schemaName) { + throw new Error('--envUrl, --siteSettingId, --definitionId, and --schemaName are all required'); + } + + const resolvedToken = token || getAuthToken(envUrl); + if (!resolvedToken) throw new Error('Failed to acquire Azure CLI token. Run `az login` first.'); + + // CRITICAL: Must use v9.0 API (not v9.2). Navigation property is "EnvironmentValue". + // Headers "if-match: *" and "clienthost: Browser" are required by the PP Management app endpoint. + const body = JSON.stringify({ + mspp_envvar_schema: schemaName, + 'EnvironmentValue@odata.bind': `/environmentvariabledefinitions(${definitionId})`, + 'EnvironmentValue@OData.Community.Display.V1.FormattedValue': schemaName, + mspp_source: 1, + }); + + const res = await helpers.makeRequest({ + url: `${envUrl}/api/data/v9.0/mspp_sitesettings(${siteSettingId})`, + method: 'PATCH', + headers: { + Authorization: `Bearer ${resolvedToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'if-match': '*', + clienthost: 'Browser', + 'x-ms-app-name': 'mspp_PowerPageManagement', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + body, + timeout: 30000, + }); + + if (res.error) throw new Error(`API request failed: ${res.error}`); + + if (res.statusCode !== 204 && res.statusCode !== 200) { + throw new Error( + `PATCH mspp_sitesettings failed (${res.statusCode}): ${res.body}\n` + + 'Hint: Check that siteSettingId and definitionId are valid GUIDs and that v9.0 API is used.' + ); + } + + // Verify the link was applied + const verifyUrl = new URL(`${envUrl}/api/data/v9.2/mspp_sitesettings(${siteSettingId})`); + verifyUrl.searchParams.set('$select', 'mspp_source,_mspp_environmentvariable_value,mspp_envvar_schema'); + + const verifyRes = await helpers.makeRequest({ + url: verifyUrl.toString(), + headers: { + Authorization: `Bearer ${resolvedToken}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + + let verified = false; + if (verifyRes.statusCode === 200) { + try { + const data = JSON.parse(verifyRes.body); + verified = + data.mspp_source === 1 && + data._mspp_environmentvariable_value === definitionId; + } catch {} + } + + return { ok: true, verified, siteSettingId, definitionId }; +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + linkSiteSettingToEnvVar(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { linkSiteSettingToEnvVar }; diff --git a/plugins/power-pages/scripts/lib/list-tenant-envs.js b/plugins/power-pages/scripts/lib/list-tenant-envs.js new file mode 100644 index 000000000..bd282b3c8 --- /dev/null +++ b/plugins/power-pages/scripts/lib/list-tenant-envs.js @@ -0,0 +1,462 @@ +#!/usr/bin/env node + +// Lists all BAP environments in the calling user's tenant, then per-env probes +// each candidate for Pipelines-solution presence. Used by Phase 2.5 of +// ensure-pipelines-host (the tenant-wide enumeration that disambiguates "no host +// bound to source env" vs "host exists but unbound"). +// +// Pre-filter (avoids probing every env in large tenants): +// - skip envs without Dataverse (linkedEnvironmentMetadata.instanceApiUrl == null) +// - skip envs not in --skus (default: Production; PE always included regardless) +// - sort remaining by lastModifiedTime desc +// - cap at --maxEnvsToProbe (default 50) +// +// Per-env probe (single Dataverse query — covers presence + version in one call): +// GET {instanceApiUrl}/api/data/v9.0/solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=version&$top=1 +// +// Classification: +// - environmentSku === 'Platform' AND Pipelines found → existingPlatformHost (one expected) +// - other sku AND Pipelines found → existingCustomHosts[] +// - has Dataverse, no Pipelines, accessible → eligibleForAppInstall[] +// - 401/403 from probe → inaccessibleEnvs[] +// - timeout / 5xx → inaccessibleEnvs[] +// +// Token acquisition: per-env Dataverse tokens via Azure CLI. The script invokes +// `az account get-access-token --resource ` for each env in parallel +// (bounded by --maxConcurrency) — cheap when the user is already signed in. +// +// Usage: +// node list-tenant-envs.js --bapToken +// [--skus Production,Sandbox] +// [--maxEnvsToProbe 50] +// [--maxConcurrency 10] +// [--probeTimeoutMs 5000] +// [--apiVersion 2020-06-01] +// [--bapBase https://api.bap.microsoft.com] +// +// Output (JSON to stdout): see module docstring at the bottom. + +'use strict'; + +const { execSync } = require('child_process'); +const helpers = require('./validation-helpers'); +const { verifyHostReadiness } = require('./verify-host-readiness'); +const { listEnvsViaPac } = require('./pac-bap-shim'); + +const DEFAULT_API_VERSION = '2020-06-01'; +const DEFAULT_BAP_BASE = 'https://api.bap.microsoft.com'; +// Default SKU filter for eligible-host enumeration. Production + Sandbox are +// both valid hosts for the Power Platform Pipelines app — the eng.ms doc +// describes the create-new fast-path as Production-only, but the *install on +// existing env* path (Phase 4.B) works on Sandbox too. Trial is opt-in +// (--skus Production,Sandbox,Trial) because Trial envs cannot use the +// env-create fast-path; surfacing them here would mislead users about +// what create-new can do, but they ARE valid for app-install. Defaulting to +// Production-only used to push trial-license tenants straight to the +// create-new path (which then fails with NotEnoughCapacity_HasTrialLicense), +// so widening the default to include Sandbox restores the existing-env +// option for the common Sandbox-only developer tenant. +const DEFAULT_SKUS = ['Production', 'Sandbox']; +const DEFAULT_MAX_ENVS = 30; +const DEFAULT_CONCURRENCY = 10; +const DEFAULT_PROBE_TIMEOUT_MS = 5000; + +// Name-hint patterns. Envs whose displayName or domainName contain these tokens +// are ranked first — Pipelines hosts are commonly named with these conventions. +const NAME_HINT_PATTERN = /\b(pipeline|deploy|host|alm|cicd|govern)/i; + +// Permissions strength — envs where the caller is an env admin probe earlier. +// Most Microsoft tenants give every user `ReadEnvironment` on every env, so that +// signal is uninformative; admin-class permissions are the discriminator. +const ADMIN_PERMS = new Set(['ListDatabaseEntities', 'CreateDatabaseEntities', 'ManageDatabaseUsers', 'AdminReadEnvironment', 'CreateBot', 'CreatePowerApp']); + +function parseArgs(argv) { + const args = argv.slice(2); + let bapToken = null; + let skus = null; + let maxEnvsToProbe = DEFAULT_MAX_ENVS; + let maxConcurrency = DEFAULT_CONCURRENCY; + let probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS; + let apiVersion = DEFAULT_API_VERSION; + let bapBase = DEFAULT_BAP_BASE; + let firstHitWins = false; + + let includeName = null; + let source = 'auto'; // auto | bap | pac + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--bapToken' && args[i + 1]) bapToken = args[++i]; + else if (args[i] === '--skus' && args[i + 1]) skus = args[++i].split(',').map((s) => s.trim()).filter(Boolean); + else if (args[i] === '--maxEnvsToProbe' && args[i + 1]) maxEnvsToProbe = Number(args[++i]) || DEFAULT_MAX_ENVS; + else if (args[i] === '--maxConcurrency' && args[i + 1]) maxConcurrency = Number(args[++i]) || DEFAULT_CONCURRENCY; + else if (args[i] === '--probeTimeoutMs' && args[i + 1]) probeTimeoutMs = Number(args[++i]) || DEFAULT_PROBE_TIMEOUT_MS; + else if (args[i] === '--apiVersion' && args[i + 1]) apiVersion = args[++i]; + else if (args[i] === '--bapBase' && args[i + 1]) bapBase = args[++i]; + else if (args[i] === '--firstHitWins') firstHitWins = true; + else if (args[i] === '--includeName' && args[i + 1]) includeName = args[++i]; + else if (args[i] === '--source' && args[i + 1]) source = args[++i]; + } + + if (!skus) skus = DEFAULT_SKUS; + return { bapToken, skus, maxEnvsToProbe, maxConcurrency, probeTimeoutMs, apiVersion, bapBase, firstHitWins, includeName, source }; +} + +async function listBapEnvs(bapToken, apiVersion, bapBase) { + if (!bapToken) throw new Error('BAP token required for source=bap'); + const cleanBase = bapBase.replace(/\/+$/, ''); + const url = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments?api-version=${encodeURIComponent(apiVersion)}&$expand=${encodeURIComponent('properties.linkedEnvironmentMetadata,properties.permissions')}`; + + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { Authorization: `Bearer ${bapToken}`, Accept: 'application/json' }, + timeout: 30000, + }); + + if (res.error) { + const err = new Error(`BAP env-list failed: ${res.error}`); + err.statusCode = null; + throw err; + } + if (res.statusCode !== 200) { + const err = new Error(`BAP env-list returned ${res.statusCode}: ${res.body.slice(0, 300)}`); + err.statusCode = res.statusCode; + throw err; + } + + let data; + try { data = JSON.parse(res.body); } catch (e) { + throw new Error(`Failed to parse BAP env-list response: ${e.message}`); + } + return Array.isArray(data.value) ? data.value : []; +} + +// Picks the right env-list source: BAP HTTP, PAC CLI shim, or auto-detect. +// Returns { envs, sourceUsed, fallbackReason? }. +async function listEnvsBySource({ source, bapToken, apiVersion, bapBase, listImpl, pacExecImpl }) { + // listImpl is the test-injection point (from BAP path). When provided, we + // honor it as a "BAP-source mock" since most existing tests use it that way. + if (listImpl) { + return { envs: await listImpl({ bapToken, apiVersion, bapBase }), sourceUsed: 'bap-mock' }; + } + + if (source === 'pac') { + const envs = await listEnvsViaPac({ execImpl: pacExecImpl }); + return { envs, sourceUsed: 'pac' }; + } + + if (source === 'bap') { + const envs = await listBapEnvs(bapToken, apiVersion, bapBase); + return { envs, sourceUsed: 'bap' }; + } + + // auto: try BAP first if a token is available, else PAC + if (!bapToken) { + const envs = await listEnvsViaPac({ execImpl: pacExecImpl }); + return { envs, sourceUsed: 'pac', fallbackReason: 'no-bap-token-provided' }; + } + try { + const envs = await listBapEnvs(bapToken, apiVersion, bapBase); + return { envs, sourceUsed: 'bap' }; + } catch (e) { + // 401/403/auth errors → fallback to PAC. Other errors (network, parse) bubble up. + const sc = e.statusCode; + if (sc === 401 || sc === 403) { + try { + const envs = await listEnvsViaPac({ execImpl: pacExecImpl }); + return { envs, sourceUsed: 'pac', fallbackReason: `bap-rejected-${sc}` }; + } catch (pacErr) { + // Both failed — surface BAP error which is more diagnostic + throw e; + } + } + throw e; + } +} + +function getDataverseToken(originUrl, getTokenImpl) { + // Pluggable for tests. Default impl shells out to `az`. + if (typeof getTokenImpl === 'function') return getTokenImpl(originUrl); + try { + const out = execSync(`az account get-access-token --resource "${originUrl}" --query accessToken -o tsv`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return out.trim(); + } catch (e) { + throw new Error(`az token acquisition failed for ${originUrl}: ${e.message || e.stderr?.toString() || 'unknown'}`); + } +} + +// Extracts the origin (scheme + host) from a full URL. +function originOf(url) { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}`; + } catch { + return null; + } +} + +// Computes a heuristic rank score per env. Higher = probe earlier. +// The score combines three signals (each ~0–1, summed): +// - nameHint: displayName/domainName matches "pipeline|deploy|host|alm|cicd|govern" → +1.0 +// - hasAdminPerms: caller has any admin-class permission on the env → +0.5 +// - recency: lastModifiedTime sort tiebreaker → up to +0.25 across the list +// Recency is normalized to a small fraction so it's a tiebreaker only. +function computeRankScore(env, recencyRank, totalRanked) { + const text = `${env.displayName || ''} ${env.domainName || ''}`; + const nameHit = NAME_HINT_PATTERN.test(text) ? 1.0 : 0; + const hasAdminPerms = (env._permKeys || []).some((k) => ADMIN_PERMS.has(k)) ? 0.5 : 0; + const recency = totalRanked > 1 ? (1 - recencyRank / Math.max(1, totalRanked - 1)) * 0.25 : 0; + return nameHit + hasAdminPerms + recency; +} + +// Pre-filter and rank envs from the BAP list. +// Returns { candidates, totalEnvsInTenant, envsAfterFilter }. +function preFilter(envs, allowedSkus, includeNameSubstring = null) { + const skuSet = new Set(allowedSkus); + // PE always included regardless of --skus filter. + skuSet.add('Platform'); + + const includeNameLower = includeNameSubstring ? String(includeNameSubstring).toLowerCase() : null; + + const filtered = []; + for (const env of envs) { + const props = env.properties || {}; + const linked = props.linkedEnvironmentMetadata || {}; + if (!linked.instanceApiUrl) continue; // No Dataverse → cannot host Pipelines. + if (!skuSet.has(props.environmentSku)) continue; + + // Hard name filter — only envs whose displayName / domainName contain the + // user-supplied substring are considered. Useful in large tenants when the + // user knows part of their host's name. Case-insensitive. + if (includeNameLower) { + const hay = `${props.displayName || ''} ${linked.domainName || ''}`.toLowerCase(); + if (!hay.includes(includeNameLower)) continue; + } + + const permKeys = props.permissions ? Object.keys(props.permissions) : []; + filtered.push({ + envId: env.name || null, + displayName: props.displayName || null, + environmentSku: props.environmentSku || null, + instanceUrl: linked.instanceUrl || null, + instanceApiUrl: linked.instanceApiUrl, + isManaged: !!linked.isManaged, + domainName: linked.domainName || null, + lastModifiedTime: props.lastModifiedTime || null, + tenantId: props.tenantId || null, + _permKeys: permKeys, + }); + } + + // Compute recency rank first. + const byRecency = [...filtered].sort((a, b) => { + const ta = Date.parse(a.lastModifiedTime || '1970-01-01') || 0; + const tb = Date.parse(b.lastModifiedTime || '1970-01-01') || 0; + return tb - ta; + }); + const recencyRank = new Map(byRecency.map((e, i) => [e.envId, i])); + + // Sort by composite score desc. Stable on env ID for determinism. + filtered.sort((a, b) => { + const sa = computeRankScore(a, recencyRank.get(a.envId) || 0, filtered.length); + const sb = computeRankScore(b, recencyRank.get(b.envId) || 0, filtered.length); + if (sb !== sa) return sb - sa; + return (a.envId || '').localeCompare(b.envId || ''); + }); + + // Strip internal helper field before returning. + const candidates = filtered.map(({ _permKeys, ...rest }) => rest); + return { candidates, totalEnvsInTenant: envs.length, envsAfterFilter: candidates.length }; +} + +async function probeOne(env, { probeTimeoutMs, getTokenImpl, verifyImpl }) { + const origin = originOf(env.instanceApiUrl); + if (!origin) { + return { envId: env.envId, classification: 'inaccessible', reason: 'invalid-instance-api-url' }; + } + + let token; + try { token = getDataverseToken(origin, getTokenImpl); } + catch (e) { + return { envId: env.envId, classification: 'inaccessible', reason: 'token-acquisition-failed', detail: e.message }; + } + + const verify = verifyImpl || verifyHostReadiness; + const result = await verify({ + hostEnvUrl: env.instanceApiUrl, + hostToken: token, + skipWhoAmI: true, // for bulk probing, the solutions query alone is the signal + }); + + // Sanity: verify-host-readiness exits 0 always; we get a result object. + // Map to classification. + if (!result.checks?.solutions?.ok) { + const code = result.checks?.solutions?.statusCode; + if (code === 401 || code === 403) { + return { envId: env.envId, classification: 'inaccessible', reason: 'forbidden', statusCode: code }; + } + if (code === 404) { + // No Dataverse / wrong URL — not a candidate, but not an error. + return { envId: env.envId, classification: 'not-eligible', reason: '404-on-solutions' }; + } + return { envId: env.envId, classification: 'inaccessible', reason: result.checks?.solutions?.error || 'probe-failed' }; + } + + if (result.checks.solutions.found) { + if (env.environmentSku === 'Platform') { + return { envId: env.envId, classification: 'platform-host', pipelinesSolutionVersion: result.pipelinesSolutionVersion }; + } + return { envId: env.envId, classification: 'custom-host', pipelinesSolutionVersion: result.pipelinesSolutionVersion }; + } + + // Has Dataverse, no Pipelines installed. + return { envId: env.envId, classification: 'eligible-for-app-install' }; +} + +// Concurrency pool with optional early-cancel. The cancel signal ({stopped: true}) +// lets workers exit cleanly when --firstHitWins fires. +async function runWithCancel(items, fn, concurrency) { + const results = new Array(items.length); + const ctl = { stopped: false }; + let cursor = 0; + async function worker() { + while (!ctl.stopped) { + const i = cursor++; + if (i >= items.length) return; + try { results[i] = await fn(items[i], i, ctl); } + catch (e) { results[i] = { _error: e.message }; } + } + } + const n = Math.min(Math.max(concurrency, 1), items.length); + if (n === 0) return { results, stopped: false }; + await Promise.all(Array.from({ length: n }, () => worker())); + return { results, stopped: ctl.stopped }; +} + +async function listTenantEnvs(opts = {}) { + const { + bapToken, + skus = DEFAULT_SKUS, + maxEnvsToProbe = DEFAULT_MAX_ENVS, + maxConcurrency = DEFAULT_CONCURRENCY, + probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, + apiVersion = DEFAULT_API_VERSION, + bapBase = DEFAULT_BAP_BASE, + firstHitWins = false, + includeName = null, + source = 'auto', + // Test injection points: + listImpl = null, // ({ bapToken, apiVersion, bapBase }) => Promise (BAP-source mock) + getTokenImpl = null, + verifyImpl = null, + pacExecImpl = null, // PAC-source mock (replaces execFile) + } = opts; + + if (source === 'bap' && !bapToken && !listImpl) { + throw new Error('--bapToken is required when --source bap'); + } + + const startedAt = Date.now(); + + const { envs, sourceUsed, fallbackReason } = await listEnvsBySource({ + source, + bapToken, + apiVersion, + bapBase, + listImpl, + pacExecImpl, + }); + + const { candidates, totalEnvsInTenant, envsAfterFilter } = preFilter(envs, skus, includeName); + + const toProbe = candidates.slice(0, maxEnvsToProbe); + const hitProbeCap = candidates.length > maxEnvsToProbe; + + const { results: probeResults, stopped: earlyExit } = await runWithCancel( + toProbe, + async (env, i, ctl) => { + const r = await probeOne(env, { probeTimeoutMs, getTokenImpl, verifyImpl }); + if (firstHitWins && (r.classification === 'custom-host' || r.classification === 'platform-host')) { + ctl.stopped = true; + } + return r; + }, + maxConcurrency, + ); + + const out = { + existingCustomHosts: [], + existingPlatformHost: null, + eligibleForAppInstall: [], + inaccessibleEnvs: [], + inaccessibilityBreakdown: { 'token-acquisition-failed': 0, 'forbidden': 0, '404-on-solutions': 0, 'unknown': 0 }, + totalEnvsInTenant, + envsAfterFilter, + envsProbed: toProbe.length, + earlyExitOnFirstHit: earlyExit, + hitProbeCap, + probeDurationMs: Date.now() - startedAt, + skusFilter: skus, + firstHitWins, + includeNameFilter: includeName || null, + sourceUsed, + fallbackReason: fallbackReason || null, + }; + + for (let i = 0; i < toProbe.length; i++) { + const env = toProbe[i]; + const r = probeResults[i] || {}; + const base = { + envId: env.envId, + displayName: env.displayName, + environmentSku: env.environmentSku, + instanceUrl: env.instanceUrl, + instanceApiUrl: env.instanceApiUrl, + isManaged: env.isManaged, + domainName: env.domainName, + }; + + if (r.classification === 'platform-host') { + out.existingPlatformHost = { ...base, pipelinesSolutionVersion: r.pipelinesSolutionVersion }; + } else if (r.classification === 'custom-host') { + out.existingCustomHosts.push({ ...base, pipelinesSolutionVersion: r.pipelinesSolutionVersion }); + } else if (r.classification === 'eligible-for-app-install') { + out.eligibleForAppInstall.push(base); + } else if (r.classification === 'not-eligible') { + // Not counted as inaccessible — env exists but isn't a host candidate. + } else { + out.inaccessibleEnvs.push({ ...base, reason: r.reason || 'unknown', detail: r.detail }); + const k = r.reason && out.inaccessibilityBreakdown[r.reason] !== undefined ? r.reason : 'unknown'; + out.inaccessibilityBreakdown[k]++; + } + } + + // Squash empty breakdown keys for compactness. + for (const k of Object.keys(out.inaccessibilityBreakdown)) { + if (out.inaccessibilityBreakdown[k] === 0) delete out.inaccessibilityBreakdown[k]; + } + + return out; +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + listTenantEnvs(opts) + .then((result) => { + const foundAny = result.existingCustomHosts.length > 0 || !!result.existingPlatformHost; + if (!foundAny && result.hitProbeCap) { + process.stderr.write( + `[hint] Probed ${result.envsProbed} of ${result.envsAfterFilter} envs after filtering (cap reached). No host found among them. ` + + `Pass --maxEnvsToProbe ${result.envsAfterFilter} to scan all, or --includeName "" to narrow by name.\n`, + ); + } + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { listTenantEnvs, preFilter, originOf }; diff --git a/plugins/power-pages/scripts/lib/pac-bap-shim.js b/plugins/power-pages/scripts/lib/pac-bap-shim.js new file mode 100644 index 000000000..3389cfd81 --- /dev/null +++ b/plugins/power-pages/scripts/lib/pac-bap-shim.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +// PAC-CLI shim for BAP env-list / env-GET. Provides the same data shape as +// resolve-env-by-id.js and list-tenant-envs.js consume from BAP, but sourced +// from `pac admin list --json` instead. +// +// Why this exists: BAP API at api.bap.microsoft.com rejects Az-CLI-acquired +// tokens in some tenants (verified 2026-04-28: D365DemoTSCE53051106 demo +// tenant returns 401 InvalidAuthenticationToken even though token claims show +// the right user/tenant/audience). PAC CLI succeeds because it uses a +// different first-party client ID with implicit BAP grants. +// +// This shim is the read-side fallback: enables our detection scripts to work +// in tenants where Az→BAP fails, and is also a sensible default since PAC is +// the canonical Power Platform CLI everyone has installed. +// +// Mapping (PAC field → BAP field): +// EnvironmentId → name +// DisplayName → properties.displayName +// EnvironmentUrl → properties.linkedEnvironmentMetadata.instanceUrl +// OrganizationId → properties.linkedEnvironmentMetadata.resourceId +// Type → properties.environmentSku (Developer/Production/Sandbox/Default/Trial) +// DomainName → properties.linkedEnvironmentMetadata.domainName +// Version → properties.linkedEnvironmentMetadata.version +// → properties.linkedEnvironmentMetadata.instanceApiUrl +// +// Fields not provided by PAC (returned as null): tenantId, location, +// lastModifiedTime, permissions, isManaged. Callers must tolerate null in +// those fields (none are critical for host detection). +// +// Usage: +// const { listEnvsViaPac, resolveEnvByIdViaPac, deriveInstanceApiUrl } = require('./pac-bap-shim'); +// const envs = await listEnvsViaPac(); // BAP-shaped array +// const env = await resolveEnvByIdViaPac(id); // BAP-shaped single env (or null) + +'use strict'; + +const { execFile } = require('child_process'); +const { promisify } = require('util'); + +const execFileAsync = promisify(execFile); + +// Derives an instanceApiUrl from the EnvironmentUrl PAC reports. +// Examples: +// https://org5fbe4359.crm5.dynamics.com/ → https://org5fbe4359.api.crm5.dynamics.com +// https://contoso.crm.dynamics.com/ → https://contoso.api.crm.dynamics.com +// https://contoso.crm9.dynamics.com → https://contoso.api.crm9.dynamics.com +function deriveInstanceApiUrl(environmentUrl) { + if (!environmentUrl) return null; + // Strip trailing slash + const clean = environmentUrl.replace(/\/+$/, ''); + // Insert ".api" before ".crm{N?}.dynamics.com" + // Regex: ^(https?://[^.]+)\.(crm\d*\.dynamics\.com)$ → $1.api.$2 + const match = clean.match(/^(https?:\/\/[^.]+)\.(crm\d*\.dynamics\.com)$/i); + if (match) { + return `${match[1]}.api.${match[2]}`; + } + // Government clouds and other hosts: pass through unchanged. Caller can + // override via direct BAP if needed. + return clean; +} + +// Maps PAC sku/type values to BAP environmentSku values. They overlap mostly +// 1:1 but PAC uses "Default" for the per-user default env where BAP uses +// "Default" too — pass through. "Platform" envs are not surfaced by +// `pac admin list` (PE is hidden from PAC), so the shim cannot help with PE +// detection. Callers needing PE must use BAP directly. +function mapPacTypeToSku(pacType) { + // PAC values seen: Developer, Production, Sandbox, Default, Trial, Teams. + // BAP values: same set, plus Platform (which PAC won't return). + return pacType || null; +} + +// Converts one PAC env record to a BAP-like env shape (subset). +function pacToBapEnv(pacEnv) { + if (!pacEnv) return null; + const url = pacEnv.EnvironmentUrl ? pacEnv.EnvironmentUrl.replace(/\/+$/, '') + '/' : null; + return { + name: pacEnv.EnvironmentId || null, + type: 'Microsoft.BusinessAppPlatform/environments', + location: null, // not provided by PAC + properties: { + displayName: pacEnv.DisplayName || null, + environmentSku: mapPacTypeToSku(pacEnv.Type), + tenantId: null, + lastModifiedTime: null, + permissions: null, + linkedEnvironmentMetadata: { + resourceId: pacEnv.OrganizationId || null, + instanceUrl: url, + instanceApiUrl: deriveInstanceApiUrl(pacEnv.EnvironmentUrl), + domainName: pacEnv.DomainName || null, + version: pacEnv.Version || null, + }, + }, + }; +} + +// Runs `pac admin list --json` and parses the output. Throws on non-zero exit. +async function runPacAdminList(execImpl) { + const exec = execImpl || execFileAsync; + let stdout; + try { + const res = await exec('pac', ['admin', 'list', '--json'], { + maxBuffer: 16 * 1024 * 1024, + shell: false, + }); + stdout = res.stdout; + } catch (e) { + throw new Error(`pac admin list failed: ${e.message}`); + } + + // PAC may print "Connected as ..." headers + "Listing..." prose before the + // JSON. Find the first '[' and parse from there. Also tolerates trailing + // text. PAC's --json on `pac admin list` outputs a single array. + const jsonStart = stdout.indexOf('['); + if (jsonStart < 0) { + throw new Error(`pac admin list returned no JSON array. Output: ${stdout.slice(0, 300)}`); + } + const jsonText = stdout.slice(jsonStart); + let parsed; + try { + parsed = JSON.parse(jsonText); + } catch (e) { + throw new Error(`Failed to parse pac admin list JSON: ${e.message}. First 300 chars: ${jsonText.slice(0, 300)}`); + } + if (!Array.isArray(parsed)) { + throw new Error(`pac admin list JSON is not an array: ${typeof parsed}`); + } + return parsed; +} + +// Returns all envs the current PAC profile has visibility into, in BAP-like +// shape suitable for callers that previously read from BAP env-list. +// `execImpl` is for tests (replaces execFileAsync). +async function listEnvsViaPac({ execImpl } = {}) { + const pacEnvs = await runPacAdminList(execImpl); + return pacEnvs.map(pacToBapEnv); +} + +// Returns one env in BAP-like shape (or null if not found). Filters the full +// list by EnvironmentId. PAC has no per-env GET command that returns the same +// shape, so this is the cheapest correct approach (single PAC invocation). +async function resolveEnvByIdViaPac({ envId, execImpl } = {}) { + if (!envId) throw new Error('envId is required'); + const all = await listEnvsViaPac({ execImpl }); + const target = (all.find((e) => (e.name || '').toLowerCase() === String(envId).toLowerCase())) || null; + return target; +} + +// Verifies that PAC CLI is signed in to a profile we can use. Returns +// { ok: bool, error?: string, user?: string }. +async function checkPacAuth(execImpl) { + const exec = execImpl || execFileAsync; + try { + const res = await exec('pac', ['env', 'who'], { maxBuffer: 1024 * 1024, shell: false }); + const out = res.stdout || ''; + // "Connected as " line appears in pac env who output. + const m = out.match(/Connected as\s+(\S+)/i); + if (m) return { ok: true, user: m[1].trim() }; + // pac env who succeeded but no "Connected as" — still treat as ok + return { ok: true, user: null }; + } catch (e) { + return { ok: false, error: e.message }; + } +} + +if (require.main === module) { + // CLI: print BAP-shaped env list as JSON. Useful for ad-hoc debugging. + listEnvsViaPac() + .then((envs) => { + console.log(JSON.stringify(envs, null, 2)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + listEnvsViaPac, + resolveEnvByIdViaPac, + checkPacAuth, + deriveInstanceApiUrl, + mapPacTypeToSku, + pacToBapEnv, + runPacAdminList, +}; diff --git a/plugins/power-pages/scripts/lib/poll-deployment-status.js b/plugins/power-pages/scripts/lib/poll-deployment-status.js new file mode 100644 index 000000000..f7eb5bdc1 --- /dev/null +++ b/plugins/power-pages/scripts/lib/poll-deployment-status.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +// Polls stagerunstatus on a deploymentstageruns record until a terminal state. +// +// Usage: node poll-deployment-status.js --hostEnvUrl --token --stageRunId +// [--intervalMs ] [--maxAttempts ] +// +// Terminal states: +// 200000002 = Succeeded +// 200000003 = Failed +// 200000004 = Canceled +// 200000005 = PendingApproval (post-validation approval gate — returns without error) +// 200000008 = AwaitingPreDeployApproval (pre-deploy approval gate — returns without error) +// +// Output (JSON to stdout): +// { "stageRunId": "...", "status": "Succeeded|Awaiting", "errorDetails": "" } +// +// Exit 0 on success or awaiting approval, exit 1 on failure (error on stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +const STATUS_SUCCEEDED = 200000002; +const STATUS_FAILED = 200000003; +const STATUS_CANCELED = 200000004; +const STATUS_PENDING_APPROVAL = 200000005; +const STATUS_AWAITING_PRE_DEPLOY = 200000008; + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { + hostEnvUrl: null, + token: null, + stageRunId: null, + intervalMs: 8000, + maxAttempts: 75, + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--hostEnvUrl' && args[i + 1]) result.hostEnvUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--stageRunId' && args[i + 1]) result.stageRunId = args[++i]; + else if (args[i] === '--intervalMs' && args[i + 1]) result.intervalMs = parseInt(args[++i], 10); + else if (args[i] === '--maxAttempts' && args[i + 1]) result.maxAttempts = parseInt(args[++i], 10); + } + + return result; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pollDeploymentStatus({ hostEnvUrl, token, stageRunId, intervalMs = 8000, maxAttempts = 75 }) { + if (!hostEnvUrl || !token || !stageRunId) { + throw new Error('Missing required arguments: --hostEnvUrl, --token, --stageRunId'); + } + + const baseUrl = hostEnvUrl.replace(/\/+$/, ''); + const url = `${baseUrl}/api/data/v9.0/deploymentstageruns(${stageRunId})?$select=stagerunstatus,errormessage`; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 30000, + }); + + if (res.error) { + throw new Error(`Request failed: ${res.error}`); + } + + if (res.statusCode !== 200) { + throw new Error(`Unexpected status ${res.statusCode}: ${res.body}`); + } + + let data; + try { + data = JSON.parse(res.body); + } catch { + throw new Error(`Invalid JSON response: ${res.body}`); + } + + const stageRunStatus = data.stagerunstatus; + const errorMessage = data.errormessage || null; + + if (stageRunStatus === STATUS_SUCCEEDED) { + return { stageRunId, status: 'Succeeded', errorDetails: null }; + } + + if (stageRunStatus === STATUS_PENDING_APPROVAL || stageRunStatus === STATUS_AWAITING_PRE_DEPLOY) { + // Approval gate — non-blocking, return for caller to handle + return { stageRunId, status: 'Awaiting', errorDetails: null }; + } + + if (stageRunStatus === STATUS_FAILED || stageRunStatus === STATUS_CANCELED) { + const label = stageRunStatus === STATUS_FAILED ? 'Failed' : 'Canceled'; + throw new Error( + `Deployment ${label} (stageRunStatus=${stageRunStatus}). Error: ${errorMessage || '(none)'}` + ); + } + + // Still in progress — wait and retry + if (attempt < maxAttempts) { + await sleep(intervalMs); + } + } + + throw new Error( + `Deployment polling timed out after ${maxAttempts} attempts (${Math.round((maxAttempts * intervalMs) / 1000)}s). ` + + 'Check status in Power Platform.' + ); +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + pollDeploymentStatus(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { pollDeploymentStatus }; diff --git a/plugins/power-pages/scripts/lib/poll-validation-status.js b/plugins/power-pages/scripts/lib/poll-validation-status.js new file mode 100644 index 000000000..7f315bb42 --- /dev/null +++ b/plugins/power-pages/scripts/lib/poll-validation-status.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +// Polls the operation field on a deploymentstageruns record until it leaves the "validating" state. +// +// Usage: node poll-validation-status.js --hostEnvUrl --token --stageRunId +// [--intervalMs ] [--maxAttempts ] +// +// Output (JSON to stdout): +// { "stageRunId": "...", "validationResults": "", "stageRunStatus": } +// +// Exit 0 on success, exit 1 on failure (error on stderr). + +'use strict'; + +const helpers = require('./validation-helpers'); + +const STAGE_RUN_STATUS_VALIDATION_SUCCEEDED = 200000007; +const STAGE_RUN_STATUS_FAILED = 200000003; + +function parseArgs(argv) { + const args = argv.slice(2); + const result = { + hostEnvUrl: null, + token: null, + stageRunId: null, + intervalMs: 5000, + maxAttempts: 36, + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--hostEnvUrl' && args[i + 1]) result.hostEnvUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) result.token = args[++i]; + else if (args[i] === '--stageRunId' && args[i + 1]) result.stageRunId = args[++i]; + else if (args[i] === '--intervalMs' && args[i + 1]) result.intervalMs = parseInt(args[++i], 10); + else if (args[i] === '--maxAttempts' && args[i + 1]) result.maxAttempts = parseInt(args[++i], 10); + } + + return result; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pollValidationStatus({ hostEnvUrl, token, stageRunId, intervalMs = 5000, maxAttempts = 36 }) { + if (!hostEnvUrl || !token || !stageRunId) { + throw new Error('Missing required arguments: --hostEnvUrl, --token, --stageRunId'); + } + + const baseUrl = hostEnvUrl.replace(/\/+$/, ''); + const url = `${baseUrl}/api/data/v9.0/deploymentstageruns(${stageRunId})?$select=operation,validationresults,stagerunstatus`; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 30000, + }); + + if (res.error) { + throw new Error(`Request failed: ${res.error}`); + } + + if (res.statusCode !== 200) { + throw new Error(`Unexpected status ${res.statusCode}: ${res.body}`); + } + + let data; + try { + data = JSON.parse(res.body); + } catch { + throw new Error(`Invalid JSON response: ${res.body}`); + } + + const stageRunStatus = data.stagerunstatus; + const validationResults = data.validationresults || null; + + // Validation succeeded — done + if (stageRunStatus === STAGE_RUN_STATUS_VALIDATION_SUCCEEDED) { + return { stageRunId, validationResults, stageRunStatus }; + } + + // Validation failed + if (stageRunStatus === STAGE_RUN_STATUS_FAILED) { + throw new Error( + `Validation failed (stageRunStatus=${stageRunStatus}). Validation results: ${validationResults || '(none)'}` + ); + } + + // Still validating — wait and retry + if (attempt < maxAttempts) { + await sleep(intervalMs); + continue; + } + } + + throw new Error( + `Validation polling timed out after ${maxAttempts} attempts. Check status in Power Platform.` + ); +} + +// CLI entry point +if (require.main === module) { + const args = parseArgs(process.argv); + + pollValidationStatus(args) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { pollValidationStatus }; diff --git a/plugins/power-pages/scripts/lib/powerpages-hook-utils.js b/plugins/power-pages/scripts/lib/powerpages-hook-utils.js index bd8952625..a7c8878c4 100644 --- a/plugins/power-pages/scripts/lib/powerpages-hook-utils.js +++ b/plugins/power-pages/scripts/lib/powerpages-hook-utils.js @@ -9,12 +9,30 @@ const TRACKED_SKILLS = { 'audit-permissions': { validatorScript: 'skills/audit-permissions/scripts/validate-audit.js', }, + 'configure-env-variables': { + validatorScript: 'skills/configure-env-variables/scripts/validate-env-variables.js', + }, 'create-site': { validatorScript: 'skills/create-site/scripts/validate-site.js', }, 'create-webroles': { validatorScript: 'skills/create-webroles/scripts/validate-webroles.js', }, + 'deploy-pipeline': { + validatorScript: 'skills/deploy-pipeline/scripts/validate-deploy-pipeline.js', + }, + 'ensure-pipelines-host': { + validatorScript: 'skills/ensure-pipelines-host/scripts/validate-ensure-host.js', + }, + 'force-link-environment': { + validatorScript: 'skills/force-link-environment/scripts/validate-force-link.js', + }, + 'export-solution': { + validatorScript: 'skills/export-solution/scripts/validate-export.js', + }, + 'import-solution': { + validatorScript: 'skills/import-solution/scripts/validate-import.js', + }, 'add-cloud-flow': { validatorScript: 'skills/add-cloud-flow/scripts/validate-cloudflow.js', }, @@ -24,12 +42,21 @@ const TRACKED_SKILLS = { 'integrate-webapi': { validatorScript: 'skills/integrate-webapi/scripts/validate-webapi-integration.js', }, + 'plan-alm': { + validatorScript: 'skills/plan-alm/scripts/validate-plan-alm.js', + }, 'setup-auth': { validatorScript: 'skills/setup-auth/scripts/validate-auth.js', }, 'setup-datamodel': { validatorScript: 'skills/setup-datamodel/scripts/validate-datamodel.js', }, + 'setup-pipeline': { + validatorScript: 'skills/setup-pipeline/scripts/validate-pipeline.js', + }, + 'setup-solution': { + validatorScript: 'skills/setup-solution/scripts/validate-solution.js', + }, 'test-site': {}, }; diff --git a/plugins/power-pages/scripts/lib/provision-custom-host.js b/plugins/power-pages/scripts/lib/provision-custom-host.js new file mode 100644 index 000000000..e2b3635a6 --- /dev/null +++ b/plugins/power-pages/scripts/lib/provision-custom-host.js @@ -0,0 +1,387 @@ +#!/usr/bin/env node + +// Provisions a new Power Platform Pipelines Custom Host via the BAP env-create +// API with the `D365_ProjectHost` organization template. The template +// pre-installs the Pipelines app, so the resulting env is immediately usable as +// a host. Used by ensure-pipelines-host Phase 4.A (the fast-path Custom Host +// provisioning step). Same template PPAC's `New custom host` button uses. +// +// POST {bapBase}/providers/Microsoft.BusinessAppPlatform/environments?api-version=2021-04-01 +// Headers: +// Authorization: Bearer {bapToken} +// Content-Type: application/json +// x-ms-correlation-id: {uuid v4} +// Body: +// { +// "location": "{region}", +// "properties": { +// "displayName": "{displayName}", +// "environmentSku": "Production", +// "databaseType": "CommonDataService", +// "linkedEnvironmentMetadata": { "templates": ["D365_ProjectHost"] } +// } +// } +// +// Response handling: +// - 200 sync — env already provisioned (rare). Return success immediately. +// - 202 async — Location header points to a lifecycle operation; Retry-After +// is the poll interval (seconds). Body usually includes the env record with +// provisioningState: 'Creating'. +// - 401 — BAP token invalid; refresh and retry. +// - 403 — caller is not Power Platform / Dynamics admin; throw. +// - 4xx other — throw with body. +// +// Polling: +// - GET the Location URL (or the env URL if Location absent). +// - Read `properties.provisioningState` (preferred), fallback to `state` / +// `status.code` / `status` (lifecycle ops vary by API version). +// - Honor `Retry-After` per response; default 10s. +// - Stop on Succeeded/Failed/Canceled or after --timeoutSec. +// +// Usage: node provision-custom-host.js +// --bapToken --displayName <"name"> +// --region +// [--correlationId ] [--timeoutSec 900] +// [--apiVersion 2021-04-01] [--bapBase ] +// +// Output (JSON to stdout): +// { +// status: 'Succeeded', +// envId: '', +// instanceUrl: 'https://...', +// instanceApiUrl: 'https://...', +// displayName: '...', +// environmentSku: 'Production', +// provisioningState: 'Succeeded', +// durationSec: , +// correlationId: '', +// pollAttempts: , +// locationHeader: '' +// } +// +// Exit 0 on success, exit 1 on error (stderr includes status + body). + +'use strict'; + +const crypto = require('crypto'); +const helpers = require('./validation-helpers'); + +const DEFAULT_API_VERSION = '2021-04-01'; +const DEFAULT_BAP_BASE = 'https://api.bap.microsoft.com'; +const DEFAULT_TIMEOUT_SEC = 900; +const DEFAULT_RETRY_AFTER_SEC = 10; +const POST_TIMEOUT_MS = 60000; +const POLL_TIMEOUT_MS = 30000; + +const TEMPLATE_NAME = 'D365_ProjectHost'; + +// SKUs the env-create API accepts when provisioning a Custom Host. Production +// is the documented fast-path target (the eng.ms doc and PPAC's "New custom +// host" UI both use Production). The other SKUs are useful as license-aware +// fallbacks: trial-license tenants return 409 +// NotEnoughCapacity_HasTrialLicense_ProvisionEnvironment for Production but +// can create Trial; subscription tenants without spare Production capacity +// can use Sandbox or Developer. The Pipelines app installs successfully +// regardless of SKU — the SKU only governs license allocation. +const ALLOWED_SKUS = new Set(['Production', 'Sandbox', 'Developer', 'Trial']); +const DEFAULT_SKU = 'Production'; + +function parseArgs(argv) { + const args = argv.slice(2); + const opts = { + bapToken: null, + displayName: null, + region: null, + correlationId: null, + timeoutSec: DEFAULT_TIMEOUT_SEC, + apiVersion: DEFAULT_API_VERSION, + bapBase: DEFAULT_BAP_BASE, + environmentSku: DEFAULT_SKU, + }; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const next = args[i + 1]; + if (a === '--bapToken' && next) opts.bapToken = args[++i]; + else if (a === '--displayName' && next) opts.displayName = args[++i]; + else if (a === '--region' && next) opts.region = args[++i]; + else if (a === '--correlationId' && next) opts.correlationId = args[++i]; + else if (a === '--timeoutSec' && next) opts.timeoutSec = Number(args[++i]) || DEFAULT_TIMEOUT_SEC; + else if (a === '--apiVersion' && next) opts.apiVersion = args[++i]; + else if (a === '--bapBase' && next) opts.bapBase = args[++i]; + else if (a === '--environmentSku' && next) opts.environmentSku = args[++i]; + } + + return opts; +} + +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Reads provisioning state from a polling response across multiple shapes. +// BAP env GET → properties.provisioningState +// Lifecycle op GET → state | status.code | status (string) +function extractProvisioningState(data) { + if (!data || typeof data !== 'object') return null; + if (data.properties && typeof data.properties.provisioningState === 'string') { + return data.properties.provisioningState; + } + if (typeof data.state === 'string') return data.state; + if (data.status && typeof data.status === 'object' && typeof data.status.code === 'string') { + return data.status.code; + } + if (typeof data.status === 'string') return data.status; + return null; +} + +function isTerminalSucceeded(state) { + if (!state) return false; + const s = String(state).toLowerCase(); + return s === 'succeeded' || s === 'succeeded.'; +} + +function isTerminalFailed(state) { + if (!state) return false; + const s = String(state).toLowerCase(); + return s === 'failed' || s === 'canceled' || s === 'cancelled'; +} + +function readRetryAfterSec(headers) { + if (!headers) return null; + const v = headers['retry-after'] || headers['Retry-After']; + if (!v) return null; + const n = Number(v); + return isFinite(n) && n > 0 ? n : null; +} + +async function provisionCustomHost(opts = {}) { + const { + bapToken, + displayName, + region, + correlationId, + timeoutSec = DEFAULT_TIMEOUT_SEC, + apiVersion = DEFAULT_API_VERSION, + bapBase = DEFAULT_BAP_BASE, + environmentSku = DEFAULT_SKU, + // Test injection points: + sleepImpl = null, + nowImpl = null, + } = opts; + + if (!bapToken) throw new Error('--bapToken is required'); + if (!displayName) throw new Error('--displayName is required'); + if (!region) throw new Error('--region is required'); + if (!ALLOWED_SKUS.has(environmentSku)) { + throw new Error(`--environmentSku must be one of: ${[...ALLOWED_SKUS].join(', ')} (got "${environmentSku}")`); + } + + const sleep = sleepImpl || defaultSleep; + const now = nowImpl || (() => Date.now()); + + const cleanBase = bapBase.replace(/\/+$/, ''); + const cid = correlationId || crypto.randomUUID(); + const startedAt = now(); + + const requestBody = JSON.stringify({ + location: region, + properties: { + displayName, + environmentSku, + databaseType: 'CommonDataService', + linkedEnvironmentMetadata: { templates: [TEMPLATE_NAME] }, + }, + }); + + const postUrl = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments?api-version=${encodeURIComponent(apiVersion)}`; + const postHeaders = { + Authorization: `Bearer ${bapToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-ms-correlation-id': cid, + }; + + const postRes = await helpers.makeRequest({ + url: postUrl, + method: 'POST', + headers: postHeaders, + body: requestBody, + timeout: POST_TIMEOUT_MS, + includeHeaders: true, + }); + + if (postRes.error) { + throw new Error(`BAP env-create POST failed: ${postRes.error}`); + } + + if (postRes.statusCode === 401) { + throw new Error('BAP env-create returned 401 — caller not authenticated; refresh BAP token and retry.'); + } + if (postRes.statusCode === 403) { + throw new Error('BAP env-create returned 403 — Custom Host fast-path requires Global / Power Platform / Dynamics admin. Suggest using the PPAC UI path or installing the Pipelines app on an existing env you administer.'); + } + if (postRes.statusCode !== 200 && postRes.statusCode !== 202) { + throw new Error(`BAP env-create returned unexpected status ${postRes.statusCode}: ${(postRes.body || '').slice(0, 500)}`); + } + + let envBody = null; + if (postRes.body) { + try { envBody = JSON.parse(postRes.body); } catch { envBody = null; } + } + + let envId = envBody?.name || null; + let instanceUrl = envBody?.properties?.linkedEnvironmentMetadata?.instanceUrl || null; + let instanceApiUrl = envBody?.properties?.linkedEnvironmentMetadata?.instanceApiUrl || null; + // Prefer the SKU BAP reports back (it's authoritative once provisioning + // succeeds — BAP may have substituted a different SKU based on tenant policy) + // and fall back to the requested SKU on synchronous responses where the body + // is sparse. + let resolvedSku = envBody?.properties?.environmentSku || environmentSku; + let provisioningState = extractProvisioningState(envBody) || 'Creating'; + const locationHeader = postRes.headers?.location || postRes.headers?.Location || null; + let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC; + + // Already done synchronously + if (postRes.statusCode === 200 && isTerminalSucceeded(provisioningState)) { + return { + status: 'Succeeded', + envId, + instanceUrl, + instanceApiUrl, + displayName, + environmentSku: resolvedSku, + provisioningState, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts: 0, + locationHeader, + }; + } + + // Polling — choose URL: prefer Location header, else build env GET URL. + if (!locationHeader && !envId) { + throw new Error('BAP env-create returned 202 but neither Location header nor env id is available; cannot poll for completion.'); + } + + const envGetUrl = envId + ? `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments/${encodeURIComponent(envId)}?api-version=${encodeURIComponent(apiVersion)}&$expand=${encodeURIComponent('properties.linkedEnvironmentMetadata')}` + : null; + + let pollAttempts = 0; + const deadline = startedAt + timeoutSec * 1000; + + while (now() < deadline) { + if (isTerminalSucceeded(provisioningState) || isTerminalFailed(provisioningState)) break; + + await sleep(retryAfterSec * 1000); + + pollAttempts++; + const pollUrl = locationHeader || envGetUrl; + const pollRes = await helpers.makeRequest({ + url: pollUrl, + method: 'GET', + headers: { + Authorization: `Bearer ${bapToken}`, + Accept: 'application/json', + 'x-ms-correlation-id': cid, + }, + timeout: POLL_TIMEOUT_MS, + includeHeaders: true, + }); + + if (pollRes.error) { + // transient — keep polling + continue; + } + + if (pollRes.statusCode === 401) { + throw new Error('Polling returned 401 mid-provision — token expired. The env may still finish; re-run detect after a few minutes.'); + } + + if (pollRes.statusCode >= 500) { + // transient server error — keep polling + continue; + } + + if (pollRes.statusCode !== 200 && pollRes.statusCode !== 202) { + throw new Error(`Polling returned unexpected status ${pollRes.statusCode}: ${(pollRes.body || '').slice(0, 500)}`); + } + + let pollData = null; + try { pollData = JSON.parse(pollRes.body || '{}'); } catch { pollData = null; } + + const newState = extractProvisioningState(pollData); + if (newState) provisioningState = newState; + + const linked = pollData?.properties?.linkedEnvironmentMetadata; + if (linked?.instanceUrl) instanceUrl = linked.instanceUrl; + if (linked?.instanceApiUrl) instanceApiUrl = linked.instanceApiUrl; + if (pollData?.name && !envId) envId = pollData.name; + + const newRetryAfter = readRetryAfterSec(pollRes.headers); + if (newRetryAfter) retryAfterSec = newRetryAfter; + } + + // After loop — decide what state we're in + if (isTerminalSucceeded(provisioningState)) { + // If lifecycle op didn't include linkedEnvironmentMetadata, do a direct env GET to fetch URLs. + if ((!instanceApiUrl || !instanceUrl) && envId && envGetUrl) { + const envFinalRes = await helpers.makeRequest({ + url: envGetUrl, + method: 'GET', + headers: { Authorization: `Bearer ${bapToken}`, Accept: 'application/json', 'x-ms-correlation-id': cid }, + timeout: POLL_TIMEOUT_MS, + }); + if (envFinalRes.statusCode === 200) { + try { + const final = JSON.parse(envFinalRes.body); + instanceApiUrl = final?.properties?.linkedEnvironmentMetadata?.instanceApiUrl || instanceApiUrl; + instanceUrl = final?.properties?.linkedEnvironmentMetadata?.instanceUrl || instanceUrl; + resolvedSku = final?.properties?.environmentSku || resolvedSku; + } catch {} + } + } + return { + status: 'Succeeded', + envId, + instanceUrl, + instanceApiUrl, + displayName, + environmentSku: resolvedSku, + provisioningState, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts, + locationHeader, + }; + } + + if (isTerminalFailed(provisioningState)) { + throw new Error(`Provisioning ended with state "${provisioningState}" after ${pollAttempts} poll(s). Inspect lifecycle op ${locationHeader || envGetUrl} for details.`); + } + + throw new Error(`Provisioning timed out after ${timeoutSec}s (${pollAttempts} polls); last state: ${provisioningState}.`); +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + provisionCustomHost(opts) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + provisionCustomHost, + extractProvisioningState, + isTerminalSucceeded, + isTerminalFailed, + readRetryAfterSec, + TEMPLATE_NAME, + ALLOWED_SKUS, + DEFAULT_SKU, +}; diff --git a/plugins/power-pages/scripts/lib/provision-platform-host.js b/plugins/power-pages/scripts/lib/provision-platform-host.js new file mode 100644 index 000000000..60e371489 --- /dev/null +++ b/plugins/power-pages/scripts/lib/provision-platform-host.js @@ -0,0 +1,355 @@ +#!/usr/bin/env node + +// Provisions a Power Platform Pipelines Platform Host (PE) via the BAP +// `getOrCreate` endpoint. The endpoint is idempotent: a tenant that already +// has a PE gets the existing one back (200 + provisioningState=Succeeded); +// a tenant without a PE gets one provisioned (202 + lifecycle op). Same call +// `make.powerapps.com → Pipelines` makes when a user clicks "Get started". +// Used by ensure-pipelines-host Phase 4.0. +// +// POST {bapBase}/providers/Microsoft.BusinessAppPlatform/environments/getOrCreate?api-version=2021-04-01 +// Headers: +// Authorization: Bearer {bapToken} +// Content-Type: application/json +// x-ms-correlation-id: {uuid v4} +// Body: +// { +// "properties": { +// "environmentSku": "Platform", +// "linkedEnvironmentMetadata": { "templates": ["D365_1stPartyAdminApps"] } +// } +// } +// +// Response handling: +// - 200 + provisioningState=Succeeded — tenant already had a PE; return it +// with alreadyExisted=true. This is the idempotent path, not an error. +// - 202 — Location header points to a lifecycle op; Retry-After is the poll +// interval (seconds). Body usually includes the env record with +// provisioningState: 'Creating'. Return alreadyExisted=false on success. +// - 401 — BAP token invalid; refresh and retry. +// - 403 — tenant policy or token-audience mismatch (PE provisioning does NOT +// require admin role). Surface body verbatim and recommend re-auth. +// - 4xx other — throw with body. +// +// Polling: identical to provision-custom-host.js. We GET the Location URL, +// read provisioningState, honor Retry-After, terminate on Succeeded/Failed/ +// Canceled or after --timeoutSec. +// +// Usage: node provision-platform-host.js --bapToken +// [--correlationId ] [--timeoutSec 600] +// [--apiVersion 2021-04-01] [--bapBase ] +// +// Output (JSON to stdout): +// { +// status: 'Succeeded', +// alreadyExisted: true | false, // 200 idempotent vs. 202 newly provisioned +// envId: '', +// instanceUrl: 'https://...', +// instanceApiUrl: 'https://...', +// displayName: '...', +// environmentSku: 'Platform', +// provisioningState: 'Succeeded', +// durationSec: , +// correlationId: '', +// pollAttempts: , +// locationHeader: '' | null +// } +// +// Exit 0 on success, exit 1 on error (stderr includes status + body). + +'use strict'; + +const crypto = require('crypto'); +const helpers = require('./validation-helpers'); + +const DEFAULT_API_VERSION = '2021-04-01'; +const DEFAULT_BAP_BASE = 'https://api.bap.microsoft.com'; +const DEFAULT_TIMEOUT_SEC = 600; +const DEFAULT_RETRY_AFTER_SEC = 10; +const POST_TIMEOUT_MS = 60000; +const POLL_TIMEOUT_MS = 30000; + +const TEMPLATE_NAME = 'D365_1stPartyAdminApps'; +const ENVIRONMENT_SKU = 'Platform'; + +function parseArgs(argv) { + const args = argv.slice(2); + const opts = { + bapToken: null, + correlationId: null, + timeoutSec: DEFAULT_TIMEOUT_SEC, + apiVersion: DEFAULT_API_VERSION, + bapBase: DEFAULT_BAP_BASE, + }; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const next = args[i + 1]; + if (a === '--bapToken' && next) opts.bapToken = args[++i]; + else if (a === '--correlationId' && next) opts.correlationId = args[++i]; + else if (a === '--timeoutSec' && next) opts.timeoutSec = Number(args[++i]) || DEFAULT_TIMEOUT_SEC; + else if (a === '--apiVersion' && next) opts.apiVersion = args[++i]; + else if (a === '--bapBase' && next) opts.bapBase = args[++i]; + } + + return opts; +} + +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function extractProvisioningState(data) { + if (!data || typeof data !== 'object') return null; + if (data.properties && typeof data.properties.provisioningState === 'string') { + return data.properties.provisioningState; + } + if (typeof data.state === 'string') return data.state; + if (data.status && typeof data.status === 'object' && typeof data.status.code === 'string') { + return data.status.code; + } + if (typeof data.status === 'string') return data.status; + return null; +} + +function isTerminalSucceeded(state) { + if (!state) return false; + const s = String(state).toLowerCase(); + return s === 'succeeded' || s === 'succeeded.'; +} + +function isTerminalFailed(state) { + if (!state) return false; + const s = String(state).toLowerCase(); + return s === 'failed' || s === 'canceled' || s === 'cancelled'; +} + +function readRetryAfterSec(headers) { + if (!headers) return null; + const v = headers['retry-after'] || headers['Retry-After']; + if (!v) return null; + const n = Number(v); + return isFinite(n) && n > 0 ? n : null; +} + +async function provisionPlatformHost(opts = {}) { + const { + bapToken, + correlationId, + timeoutSec = DEFAULT_TIMEOUT_SEC, + apiVersion = DEFAULT_API_VERSION, + bapBase = DEFAULT_BAP_BASE, + sleepImpl = null, + nowImpl = null, + } = opts; + + if (!bapToken) throw new Error('--bapToken is required'); + + const sleep = sleepImpl || defaultSleep; + const now = nowImpl || (() => Date.now()); + + const cleanBase = bapBase.replace(/\/+$/, ''); + const cid = correlationId || crypto.randomUUID(); + const startedAt = now(); + + const requestBody = JSON.stringify({ + properties: { + environmentSku: ENVIRONMENT_SKU, + linkedEnvironmentMetadata: { templates: [TEMPLATE_NAME] }, + }, + }); + + // Endpoint is `/environments/getOrCreate`, NOT `/getOrCreate`. The latter + // returns 404 from BAP. Confirmed against a live tenant where the existing + // Platform Host (envId 8916a7c4-8c4c-e041-ad42-aa9980ff6810, + // `PlatformEnv-unitedstates`) was only reachable via the `/environments/` + // prefix. Same shape as the rest of the BAP environments RP. + const postUrl = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments/getOrCreate?api-version=${encodeURIComponent(apiVersion)}`; + const postHeaders = { + Authorization: `Bearer ${bapToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-ms-correlation-id': cid, + }; + + const postRes = await helpers.makeRequest({ + url: postUrl, + method: 'POST', + headers: postHeaders, + body: requestBody, + timeout: POST_TIMEOUT_MS, + includeHeaders: true, + }); + + if (postRes.error) { + throw new Error(`BAP getOrCreate POST failed: ${postRes.error}`); + } + + if (postRes.statusCode === 401) { + throw new Error('BAP getOrCreate returned 401 — caller not authenticated; refresh BAP token and retry.'); + } + if (postRes.statusCode === 403) { + throw new Error(`BAP getOrCreate returned 403 — tenant policy may have disabled Platform Host provisioning, or the BAP token audience is mismatched. Try re-authenticating ('az logout && az login') and retry. Body: ${(postRes.body || '').slice(0, 500)}`); + } + if (postRes.statusCode !== 200 && postRes.statusCode !== 202) { + throw new Error(`BAP getOrCreate returned unexpected status ${postRes.statusCode}: ${(postRes.body || '').slice(0, 500)}`); + } + + let envBody = null; + if (postRes.body) { + try { envBody = JSON.parse(postRes.body); } catch { envBody = null; } + } + + let envId = envBody?.name || null; + let instanceUrl = envBody?.properties?.linkedEnvironmentMetadata?.instanceUrl || null; + let instanceApiUrl = envBody?.properties?.linkedEnvironmentMetadata?.instanceApiUrl || null; + let displayName = envBody?.properties?.displayName || null; + let resolvedSku = envBody?.properties?.environmentSku || ENVIRONMENT_SKU; + let provisioningState = extractProvisioningState(envBody) || 'Creating'; + const locationHeader = postRes.headers?.location || postRes.headers?.Location || null; + let retryAfterSec = readRetryAfterSec(postRes.headers) || DEFAULT_RETRY_AFTER_SEC; + + // Idempotent existing-PE path: 200 + Succeeded means the tenant already had + // a PE; getOrCreate is returning it. Distinguish with alreadyExisted=true so + // the caller can write the right telemetry. + if (postRes.statusCode === 200 && isTerminalSucceeded(provisioningState)) { + return { + status: 'Succeeded', + alreadyExisted: true, + envId, + instanceUrl, + instanceApiUrl, + displayName, + environmentSku: resolvedSku, + provisioningState, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts: 0, + locationHeader, + }; + } + + // 202 path — we just kicked off a new provision. Poll until terminal. + if (!locationHeader && !envId) { + throw new Error('BAP getOrCreate returned 202 but neither Location header nor env id is available; cannot poll for completion.'); + } + + const envGetUrl = envId + ? `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments/${encodeURIComponent(envId)}?api-version=${encodeURIComponent(apiVersion)}&$expand=${encodeURIComponent('properties.linkedEnvironmentMetadata')}` + : null; + + let pollAttempts = 0; + const deadline = startedAt + timeoutSec * 1000; + + while (now() < deadline) { + if (isTerminalSucceeded(provisioningState) || isTerminalFailed(provisioningState)) break; + + await sleep(retryAfterSec * 1000); + + pollAttempts++; + const pollUrl = locationHeader || envGetUrl; + const pollRes = await helpers.makeRequest({ + url: pollUrl, + method: 'GET', + headers: { + Authorization: `Bearer ${bapToken}`, + Accept: 'application/json', + 'x-ms-correlation-id': cid, + }, + timeout: POLL_TIMEOUT_MS, + includeHeaders: true, + }); + + if (pollRes.error) { + continue; + } + + if (pollRes.statusCode === 401) { + throw new Error('Polling returned 401 mid-provision — token expired. The PE may still finish; re-run detect after a few minutes.'); + } + + if (pollRes.statusCode >= 500) { + continue; + } + + if (pollRes.statusCode !== 200 && pollRes.statusCode !== 202) { + throw new Error(`Polling returned unexpected status ${pollRes.statusCode}: ${(pollRes.body || '').slice(0, 500)}`); + } + + let pollData = null; + try { pollData = JSON.parse(pollRes.body || '{}'); } catch { pollData = null; } + + const newState = extractProvisioningState(pollData); + if (newState) provisioningState = newState; + + const linked = pollData?.properties?.linkedEnvironmentMetadata; + if (linked?.instanceUrl) instanceUrl = linked.instanceUrl; + if (linked?.instanceApiUrl) instanceApiUrl = linked.instanceApiUrl; + if (pollData?.properties?.displayName) displayName = pollData.properties.displayName; + if (pollData?.name && !envId) envId = pollData.name; + + const newRetryAfter = readRetryAfterSec(pollRes.headers); + if (newRetryAfter) retryAfterSec = newRetryAfter; + } + + if (isTerminalSucceeded(provisioningState)) { + if ((!instanceApiUrl || !instanceUrl) && envId && envGetUrl) { + const envFinalRes = await helpers.makeRequest({ + url: envGetUrl, + method: 'GET', + headers: { Authorization: `Bearer ${bapToken}`, Accept: 'application/json', 'x-ms-correlation-id': cid }, + timeout: POLL_TIMEOUT_MS, + }); + if (envFinalRes.statusCode === 200) { + try { + const final = JSON.parse(envFinalRes.body); + instanceApiUrl = final?.properties?.linkedEnvironmentMetadata?.instanceApiUrl || instanceApiUrl; + instanceUrl = final?.properties?.linkedEnvironmentMetadata?.instanceUrl || instanceUrl; + displayName = final?.properties?.displayName || displayName; + resolvedSku = final?.properties?.environmentSku || resolvedSku; + } catch {} + } + } + return { + status: 'Succeeded', + alreadyExisted: false, + envId, + instanceUrl, + instanceApiUrl, + displayName, + environmentSku: resolvedSku, + provisioningState, + durationSec: (now() - startedAt) / 1000, + correlationId: cid, + pollAttempts, + locationHeader, + }; + } + + if (isTerminalFailed(provisioningState)) { + throw new Error(`Platform Host provisioning ended with state "${provisioningState}" after ${pollAttempts} poll(s). Inspect lifecycle op ${locationHeader || envGetUrl} for details.`); + } + + throw new Error(`Platform Host provisioning timed out after ${timeoutSec}s (${pollAttempts} polls); last state: ${provisioningState}.`); +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + provisionPlatformHost(opts) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { + provisionPlatformHost, + extractProvisioningState, + isTerminalSucceeded, + isTerminalFailed, + readRetryAfterSec, + TEMPLATE_NAME, + ENVIRONMENT_SKU, +}; diff --git a/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js b/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js new file mode 100644 index 000000000..d61719fc8 --- /dev/null +++ b/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js @@ -0,0 +1,942 @@ +#!/usr/bin/env node + +// Refreshes docs/.alm-plan-data.json with post-run state from the marker +// files written by setup-pipeline / deploy-pipeline / ensure-pipelines-host / +// test-site, then optionally invokes the renderer. +// +// Plan-alm Phase 3 writes the planData JSON once at plan generation time, +// reflecting pre-run intent (e.g. hostResolution.status: "NoHost", +// risks: ["No Pipelines host detected — setup-pipeline will provision..."]). +// After each run step actually executes, the rendered HTML stays frozen at +// pre-run state unless the planData is refreshed and re-rendered. +// +// This helper centralizes the refresh so the SKILL.md prose can stay short +// and the agent doesn't have to inline shape transforms each time. +// +// Usage: +// node refresh-alm-plan-data.js +// --projectRoot +// --phase +// [--render] also invoke render-alm-plan.js after writing +// [--rendererPath ] defaults to skills/plan-alm/scripts/render-alm-plan.js +// relative to plugin root +// +// What gets refreshed per phase: +// setup-solution: +// - plan footer status (no change — stays "In Execution") +// setup-pipeline: +// - hostResolution from docs/alm/last-host-check.json +// - pipelineMeta from docs/alm/last-pipeline.json (no lastDeploy yet) +// - drop pre-run NoHost / *Unbound* warnings from risks[] +// deploy-pipeline: +// - pipelineMeta.lastDeploy from docs/alm/last-deploy.json +// - drop pre-run "Pipelines host not yet provisioned" warnings (defensive) +// test-site: +// - validationRuns[stage] from docs/alm/last-test-site.json (if present) +// finalize: +// - PLAN_STATUS = "Completed" +// +// Exit 0 on success (including no-op when planData missing — caller decides). +// Exit 1 on argparse / fatal error. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { almPath } = require('./alm-paths'); + +const PHASES = new Set([ + 'setup-solution', + 'setup-pipeline', + // configure-env-variables: invoked when the user runs the standalone + // /power-pages:configure-env-variables skill (or when setup-solution + // delegates to it). The refresh re-reads docs/alm/last-env-vars.json + // (if setup-solution's Phase 6.2b sidecar exists or configure-env-variables + // wrote its own equivalent) AND backfills planData.envVars[i].values{} + // from the freshly-written deployment-settings.json so the rendered plan + // shows both the created definitions and their per-stage values. + 'configure-env-variables', + 'deploy-pipeline', + // Manual-path phases (export/import/activate). For PP Pipelines path the + // deploy is a single 'deploy-pipeline' phase that covers import + activate + // implicitly; for Manual path each step is a separate phase. Each handler + // is intentionally minimal — the main work the refresh-and-render does for + // Manual path is re-rendering the HTML so the agent's step-status updates + // (planData.steps[i].status) flow through. Per-stage data ingestion (e.g. + // last-import.json with import outcomes per target) can be added later + // without changing the phase set. + 'export-solution', + 'import-solution', + 'activate-site', + 'test-site', + 'finalize', +]); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + projectRoot: process.cwd(), + phase: null, + render: false, + rendererPath: null, + stageName: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + else if (args[i] === '--phase' && args[i + 1]) out.phase = args[++i]; + else if (args[i] === '--render') out.render = true; + else if (args[i] === '--rendererPath' && args[i + 1]) out.rendererPath = args[++i]; + else if (args[i] === '--stageName' && args[i + 1]) out.stageName = args[++i]; + } + return out; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +// Map docs/alm/last-host-check.json's resolutionStatus to plan-alm's hostResolution.status. +// Pass-through when the value already matches plan-alm's enum; the wrappers +// emit the same names today, but we keep this map explicit so the SKILL.md +// contract stays clear. ensure-pipelines-host post-run typically reports +// "AvailableUsingCustomHost" (the new host is now bound to the source env). +function buildHostResolutionFromCheck(check) { + if (!check || typeof check !== 'object') return null; + return { + status: check.resolutionStatus || 'DetectionFailed', + hostEnvUrl: check.finalHostEnvUrl || null, + hostEnvId: check.finalHostEnvId || null, + hostEnvName: check.finalHostEnvName || null, // BAP env displayName — surfaces in the renderer's host card so the env is identifiable by name, not by URL alone + hostType: check.hostType || null, + pipelinesSolutionVersion: check.pipelinesSolutionVersion || null, + candidatesCount: check.candidates?.existingCustomHosts?.length || 0, + willEnsureDuringExecution: false, // post-run: nothing left to ensure + willProvisionPlatform: false, + willProvisionCustom: false, + willUsePpac: false, + chosenEnvUrl: null, + userChoseDeferToSetupPipeline: false, + }; +} + +// Reads `deployment-settings.json` from the project root. Returns null when +// missing or malformed — callers degrade to "no per-stage backfill" rather +// than throwing. deploy-pipeline Phase 5 writes/reads this file; the user +// can also hand-edit it. The file is the source of truth for per-stage env +// var override values. +function readDeploymentSettings(projectRoot) { + if (!projectRoot) return null; + try { + const filePath = path.join(projectRoot, 'deployment-settings.json'); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +// Pivot deployment-settings.json into `{ schemaName: { stageName: value, ... } }`. +// Accepts two shapes observed in the wild: +// - top-level stage keys: `{ "Staging": { "EnvironmentVariables": [...] }, "Production": {...} }` +// (deploy-pipeline SKILL.md Phase 5.0a template) +// - nested under `stages`: `{ "stages": { "Staging": {...} } }` +// (deploy-pipeline SKILL.md Phase 5.1 read path) +// Also accepts both casings on inner keys: `SchemaName`/`Value` (per the +// platform's deploymentsettingsjson schema) and `schemaName`/`value` (camelCase +// for parity with planData). Empty-string values are skipped so a template +// row that the user hasn't filled in yet doesn't clobber a real value +// already on `ev.values[stageName]`. +function extractPerStageValues(deploymentSettings) { + if (!deploymentSettings || typeof deploymentSettings !== 'object') return null; + const stagesContainer = deploymentSettings.stages && typeof deploymentSettings.stages === 'object' + ? deploymentSettings.stages + : deploymentSettings; + + const bySchema = {}; + for (const [stageName, stageBlock] of Object.entries(stagesContainer)) { + if (!stageBlock || typeof stageBlock !== 'object') continue; + // Guard against the user mixing the two shapes — if the value at the + // root is itself the inner `EnvironmentVariables` array (rare but + // possible from a hand-edit), skip; we only walk stage-shaped values. + if (Array.isArray(stageBlock)) continue; + if (stageName === 'stages' || stageName === 'EnvironmentVariables' || + stageName === 'ConnectionReferences') continue; + + const envVars = stageBlock.EnvironmentVariables || stageBlock.environmentVariables; + if (!Array.isArray(envVars)) continue; + for (const ev of envVars) { + if (!ev || typeof ev !== 'object') continue; + const schemaName = ev.SchemaName || ev.schemaName; + const rawValue = ev.Value != null ? ev.Value : ev.value; + if (!schemaName) continue; + if (rawValue == null || rawValue === '') continue; + const strValue = String(rawValue); + bySchema[schemaName] = bySchema[schemaName] || {}; + bySchema[schemaName][stageName] = strValue; + } + } + return bySchema; +} + +// Backfill planData.envVars[i].values{} from deployment-settings.json so +// the rendered plan's "Values by Environment" matrix auto-populates after +// deploy-pipeline runs. Idempotent: an existing non-empty value on +// ev.values[stageName] is preserved (manual overrides win). Returns the +// number of cells filled in this call. +// +// TODO(follow-up): also query the live `environmentvariablevalues` table +// per target env so values set in Power Platform Admin Center (bypassing +// the file) show up. Needs per-stage tokens + env var definition GUIDs, +// neither of which the helper currently has — deferring until those +// inputs are wired through the refresh contract. +function backfillEnvVarValuesFromSettings(planData, projectRoot) { + if (!Array.isArray(planData.envVars) || planData.envVars.length === 0) return 0; + const settings = readDeploymentSettings(projectRoot); + if (!settings) return 0; + const bySchema = extractPerStageValues(settings); + if (!bySchema || Object.keys(bySchema).length === 0) return 0; + + let filled = 0; + for (const ev of planData.envVars) { + if (!ev || typeof ev.schemaName !== 'string') continue; + const matches = bySchema[ev.schemaName]; + if (!matches) continue; + ev.values = (ev.values && typeof ev.values === 'object') ? ev.values : {}; + for (const [stageName, value] of Object.entries(matches)) { + // Manual override / prior call wins — never overwrite a populated cell. + const existing = ev.values[stageName]; + if (existing != null && existing !== '') continue; + ev.values[stageName] = value; + filled += 1; + } + } + return filled; +} + +// Flip a matching entry in planData.steps[] to the given status. Each phase +// owns a "what step does this complete?" rule, captured at the bottom of the +// per-phase refresh function. The agent used to flip steps[i].status by hand +// via Edit tool between phases; in multi-phase orchestration that consistently +// got missed, so the rendered checklist drifted from reality. This helper +// closes the gap. Rules: +// - Match `step.name` case-insensitively against `keyword` (regex). +// - When `stage` is set, also require that lower-cased stage substring in +// `step.name` — needed for "Deploy via pipeline to Staging" vs +// "...Production" disambiguation. +// - Skip steps with `skip: true` (user opted out — never auto-mark). +// - Skip steps already in a terminal state UNLESS we're setting `failed` +// (a retry that succeeded after a failure is recorded as completed by a +// subsequent invocation; a fresh failure overrides any prior status). +function setStepStatus(planData, { keyword, stage, status }) { + if (!Array.isArray(planData.steps)) return 0; + if (!keyword) return 0; + const targetStage = (typeof stage === 'string' && stage.length > 0) ? stage.toLowerCase() : null; + let flipped = 0; + for (const step of planData.steps) { + if (!step || typeof step.name !== 'string') continue; + if (step.skip === true) continue; + const name = step.name.toLowerCase(); + if (!keyword.test(name)) continue; + if (targetStage && !name.includes(targetStage)) continue; + // Don't regress a completed step to anything other than `failed`. A retry + // that succeeded should NOT downgrade to `in_progress`, but a `failed` + // signal from a fresh failure must override a stale `completed`. + if (step.status === 'completed' && status !== 'failed') continue; + if (step.status === status) continue; + step.status = status; + flipped += 1; + } + return flipped; +} + +// Drop risk entries that are no longer applicable after a phase completes. +// We match by canonical leading-text fragments because the risks list is +// authored as free text in Phase 3 — exact-text matching is brittle but +// more deterministic than pattern matching the whole sentence. +function dropResolvedRisks(risks, phase) { + if (!Array.isArray(risks)) return risks || []; + const stalePrefixes = { + 'setup-pipeline': [ + 'No Pipelines host detected', + 'An existing Custom Host (', + ' existing Custom Hosts found in tenant', + 'Tenant has a Platform Host', + ], + 'deploy-pipeline': [ + // Defensive — if a future Phase 3 risks template adds "host not yet + // provisioned" entries, drop them here too. + 'Pipelines host has not been provisioned yet', + ], + }; + const prefixes = stalePrefixes[phase] || []; + if (prefixes.length === 0) return risks; + return risks.filter((r) => { + const msg = (r && typeof r === 'object' && typeof r.message === 'string') ? r.message : ''; + return !prefixes.some((p) => msg.includes(p)); + }); +} + +function refreshSetupPipeline(planData, projectRoot) { + const hostCheckPath = almPath(projectRoot, 'lastHostCheck'); + const pipelineMarkerPath = almPath(projectRoot, 'lastPipeline'); + const hostCheck = readJson(hostCheckPath); + const pipelineMarker = readJson(pipelineMarkerPath); + + if (hostCheck) { + const next = buildHostResolutionFromCheck(hostCheck); + if (next) planData.hostResolution = next; + } + // Rewrite alm-host-resolution.json so the audit snapshot tracks the + // resolved state instead of the pre-run "NoHost" capture. + mirrorHostResolutionSnapshot(planData, projectRoot); + + if (pipelineMarker) { + planData.pipelineMeta = { + ...(planData.pipelineMeta || {}), + pipelineId: pipelineMarker.pipelineId || null, + pipelineName: pipelineMarker.pipelineName || null, + hostEnvUrl: pipelineMarker.hostEnvUrl || null, + sourceDeploymentEnvironmentId: pipelineMarker.sourceDeploymentEnvironmentId || null, + stages: Array.isArray(pipelineMarker.stages) ? pipelineMarker.stages : null, + isActive: true, + // Keep any reusedByWiring annotation Phase 6 may have written. + reusedByWiring: planData.pipelineMeta?.reusedByWiring || null, + // lastDeploy fills in from the next phase. + lastDeploy: planData.pipelineMeta?.lastDeploy || null, + }; + } + + planData.risks = dropResolvedRisks(planData.risks, 'setup-pipeline'); + // Step-sync: a successful invocation of --phase setup-pipeline completes + // the "Setup pipeline" checklist entry. Marker presence is not required + // because the agent invokes this helper only after the phase finished; the + // invocation itself is the proof of completion. + setStepStatus(planData, { keyword: /\bsetup\s+pipeline\b/i, status: 'completed' }); + return planData; +} + +function refreshDeployPipeline(planData, projectRoot) { + // Refresh hostResolution from the most recent host-check (and mirror into + // rawDiscovery.hostResolution if the plan was generated with that envelope). + // Validation surfaced the case where rawDiscovery.hostResolution stayed at + // {ready:false, status:"NoHost"} from the initial Phase 1 scan even though + // a successful setup-pipeline or ensure-pipelines-host had since resolved + // the host. deploy-pipeline runs AFTER setup-pipeline so by the time we + // get here the host is unambiguously bound. Mirror the resolved state into + // both top-level and rawDiscovery so the rendered host card and the raw + // diagnostic envelope agree. + const hostCheck = readJson(almPath(projectRoot, 'lastHostCheck')); + if (hostCheck) { + const next = buildHostResolutionFromCheck(hostCheck); + if (next) { + planData.hostResolution = next; + // Mirror to rawDiscovery if that envelope exists in the plan. + if (planData.rawDiscovery && typeof planData.rawDiscovery === 'object') { + planData.rawDiscovery.hostResolution = { ...next }; + } + // Rewrite the audit snapshot too — same logic as refreshSetupPipeline. + mirrorHostResolutionSnapshot(planData, projectRoot); + } + } + + const deployMarker = readJson(almPath(projectRoot, 'lastDeploy')); + if (deployMarker) { + planData.pipelineMeta = planData.pipelineMeta || {}; + planData.pipelineMeta.lastDeploy = { + stageRunId: deployMarker.stageRunId || null, + stageName: deployMarker.stageName || null, + status: deployMarker.status || null, + deployedAt: deployMarker.deployedAt || null, + artifactVersion: deployMarker.artifactVersion || null, + componentCount: deployMarker.componentCount != null ? deployMarker.componentCount : null, + activationStatus: deployMarker.activationStatus || null, + siteUrl: deployMarker.siteUrl || null, + }; + // MULTI_RUN_MODE only: deploy-pipeline Phase 3.6 fans out parallel + // ValidatePackageAsync calls before the serial deploy loop and persists + // a `batchValidation` summary into the marker. Surface it on + // pipelineMeta.lastDeploy so the renderer can display "Parallel validation: + // N solutions in ~Ts, M succeeded" without re-querying. We carry the + // per-solution stageRunIds too in case the renderer wants to deep-link + // each one back to PPAC. Field absent for single-solution / legacy v2 + // deploys — caller's renderer should treat `null` as "not multi-run". + if (deployMarker.batchValidation && typeof deployMarker.batchValidation === 'object') { + const b = deployMarker.batchValidation; + // Accept both `elapsedSeconds` (current Phase 3.6.6 schema, populated + // directly from validate-stage-runs-batch.js's helper output) and + // `elapsedSecondsApprox` (legacy name used in earlier drafts of the + // SKILL.md before the helper exposed wall-clock measurement). The + // helper is the source of truth going forward, but legacy markers + // written before that change shouldn't get silently dropped. + const elapsed = b.elapsedSeconds != null + ? b.elapsedSeconds + : (b.elapsedSecondsApprox != null ? b.elapsedSecondsApprox : null); + planData.pipelineMeta.lastDeploy.batchValidation = { + totalSolutions: b.totalSolutions != null ? b.totalSolutions : null, + succeeded: b.succeeded != null ? b.succeeded : null, + failed: b.failed != null ? b.failed : null, + pendingApproval: b.pendingApproval != null ? b.pendingApproval : null, + timedOut: b.timedOut != null ? b.timedOut : null, + elapsedSeconds: elapsed, + perSolutionStageRunIds: (b.perSolutionStageRunIds && typeof b.perSolutionStageRunIds === 'object') + ? { ...b.perSolutionStageRunIds } + : null, + }; + } else { + planData.pipelineMeta.lastDeploy.batchValidation = null; + } + planData.pipelineMeta.isActive = true; + } + planData.risks = dropResolvedRisks(planData.risks, 'deploy-pipeline'); + // Env var values matrix: backfill from deployment-settings.json (the same + // file deploy-pipeline Phase 5 reads to build `deploymentsettingsjson`). + // Without this the renderer's "Values by Environment" matrix stays empty + // even though the deploy already shipped those values to the target env, + // because the planData.envVars[].values map only gets populated if the + // agent manually edits the JSON. Runs unconditionally — independent of + // deployMarker presence, since the values are user-authored and may exist + // for stages that haven't been deployed yet. + backfillEnvVarValuesFromSettings(planData, projectRoot); + // Step-sync: complete the "Deploy via pipeline to {stage}" step. Outcome + // comes from the marker — a failed deploy marks the step `failed` so the + // checklist surfaces what actually happened. Without a marker we don't + // know the stage, so we leave steps[] alone (rare — deploy-pipeline always + // writes one). + if (deployMarker && deployMarker.stageName) { + const failed = /fail/i.test(String(deployMarker.status || '')); + setStepStatus(planData, { + keyword: /\bdeploy\b/i, + stage: deployMarker.stageName, + status: failed ? 'failed' : 'completed', + }); + } + return planData; +} + +function refreshTestSite(planData, projectRoot, stageName) { + // Stage resolution: explicit --stageName arg wins; falls back to the + // marker's stageName field (test-site writes it when known, e.g. when + // plan-alm orchestrates the call); finally falls back to the FIRST target + // stage in planData.stages when planData has only one target. Standalone + // single-stage test-site invocations work without --stageName via the + // last fallback; multi-stage standalone runs need the explicit arg. + const tsMarker = readJson(almPath(projectRoot, 'lastTestSite')); + if (!tsMarker) return planData; + + let resolvedStage = (typeof stageName === 'string' && stageName.length > 0) ? stageName : null; + if (!resolvedStage && tsMarker.stageName) resolvedStage = tsMarker.stageName; + if (!resolvedStage && Array.isArray(planData.stages)) { + const targets = planData.stages.filter((s) => s && s.type === 'target'); + if (targets.length === 1 && targets[0].label) resolvedStage = targets[0].label; + } + if (!resolvedStage) return planData; + + planData.validationRuns = planData.validationRuns || {}; + planData.validationRuns[resolvedStage] = { + url: tsMarker.url || null, + runAt: tsMarker.runAt || null, + durationSec: tsMarker.durationSec != null ? tsMarker.durationSec : null, + runOutcome: tsMarker.runOutcome || null, + summary: tsMarker.summary || null, + categories: Array.isArray(tsMarker.categories) ? tsMarker.categories : null, + }; + // Step-sync: test-site is intentionally non-blocking — a "failed" runOutcome + // does NOT abort the plan. The corresponding checklist step still marks + // `completed` because the test ran; per-test pass/fail detail lives in + // validationRuns[stage], which the renderer surfaces alongside the step. + setStepStatus(planData, { + keyword: /\btest\s+site\b/i, + stage: resolvedStage, + status: 'completed', + }); + return planData; +} + +function refreshFinalize(planData) { + planData.PLAN_STATUS = 'Completed'; + // Step-sync: complete the "Finalize" checklist entry. + setStepStatus(planData, { keyword: /\bfinalize\b/i, status: 'completed' }); + return planData; +} + +// Refresh-phase helper: stamp `LAST_SYNC_AT` on planData so check-alm-plan.js's +// freshness check correctly accounts for source-solution modifications caused +// by the just-completed phase. Called by every phase that touches the source +// solution's modifiedon — setup-solution (bump + AddSolutionComponent), +// configure-env-variables (env var definition creation + AddSolutionComponent), +// and export-solution (bump-solution-version.js). Without this stamp, a +// subsequent Phase 0 check sees `sol.modifiedon > GENERATED_AT` and falsely +// classifies the plan as stale — even though the modification was caused by +// the just-completed phase, not by drift. The check uses +// `max(GENERATED_AT, LAST_SYNC_AT)` as the reference point. +// +// Phases that do NOT call this helper: setup-pipeline (only writes to host env), +// deploy-pipeline (writes to target, not source — though Phase 3.5 may delegate +// to setup-solution which stamps via its own refresh), import-solution (target), +// activate-site (no source mod), test-site (read-only), ensure-pipelines-host +// (host env), force-link-environment (host env). +function stampLastSyncAt(planData) { + planData.LAST_SYNC_AT = new Date().toISOString(); +} + +// Refresh-phase helper: copy the post-phase env var snapshot (docs/alm/last-env-vars.json +// — written by setup-solution Phase 6.2b after each setup-solution run, or by +// configure-env-variables if it adds a similar sidecar write) over to +// docs/alm/alm-env-vars.json so the plan-time snapshot stays current. +// +// Background: plan-alm Phase 1 Step 10b writes alm-env-vars.json once at +// plan-generation time. Validation surfaced the case where alm-env-vars.json +// was {envVars: [], count: 0} long after env vars were actually created — +// because nothing refreshed the file. last-env-vars.json IS refreshed +// (setup-solution Phase 6.2b runs the discovery helper post-setup), and +// refreshSetupSolution / refreshConfigureEnvVariables here ingest it into +// planData.envVars[]. Mirroring the same content over to alm-env-vars.json +// closes the audit-file gap so a user inspecting docs/alm/ doesn't see two +// disagreeing snapshots. +// +// Best-effort: a missing last-env-vars.json (rare — both refresh callers +// only invoke this AFTER checking the sidecar exists) is a no-op rather than +// an error; alm-env-vars.json simply stays at whatever plan-alm wrote. +// Refresh-phase helper: rewrite docs/alm/alm-host-resolution.json with the +// current planData.hostResolution state. Without this, the file persists at +// whatever plan-alm Phase 1 captured (typically `{ready:false, status:"NoHost"}` +// for a fresh project) even after setup-pipeline / ensure-pipelines-host has +// resolved the host. Validation surfaced this case on Citizens portal — the +// stale "no host" snapshot sat alongside a resolved last-host-check.json, +// confusing the audit trail. +function mirrorHostResolutionSnapshot(planData, projectRoot) { + if (!projectRoot) return; + if (!planData || !planData.hostResolution || typeof planData.hostResolution !== 'object') return; + try { + const targetPath = almPath(projectRoot, 'hostResolution'); + const content = JSON.stringify(planData.hostResolution, null, 2); + const tmp = targetPath + '.tmp'; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, targetPath); + } catch { + // Best-effort. + } +} + +// Refresh-phase helper: patch `publisherPrefix` and `siteName` fields in +// docs/alm/alm-size-estimate.json with the post-setup-solution values from +// .solution-manifest.json. Without this, the estimate file persists at +// plan-time defaults (e.g. `cr5fe`, the new-project default) even after +// setup-solution established the actual publisher prefix (`c311`, etc.). +// Best-effort — a missing or unparseable file is a no-op. +function patchSizeEstimatePublisherFields(projectRoot, fields) { + if (!projectRoot || !fields) return; + try { + const estPath = almPath(projectRoot, 'sizeEstimate'); + if (!fs.existsSync(estPath)) return; + const raw = fs.readFileSync(estPath, 'utf8'); + let est; + try { est = JSON.parse(raw); } catch { return; } + let changed = false; + if (fields.publisherPrefix && est.publisherPrefix !== fields.publisherPrefix) { + est.publisherPrefix = fields.publisherPrefix; + changed = true; + } + if (fields.siteName && est.siteName !== fields.siteName) { + est.siteName = fields.siteName; + changed = true; + } + if (!changed) return; + const tmp = estPath + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(est, null, 2)); + fs.renameSync(tmp, estPath); + } catch { + // Best-effort. + } +} + +function mirrorEnvVarsSnapshot(projectRoot) { + if (!projectRoot) return; + try { + const lastEnvVarsPath = almPath(projectRoot, 'lastEnvVars'); + const almEnvVarsPath = almPath(projectRoot, 'envVars'); + if (!fs.existsSync(lastEnvVarsPath)) return; + const content = fs.readFileSync(lastEnvVarsPath, 'utf8'); + // Sanity check — only mirror if the source parses as JSON. + try { JSON.parse(content); } catch { return; } + // Same tmp-file + rename pattern used everywhere else in this module. + const tmp = almEnvVarsPath + '.tmp'; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, almEnvVarsPath); + } catch { + // Best-effort — don't break the broader refresh on a mirror failure. + } +} + +function refreshSetupSolution(planData, projectRoot) { + // After setup-solution runs, the planned-vs-existing distinction the + // renderer surfaces (Overview stat + Size Analysis signal + Env Variables + // tab) needs to flip: + // - plannedEnvVarCount → 0 (the planned set was either created or skipped) + // - planData.envVars[] → the freshly-created/adopted definitions + // setup-solution Phase 6 step 2b writes docs/alm/last-env-vars.json by running + // discover-env-var-definitions.js with post-setup state — we ingest that + // sidecar here. Without this, the rendered plan's Env Variables tab stays + // empty even though setup-solution just created definitions in Dataverse, + // and the Overview stat card stays at "0 / +N planned" forever. + if (typeof planData.plannedEnvVarCount === 'number' && planData.plannedEnvVarCount > 0) { + planData.plannedEnvVarCount = 0; + } + const envVarsMarker = projectRoot ? readJson(almPath(projectRoot, 'lastEnvVars')) : null; + if (envVarsMarker && Array.isArray(envVarsMarker.envVars)) { + // Discovery returns the same { schemaName, type, defaultValue, siteSetting } + // shape the renderer expects — pass through verbatim. Empty array is a + // valid post-state: setup-solution may have skipped all env vars (Tier 1 + // Skip-all + no Tier 2 promotions), in which case the tab should reflect + // the empty existing state instead of carrying stale planned counts. + planData.envVars = envVarsMarker.envVars; + } + // Refresh sizeAnalysis.envVarCount so the Overview stat card + Size Analysis + // signal reflect the post-setup-solution count (after OAuth promotions, + // credential conversions, and orphan adoptions land). Without this, the + // pre-setup snapshot from plan generation persists and the Overview shows + // a misleading "0 env vars" even after several were created. + const postCount = Array.isArray(planData.envVars) ? planData.envVars.length : 0; + if (planData.sizeAnalysis && typeof planData.sizeAnalysis === 'object') { + planData.sizeAnalysis = { + ...planData.sizeAnalysis, + envVarCount: { + ...(planData.sizeAnalysis.envVarCount || {}), + value: postCount, + }, + }; + } + // Mirror the freshly-written last-env-vars.json over to alm-env-vars.json so + // the plan-time snapshot stays current. Closes the audit gap where + // alm-env-vars.json sat at {envVars:[],count:0} after env vars existed. + mirrorEnvVarsSnapshot(projectRoot); + // Patch alm-size-estimate.json with the post-setup publisher prefix + + // siteName from .solution-manifest.json. Without this, the estimate file + // keeps the plan-time defaults (often `cr5fe` for fresh projects) even + // after setup-solution established the actual publisher (e.g. `c311`). + if (projectRoot) { + try { + const manifestPath = path.join(projectRoot, '.solution-manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const publisherPrefix = manifest.publisher && manifest.publisher.customizationPrefix; + const siteName = manifest.siteName || (planData && planData.SITE_NAME); + patchSizeEstimatePublisherFields(projectRoot, { publisherPrefix, siteName }); + } + } catch { + // Best-effort — no-op if the manifest is missing or malformed. + } + } + // Mark `LAST_SYNC_AT` so check-alm-plan.js's freshness check accounts for + // the source-solution modification this phase just caused. + stampLastSyncAt(planData); + // Step-sync: complete the "Setup solution" checklist entry. + setStepStatus(planData, { keyword: /\bsetup\s+solution\b/i, status: 'completed' }); + return planData; +} + +// Manual-path passthrough refreshes. The agent updates planData.steps[i].status +// before calling these phases, so the main work each handler does is trigger +// the re-render. Each may grow to ingest a per-stage marker file (e.g. +// docs/alm/last-import.json keyed by target stage) in a future iteration. + +function refreshExportSolution(planData, projectRoot) { + // export-solution writes: + // - the solution zip to disk + // - a `.solution-manifest.json` version bump + // - `docs/alm/last-export.json` marker (since 2026-05-25 — `bump-solution-version.js` + // + always-on Phase 4.0 bump) + // + // Ingest the marker into `planData.manualMeta.lastExport` so the rendered + // plan's Manual-path tab surfaces what was last shipped: solution name, + // bumped version (and the previous version it superseded), managed flag, + // zip path, and timestamp. The renderer is free to ignore fields it + // doesn't display today — we persist all the marker's known fields so + // future renderer changes don't have to round-trip through a refresh PR. + const exportMarker = readJson(almPath(projectRoot, 'lastExport')); + if (exportMarker) { + planData.manualMeta = planData.manualMeta || {}; + planData.manualMeta.lastExport = { + solutionUniqueName: exportMarker.solutionUniqueName || null, + solutionId: exportMarker.solutionId || null, + previousVersion: exportMarker.previousVersion || null, + version: exportMarker.version || null, + managed: typeof exportMarker.managed === 'boolean' ? exportMarker.managed : null, + sourceEnvironmentUrl: exportMarker.sourceEnvironmentUrl || null, + zipPath: exportMarker.zipPath || null, + fileSizeBytes: exportMarker.fileSizeBytes != null ? exportMarker.fileSizeBytes : null, + asyncOperationId: exportMarker.asyncOperationId || null, + exportedAt: exportMarker.exportedAt || null, + }; + } + + // export-solution Phase 4 Step 4.0 always-on-bump modifies source `modifiedon`. + // Stamp LAST_SYNC_AT so subsequent Phase 0 checks don't falsely flag stale. + stampLastSyncAt(planData); + // Step-sync: a successful invocation of --phase export-solution completes + // the "Export solution" checklist entry. Independent of marker presence — + // legacy projects on the manual path may not yet have the marker file even + // though the export succeeded. + setStepStatus(planData, { keyword: /\bexport\b/i, status: 'completed' }); + return planData; +} + +function refreshImportSolution(planData, projectRoot, stageName) { + // import-solution writes docs/alm/last-import.json with { solutionName, + // targetEnvironment, importedAt, status, componentResults }. For Manual + // path with multiple targets, the file reflects the MOST RECENT import — + // not a per-stage history. We resolve the stage label from --stageName + // (passed by plan-alm Phase 7's per-target loop) or by matching + // docs/alm/last-import.json's targetEnvironment URL against planData.stages[].envUrl. + // The result writes into planData.manualImports[stageName] (parallel to + // validationRuns[stageName]) so reviewers see per-target outcome on the + // rendered plan, not just the most recent. + const importMarker = readJson(almPath(projectRoot, 'lastImport')); + if (!importMarker) return planData; + + // Resolve the target stage label: explicit --stageName wins; fall back to + // matching the marker's targetEnvironment URL origin against the plan's + // stages array. If neither resolves, log a soft note via stderr but still + // capture the data under a synthetic key so the import isn't silently lost. + let resolvedStage = (typeof stageName === 'string' && stageName.length > 0) ? stageName : null; + if (!resolvedStage && importMarker.targetEnvironment && Array.isArray(planData.stages)) { + const matchOrigin = (u) => { + try { return new URL(u).origin.toLowerCase(); } catch { return null; } + }; + const targetOrigin = matchOrigin(importMarker.targetEnvironment); + if (targetOrigin) { + const hit = planData.stages.find((s) => matchOrigin(s.envUrl) === targetOrigin); + if (hit && hit.label) resolvedStage = hit.label; + } + } + if (!resolvedStage) { + // Defensive — write to a synthetic key so subsequent imports for resolvable + // stages don't clobber it. Caller should pass --stageName explicitly. + resolvedStage = `unresolved-${importMarker.targetEnvironment || 'unknown'}`; + } + + planData.manualImports = planData.manualImports || {}; + planData.manualImports[resolvedStage] = { + solutionName: importMarker.solutionName || null, + targetEnvironment: importMarker.targetEnvironment || null, + importedAt: importMarker.importedAt || null, + status: importMarker.status || null, + artifactVersion: importMarker.artifactVersion || importMarker.version || null, + componentCount: importMarker.componentCount != null ? importMarker.componentCount + : (Array.isArray(importMarker.componentResults) ? importMarker.componentResults.length : null), + componentFailureCount: Array.isArray(importMarker.componentResults) + ? importMarker.componentResults.filter((c) => c && c.status && /fail/i.test(c.status)).length + : null, + importJobId: importMarker.importJobId || null, + }; + // Step-sync: complete the "Import to {stage}" step unless the marker + // indicates failure. `resolvedStage` defended above; if it ended up as + // `unresolved-...` we still won't match a real step entry, so the call is + // safely a no-op in that branch. + if (!/^unresolved-/.test(resolvedStage)) { + const failed = /fail/i.test(String(importMarker.status || '')); + setStepStatus(planData, { + keyword: /\bimport\b/i, + stage: resolvedStage, + status: failed ? 'failed' : 'completed', + }); + } + return planData; +} + +function refreshActivateSite(planData, projectRoot, stageName) { + // activate-site Phase 5.1b writes docs/alm/last-activate.json with the post-activation + // state (siteUrl, websiteRecordId, environmentUrl, activatedAt, status). We + // ingest it into planData.activations[stageName] (parallel to validationRuns + // and manualImports) so the Manual-path "Activate site in {stage}" checklist + // step can render an ACTIVATED badge with the live site URL inline. + // + // Stage resolution: explicit --stageName wins; falls back to URL matching + // docs/alm/last-activate.json's environmentUrl against planData.stages[].envUrl when + // omitted. PP Pipelines path tracks activation in docs/alm/last-deploy.json instead + // (refreshDeployPipeline ingests it); the Manual-path standalone case is + // what this handler covers. + const marker = projectRoot ? readJson(almPath(projectRoot, 'lastActivate')) : null; + if (!marker) return planData; + + let resolvedStage = (typeof stageName === 'string' && stageName.length > 0) ? stageName : null; + if (!resolvedStage && marker.stageName) resolvedStage = marker.stageName; + if (!resolvedStage && marker.environmentUrl && Array.isArray(planData.stages)) { + const matchOrigin = (u) => { + try { return new URL(u).origin.toLowerCase(); } catch { return null; } + }; + const targetOrigin = matchOrigin(marker.environmentUrl); + if (targetOrigin) { + const hit = planData.stages.find((s) => matchOrigin(s.envUrl) === targetOrigin); + if (hit && hit.label) resolvedStage = hit.label; + } + } + if (!resolvedStage) { + resolvedStage = `unresolved-${marker.environmentUrl || 'unknown'}`; + } + + planData.activations = planData.activations || {}; + planData.activations[resolvedStage] = { + siteName: marker.siteName || null, + siteUrl: marker.siteUrl || null, + websiteRecordId: marker.websiteRecordId || null, + environmentUrl: marker.environmentUrl || null, + activatedAt: marker.activatedAt || null, + status: marker.status || null, + }; + // Step-sync: complete the "Activate site in {stage}" step. Activate-site + // is treated as success when the marker exists at all — activation failures + // halt the upstream skill before the marker writes, so a marker present + // means activation succeeded. + if (!/^unresolved-/.test(resolvedStage)) { + setStepStatus(planData, { + keyword: /\bactivate\b/i, + stage: resolvedStage, + status: 'completed', + }); + } + return planData; +} + +function refreshConfigureEnvVariables(planData, projectRoot) { + // configure-env-variables creates env var definitions (mirrors setup-solution's + // Phase 5.4 path) AND writes deployment-settings.json with per-stage values. + // Refresh responsibilities: + // 1. Re-read docs/alm/last-env-vars.json so newly-created definitions show + // up in planData.envVars[] (same sidecar setup-solution Phase 6.2b uses; + // configure-env-variables should write to it too for consistency). + // 2. Backfill values{} from deployment-settings.json (the file the skill + // just wrote — the per-stage matrix is now usable in the rendered plan). + // 3. Zero out plannedEnvVarCount — configuration phase is the moment the + // "planned" count converts to "actual". + // 4. Drop pre-run "env vars not yet configured" risks (defensive — current + // Phase 3 risks don't include this template, but a future addition is + // protected here). + // 5. Step-sync the matching checklist entry. + if (typeof planData.plannedEnvVarCount === 'number' && planData.plannedEnvVarCount > 0) { + planData.plannedEnvVarCount = 0; + } + const envVarsMarker = projectRoot ? readJson(almPath(projectRoot, 'lastEnvVars')) : null; + if (envVarsMarker && Array.isArray(envVarsMarker.envVars)) { + planData.envVars = envVarsMarker.envVars; + } + // Backfill is the major payoff for this phase — the user just authored + // per-stage values in deployment-settings.json and the rendered plan should + // surface them in the Values by Environment matrix immediately. + backfillEnvVarValuesFromSettings(planData, projectRoot); + planData.risks = dropResolvedRisks(planData.risks, 'configure-env-variables'); + // Refresh sizeAnalysis.envVarCount so the Overview stat card + Size Analysis + // signal reflect the post-config count, not the pre-setup-solution snapshot + // from plan generation. Without this, validation surfaced the case where + // sizeAnalysis.envVarCount.value stayed at 0 even after configure-env-variables + // had created two definitions. + const postCount = Array.isArray(planData.envVars) ? planData.envVars.length : 0; + if (planData.sizeAnalysis && typeof planData.sizeAnalysis === 'object') { + planData.sizeAnalysis = { + ...planData.sizeAnalysis, + envVarCount: { + ...(planData.sizeAnalysis.envVarCount || {}), + value: postCount, + }, + }; + } + // Mirror the latest last-env-vars.json over to alm-env-vars.json so both + // snapshots agree after configure-env-variables creates new definitions. + mirrorEnvVarsSnapshot(projectRoot); + // Env var definition creation + AddSolutionComponent bumps source `modifiedon`. + // Stamp LAST_SYNC_AT to keep subsequent Phase 0 checks accurate. + stampLastSyncAt(planData); + setStepStatus(planData, { keyword: /\bconfigure\s+env(?:ironment)?\s+var/i, status: 'completed' }); + return planData; +} + +function applyRefresh(planData, phase, projectRoot, stageName) { + switch (phase) { + case 'setup-solution': return refreshSetupSolution(planData, projectRoot); + case 'setup-pipeline': return refreshSetupPipeline(planData, projectRoot); + case 'configure-env-variables': return refreshConfigureEnvVariables(planData, projectRoot); + case 'deploy-pipeline': return refreshDeployPipeline(planData, projectRoot); + case 'export-solution': return refreshExportSolution(planData, projectRoot); + case 'import-solution': return refreshImportSolution(planData, projectRoot, stageName); + case 'activate-site': return refreshActivateSite(planData, projectRoot, stageName); + case 'test-site': return refreshTestSite(planData, projectRoot, stageName); + case 'finalize': return refreshFinalize(planData); + default: throw new Error('Unknown phase: ' + phase); + } +} + +function findRendererPath(rendererPath) { + if (rendererPath) return rendererPath; + // The helper lives at scripts/lib/; the renderer at skills/plan-alm/scripts/. + // Both are siblings under the plugin root. + return path.resolve(__dirname, '..', '..', 'skills', 'plan-alm', 'scripts', 'render-alm-plan.js'); +} + +function invokeRenderer(rendererPath, dataPath, outputPath) { + execFileSync(process.execPath, [rendererPath, '--data', dataPath, '--output', outputPath], { + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function refresh({ projectRoot, phase, render, rendererPath, stageName }) { + if (!projectRoot) throw new Error('--projectRoot is required'); + if (!phase) throw new Error('--phase is required'); + if (!PHASES.has(phase)) { + throw new Error('--phase must be one of: ' + [...PHASES].join(', ')); + } + + const dataPath = path.join(projectRoot, 'docs', '.alm-plan-data.json'); + const htmlPath = path.join(projectRoot, 'docs', 'alm-plan.html'); + + if (!fs.existsSync(dataPath)) { + return { + ok: false, + reason: 'docs/.alm-plan-data.json not found — was the file deleted? plan-alm Phase 3 writes it; the file must persist for post-run refreshes.', + dataPath, + }; + } + + let planData; + try { + planData = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + } catch (e) { + throw new Error('Could not parse docs/.alm-plan-data.json: ' + e.message); + } + + applyRefresh(planData, phase, projectRoot, stageName); + fs.writeFileSync(dataPath, JSON.stringify(planData, null, 2), 'utf8'); + + let rendered = false; + if (render) { + invokeRenderer(findRendererPath(rendererPath), dataPath, htmlPath); + rendered = true; + } + + return { ok: true, phase, dataPath, htmlPath, rendered }; +} + +if (require.main === module) { + const args = parseArgs(process.argv); + try { + const result = refresh(args); + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(result.ok ? 0 : 0); // ok:false is a soft no-op (missing planData) + } catch (err) { + process.stderr.write('refresh-alm-plan-data: ' + err.message + '\n'); + process.exit(1); + } +} + +module.exports = { + refresh, + buildHostResolutionFromCheck, + dropResolvedRisks, + setStepStatus, + backfillEnvVarValuesFromSettings, + extractPerStageValues, + PHASES, +}; diff --git a/plugins/power-pages/scripts/lib/resolve-env-by-id.js b/plugins/power-pages/scripts/lib/resolve-env-by-id.js new file mode 100644 index 000000000..ef84d56ac --- /dev/null +++ b/plugins/power-pages/scripts/lib/resolve-env-by-id.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +// Resolves a BAP environment GUID to instance URL + sku + linked metadata + permissions. +// Mirrors `useGetEnvironmentByName` from ProjectHostProvider.tsx — same BAP endpoint. +// +// GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/{envId} +// ?api-version=2020-06-01 +// &$expand=properties.linkedEnvironmentMetadata,properties.permissions +// +// 404 disambiguation: per PowerPipelines_PE_Knowledge.md §6.A, BAP returns 404 for +// deleted/disabled/no-PE/no-access without distinguishing. Callers must corroborate +// with list-tenant-envs.js before treating as "doesn't exist". +// +// Usage: node resolve-env-by-id.js --bapToken --envId +// [--apiVersion 2020-06-01] +// +// Output (JSON to stdout): +// 200 → { found: true, envId, instanceUrl, instanceApiUrl, displayName, environmentSku, isManaged, permissions, raw } +// 404 → { found: false, reason: "404-ambiguous", envId } +// 403 → throws (caller decides handling) +// +// Exit 0 on success (including found: false), exit 1 on error. + +'use strict'; + +const helpers = require('./validation-helpers'); +const { resolveEnvByIdViaPac } = require('./pac-bap-shim'); + +const DEFAULT_API_VERSION = '2020-06-01'; +const DEFAULT_BAP_BASE = 'https://api.bap.microsoft.com'; + +function parseArgs(argv) { + const args = argv.slice(2); + let bapToken = null; + let envId = null; + let apiVersion = DEFAULT_API_VERSION; + let bapBase = DEFAULT_BAP_BASE; + let source = 'auto'; // auto | bap | pac + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--bapToken' && args[i + 1]) bapToken = args[++i]; + else if (args[i] === '--envId' && args[i + 1]) envId = args[++i]; + else if (args[i] === '--apiVersion' && args[i + 1]) apiVersion = args[++i]; + else if (args[i] === '--bapBase' && args[i + 1]) bapBase = args[++i]; + else if (args[i] === '--source' && args[i + 1]) source = args[++i]; + } + + return { bapToken, envId, apiVersion, bapBase, source }; +} + +// Maps a BAP-shaped env (or PAC-shim-shaped one) to our consistent output. +function bapEnvToResult(data, fallbackEnvId) { + const props = data.properties || {}; + const linked = props.linkedEnvironmentMetadata || {}; + return { + found: true, + envId: data.name || fallbackEnvId, + instanceUrl: linked.instanceUrl || null, + instanceApiUrl: linked.instanceApiUrl || null, + displayName: props.displayName || null, + environmentSku: props.environmentSku || null, + isManaged: !!linked.isManaged, + permissions: props.permissions || {}, + location: data.location || null, + tenantId: props.tenantId || null, + azureRegionHint: props.azureRegionHint || null, + domainName: linked.domainName || null, + }; +} + +async function resolveViaBap({ bapToken, envId, apiVersion, bapBase }) { + if (!bapToken) { + const err = new Error('BAP token required for source=bap'); + err.statusCode = null; + throw err; + } + const cleanBase = bapBase.replace(/\/+$/, ''); + const expand = encodeURIComponent('properties.linkedEnvironmentMetadata,properties.permissions'); + const url = `${cleanBase}/providers/Microsoft.BusinessAppPlatform/environments/${encodeURIComponent(envId)}?api-version=${encodeURIComponent(apiVersion)}&$expand=${expand}`; + + const res = await helpers.makeRequest({ + url, + method: 'GET', + headers: { Authorization: `Bearer ${bapToken}`, Accept: 'application/json' }, + timeout: 15000, + }); + + if (res.error) { + const err = new Error(`BAP env GET failed: ${res.error}`); + err.statusCode = null; + throw err; + } + if (res.statusCode === 404) return { found: false, reason: '404-ambiguous', envId }; + if (res.statusCode === 403) { + const err = new Error(`BAP env GET returned 403 for env ${envId} — caller lacks permission`); + err.statusCode = 403; + throw err; + } + if (res.statusCode === 401) { + const err = new Error(`BAP env GET returned 401 for env ${envId} — token rejected by BAP`); + err.statusCode = 401; + throw err; + } + if (res.statusCode !== 200) { + const err = new Error(`BAP env GET returned unexpected status ${res.statusCode}: ${res.body}`); + err.statusCode = res.statusCode; + throw err; + } + + let data; + try { data = JSON.parse(res.body); } catch (e) { + throw new Error(`Failed to parse BAP env response: ${e.message}`); + } + return { ...bapEnvToResult(data, envId), sourceUsed: 'bap' }; +} + +async function resolveViaPac({ envId, pacExecImpl }) { + const env = await resolveEnvByIdViaPac({ envId, execImpl: pacExecImpl }); + if (!env) return { found: false, reason: 'not-in-pac-list', envId, sourceUsed: 'pac' }; + return { ...bapEnvToResult(env, envId), sourceUsed: 'pac' }; +} + +async function resolveEnvById({ + bapToken, + envId, + apiVersion = DEFAULT_API_VERSION, + bapBase = DEFAULT_BAP_BASE, + source = 'auto', + pacExecImpl = null, +} = {}) { + if (!envId) throw new Error('--envId is required'); + if (source === 'bap' && !bapToken) throw new Error('--bapToken is required when --source bap'); + + if (source === 'pac') { + return resolveViaPac({ envId, pacExecImpl }); + } + if (source === 'bap') { + return resolveViaBap({ bapToken, envId, apiVersion, bapBase }); + } + // auto: try BAP first if a token is available, else PAC; on 401/403 fall back to PAC + if (!bapToken) { + const r = await resolveViaPac({ envId, pacExecImpl }); + return { ...r, fallbackReason: 'no-bap-token-provided' }; + } + try { + return await resolveViaBap({ bapToken, envId, apiVersion, bapBase }); + } catch (e) { + const sc = e.statusCode; + if (sc === 401 || sc === 403) { + try { + const r = await resolveViaPac({ envId, pacExecImpl }); + return { ...r, fallbackReason: `bap-rejected-${sc}` }; + } catch (pacErr) { + throw e; // surface original BAP error + } + } + throw e; + } +} + +if (require.main === module) { + const opts = parseArgs(process.argv); + resolveEnvById(opts) + .then((result) => { + console.log(JSON.stringify(result)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { resolveEnvById, resolveViaBap, resolveViaPac, bapEnvToResult }; diff --git a/plugins/power-pages/scripts/lib/resolve-target-solution.js b/plugins/power-pages/scripts/lib/resolve-target-solution.js new file mode 100644 index 000000000..6f3742590 --- /dev/null +++ b/plugins/power-pages/scripts/lib/resolve-target-solution.js @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +// Resolves "which solution should this new Dataverse record land in?" +// Implements the strict 3-step order documented in plugins/power-pages/AGENTS.md +// under the ALM-aware-by-default principle: +// +// 1. Explicit --solutionUniqueName (or equivalent caller arg). +// 2. .solution-manifest.json in the project root. +// 3. Neither present — throw NoSolutionConfiguredError. +// +// The module NEVER auto-picks from Dataverse. Interactive prompt UX is the +// caller's responsibility: callers catch NoSolutionConfiguredError, present +// an AskUserQuestion list, and re-invoke with `explicit` populated. +// +// Callers that need to confirm the solution still exists in Dataverse can pass +// `verifyExists: true`; the module will GET /solutions and enrich the result +// with { solutionId, version, ismanaged }. +// +// Usage (as a module): +// const { resolveTargetSolution, NoSolutionConfiguredError } = require('./resolve-target-solution'); +// const r = await resolveTargetSolution({ explicit, projectRoot, envUrl, token, verifyExists: true }); +// // r === { solutionUniqueName, solutionId, version, ismanaged, source } +// +// Usage (as a CLI): +// node resolve-target-solution.js [--explicit ] [--projectRoot ] +// [--envUrl ] [--token ] [--verify] +// Exit 0 + JSON to stdout on success; exit 1 + message to stderr on failure. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const helpers = require('./validation-helpers'); + +class NoSolutionConfiguredError extends Error { + constructor(message, { hint } = {}) { + super(message); + this.name = 'NoSolutionConfiguredError'; + this.hint = hint; + } +} + +const NO_SOLUTION_HINT = + 'Run /power-pages:setup-solution to create a solution (writes .solution-manifest.json), ' + + 'or pass --solutionUniqueName to target an existing solution explicitly.'; + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + explicit: null, + projectRoot: null, + envUrl: null, + token: null, + verifyExists: false, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--explicit' && args[i + 1]) out.explicit = args[++i]; + else if (args[i] === '--solutionUniqueName' && args[i + 1]) out.explicit = args[++i]; + else if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + else if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; + else if (args[i] === '--token' && args[i + 1]) out.token = args[++i]; + else if (args[i] === '--verify') out.verifyExists = true; + } + return out; +} + +/** + * Reads `.solution-manifest.json` from `projectRoot` (or any ancestor + * directory up to the filesystem root). Returns the parsed object or null. + */ +function readManifest(projectRoot) { + let dir = projectRoot || process.cwd(); + const { root } = path.parse(dir); + while (true) { + const candidate = path.join(dir, '.solution-manifest.json'); + if (fs.existsSync(candidate)) { + try { + const raw = fs.readFileSync(candidate, 'utf8'); + return { path: candidate, data: JSON.parse(raw) }; + } catch (err) { + throw new Error( + `Found .solution-manifest.json at ${candidate} but it could not be parsed: ${err.message}` + ); + } + } + if (dir === root) return null; + dir = path.dirname(dir); + } +} + +/** + * Calls the Dataverse /solutions endpoint to confirm a solution by uniquename. + * Returns { solutionId, version, ismanaged } or null if not found. + */ +async function verifySolutionExists({ envUrl, token, uniqueName, makeRequest }) { + if (!envUrl) throw new Error('verifyExists: true requires envUrl'); + if (!token) throw new Error('verifyExists: true requires token'); + const cleanUrl = envUrl.replace(/\/+$/, ''); + // Validate uniquename — only alphanumeric + underscore allowed. Reject anything + // else up front so a typo surfaces as "invalid name" rather than a confusing + // "not found" after silently dropping characters. Also protects the OData + // filter below from injection. + const trimmed = String(uniqueName).trim(); + if (!/^[A-Za-z0-9_]+$/.test(trimmed)) { + throw new Error( + `Invalid solution unique name "${uniqueName}" — only alphanumeric and underscore characters are allowed.` + ); + } + const safeName = trimmed; + const url = + `${cleanUrl}/api/data/v9.2/solutions` + + `?$filter=uniquename eq '${safeName}'` + + `&$select=solutionid,uniquename,version,ismanaged`; + const res = await makeRequest({ + url, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + if (res.error) throw new Error(`Solution lookup failed: ${res.error}`); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`Solution lookup returned ${res.statusCode}: ${(res.body || '').slice(0, 300)}`); + } + const parsed = JSON.parse(res.body); + const row = (parsed.value || [])[0]; + if (!row) return null; + return { + solutionId: row.solutionid, + version: row.version, + ismanaged: row.ismanaged, + }; +} + +/** + * Resolves the target solution per the ALM-aware-by-default resolution order. + * + * @param {object} opts + * @param {string} [opts.explicit] - From --solutionUniqueName CLI arg. Highest priority. + * @param {string} [opts.projectRoot] - Directory to start manifest search. Defaults to cwd. + * @param {boolean} [opts.verifyExists=false] - When true, GET /solutions to confirm. + * @param {string} [opts.envUrl] - Required when verifyExists is true. + * @param {string} [opts.token] - Required when verifyExists is true. + * @param {Function} [opts.makeRequest] - Injected for tests. + * + * @returns {Promise<{ + * solutionUniqueName: string, + * solutionId?: string, + * version?: string, + * ismanaged?: boolean, + * source: 'arg' | 'manifest', + * manifestPath?: string + * }>} + * + * @throws {NoSolutionConfiguredError} when neither explicit nor manifest yields a name. + * @throws {Error} on parse or verification failures. + */ +async function resolveTargetSolution({ + explicit = null, + projectRoot = null, + verifyExists = false, + envUrl = null, + token = null, + makeRequest = helpers.makeRequest, +} = {}) { + // Step 1: explicit arg wins unconditionally. + if (explicit && String(explicit).trim()) { + const result = { + solutionUniqueName: String(explicit).trim(), + source: 'arg', + }; + if (verifyExists) { + const verified = await verifySolutionExists({ + envUrl, + token, + uniqueName: result.solutionUniqueName, + makeRequest, + }); + if (!verified) { + throw new Error( + `Solution "${result.solutionUniqueName}" not found in ${envUrl}. ` + + `Either create it first with /power-pages:setup-solution or check the name.` + ); + } + Object.assign(result, verified); + } + return result; + } + + // Step 2: .solution-manifest.json. + const manifest = readManifest(projectRoot); + if (manifest && manifest.data && manifest.data.solution && manifest.data.solution.uniqueName) { + const m = manifest.data.solution; + const result = { + solutionUniqueName: m.uniqueName, + solutionId: m.solutionId, + version: m.version, + source: 'manifest', + manifestPath: manifest.path, + }; + if (verifyExists) { + const verified = await verifySolutionExists({ + envUrl, + token, + uniqueName: result.solutionUniqueName, + makeRequest, + }); + if (!verified) { + throw new Error( + `.solution-manifest.json references "${result.solutionUniqueName}" but it was not found in ${envUrl}. ` + + `The manifest may be stale or point to a different environment. ` + + `Run /power-pages:setup-solution to recreate, or delete the manifest and start fresh.` + ); + } + // Manifest may be stale on solutionId/version; prefer live data. + Object.assign(result, verified); + } + return result; + } + + // Step 3: nothing resolves — throw with an actionable hint. + throw new NoSolutionConfiguredError( + 'No target solution could be resolved — no --solutionUniqueName argument and no .solution-manifest.json found.', + { hint: NO_SOLUTION_HINT } + ); +} + +if (require.main === module) { + const args = parseArgs(process.argv); + resolveTargetSolution(args) + .then((r) => { + process.stdout.write(JSON.stringify(r)); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${err.message}\n`); + if (err.hint) process.stderr.write(`Hint: ${err.hint}\n`); + process.exit(1); + }); +} + +module.exports = { + resolveTargetSolution, + readManifest, + verifySolutionExists, + NoSolutionConfiguredError, + NO_SOLUTION_HINT, +}; diff --git a/plugins/power-pages/scripts/lib/strip-invalid-secret-values.js b/plugins/power-pages/scripts/lib/strip-invalid-secret-values.js new file mode 100644 index 000000000..eb618c074 --- /dev/null +++ b/plugins/power-pages/scripts/lib/strip-invalid-secret-values.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +// Defensive write-back: strips invalid Secret-reference values from +// deployment-settings.json by setting `Value: ""` (which Dataverse interprets +// as "use the env var definition's default") for specified schema names. +// Used by deploy-pipeline Phase 7.6.4 as the strip-and-retry remediation +// when an import fails with the canonical Secret-reference validation +// error pattern. +// +// The pre-write validator (configure-env-variables Phase 6.1) catches these +// at write time so they shouldn't reach the deploy. This helper is the +// backstop for the cases where: +// - The user hand-edited deployment-settings.json after configure-env-variables. +// - A legacy deployment-settings.json was committed before Phase 6.1 existed. +// - validate-deployment-settings.js's pre-PATCH gate (Phase 5.1b) was bypassed +// by a transient failure or returned `status: "unknown-type"`. +// +// Empty-string semantics: per the canonical-Secret-format note in +// configure-env-variables Phase 3, `Value: ""` means "use the env var +// definition's default in this stage" — the safest fallback when a stage's +// Secret reference is malformed. The user can update the value to a real +// Key Vault URI later via configure-env-variables. +// +// Usage: +// node strip-invalid-secret-values.js \ +// --settingsFile ./deployment-settings.json \ +// --schemaNames foo_secret,bar_apikey \ +// [--stageLabel "Deploy to Staging"] +// +// Behavior: +// - Reads settingsFile, parses JSON, accepts both top-level-stage AND +// nested-`stages` shapes (matching the schemas accepted by +// validate-deployment-settings.js and refresh-alm-plan-data.js). +// - For each entry in EnvironmentVariables[] whose SchemaName matches the +// --schemaNames CSV, sets Value to "" (preserving the entry's structure). +// - If --stageLabel is provided, only strips within that stage's +// EnvironmentVariables[]. Without it, strips across ALL stages. +// - Atomic tmp+rename to avoid mid-write corruption. +// +// Output (JSON to stdout): +// { +// "ok": true, +// "settingsFile": "", +// "stripped": [ +// { "stage": "Deploy to Staging", "schemaName": "foo_secret", "previousValue": "@KeyVault(...)" } +// ], +// "notFound": ["bar_apikey"], // schema names that didn't appear in any stage +// "totalStagesScanned": 2 +// } +// +// Exit 0 on success (including "no matches found"), exit 1 on fatal errors +// (missing args, unparseable settings file, write failure). + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + settingsFile: null, + schemaNames: null, + stageLabel: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--settingsFile' && args[i + 1]) out.settingsFile = args[++i]; + else if (args[i] === '--schemaNames' && args[i + 1]) out.schemaNames = args[++i]; + else if (args[i] === '--stageLabel' && args[i + 1]) out.stageLabel = args[++i]; + } + return out; +} + +// Pick the right object to iterate stages over — accepts both shapes. +// Same logic as extractPerStageValues in refresh-alm-plan-data.js. +function resolveStagesContainer(deploymentSettings) { + if (!deploymentSettings || typeof deploymentSettings !== 'object') return null; + if (deploymentSettings.stages && typeof deploymentSettings.stages === 'object' + && !Array.isArray(deploymentSettings.stages)) { + return deploymentSettings.stages; + } + return deploymentSettings; +} + +// Reserved keys at the root that are NOT stage entries. +const NON_STAGE_KEYS = new Set(['$schema', 'description', 'stages', 'EnvironmentVariables', 'ConnectionReferences']); + +function stripInvalidSecretValues({ settingsFile, schemaNames, stageLabel, specs }) { + if (!settingsFile) throw new Error('--settingsFile is required'); + // schemaNames can be a CSV string (from CLI) or an array (programmatic). + let nameSet; + if (Array.isArray(specs) && specs.length > 0) { + nameSet = new Set(specs.map((s) => String(s).trim()).filter(Boolean)); + } else if (typeof schemaNames === 'string' && schemaNames.trim()) { + nameSet = new Set(schemaNames.split(',').map((s) => s.trim()).filter(Boolean)); + } else if (Array.isArray(schemaNames) && schemaNames.length > 0) { + nameSet = new Set(schemaNames.map((s) => String(s).trim()).filter(Boolean)); + } else { + throw new Error('--schemaNames (or specs[]) must list at least one schema name to strip'); + } + + const absPath = path.resolve(settingsFile); + if (!fs.existsSync(absPath)) { + throw new Error(`settings file not found: ${absPath}`); + } + const raw = fs.readFileSync(absPath, 'utf8'); + let settings; + try { + settings = JSON.parse(raw); + } catch (e) { + throw new Error(`settings file is not valid JSON: ${e.message}`); + } + + const stagesContainer = resolveStagesContainer(settings); + if (!stagesContainer) { + throw new Error('settings file does not contain a parseable stages container'); + } + + const stripped = []; + const seenSchemaNames = new Set(); + let totalStagesScanned = 0; + + for (const [maybeStageKey, stageBlock] of Object.entries(stagesContainer)) { + // Filter out non-stage keys when iterating the root-level shape. + if (NON_STAGE_KEYS.has(maybeStageKey)) continue; + if (!stageBlock || typeof stageBlock !== 'object' || Array.isArray(stageBlock)) continue; + + // Scope to a specific stage if --stageLabel was supplied. + if (stageLabel && maybeStageKey !== stageLabel) continue; + totalStagesScanned += 1; + + const envVars = stageBlock.EnvironmentVariables || stageBlock.environmentVariables; + if (!Array.isArray(envVars)) continue; + + for (const ev of envVars) { + if (!ev || typeof ev !== 'object') continue; + const evSchemaName = ev.SchemaName || ev.schemaName; + if (!evSchemaName) continue; + seenSchemaNames.add(evSchemaName); + if (!nameSet.has(evSchemaName)) continue; + const previousValue = ev.Value != null ? ev.Value : ev.value; + if (previousValue === '' || previousValue == null) continue; // already stripped + // Preserve whichever key shape the entry was using. + if ('Value' in ev) ev.Value = ''; + if ('value' in ev) ev.value = ''; + // Cover the case where neither key was present (defensive). + if (!('Value' in ev) && !('value' in ev)) ev.Value = ''; + stripped.push({ stage: maybeStageKey, schemaName: evSchemaName, previousValue }); + } + } + + const notFound = Array.from(nameSet).filter((name) => !seenSchemaNames.has(name)); + + // Only write if we actually changed something. + if (stripped.length > 0) { + const tmpPath = absPath + '.tmp'; + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2)); + fs.renameSync(tmpPath, absPath); + } + + return { + ok: true, + settingsFile: absPath, + stripped, + notFound, + totalStagesScanned, + }; +} + +if (require.main === module) { + const args = parseArgs(process.argv); + try { + const result = stripInvalidSecretValues(args); + console.log(JSON.stringify(result, null, 2)); + process.exit(0); + } catch (e) { + process.stderr.write('strip-invalid-secret-values: ' + e.message + '\n'); + process.exit(1); + } +} + +module.exports = { stripInvalidSecretValues, resolveStagesContainer, NON_STAGE_KEYS }; diff --git a/plugins/power-pages/scripts/lib/validate-deployment-settings.js b/plugins/power-pages/scripts/lib/validate-deployment-settings.js new file mode 100644 index 000000000..17c5edfd2 --- /dev/null +++ b/plugins/power-pages/scripts/lib/validate-deployment-settings.js @@ -0,0 +1,460 @@ +#!/usr/bin/env node + +// Pre-deploy validator for deployment-settings.json. Classifies each +// EnvironmentVariables[] entry by value format and (when --envUrl is +// provided) cross-checks the value against the env var's declared type +// on the dev environment. +// +// Why this exists: the Power Platform Pipelines handler validates the +// `deploymentsettingsjson` PATCH at import time, AFTER the stage run has +// been queued and potentially after a long wait behind serialized imports. +// A bad Secret reference value (placeholder like `@KeyVault(vaultName=...)`, +// raw secret value, malformed URI) fails the import with: +// +// ImportAsHolding failed: The value provided as a secret reference does +// not match a valid secret reference format. +// +// This can sit in the host's serialized import queue for hours before +// failing. Catching the bad reference upfront — at Phase 5 before the +// PATCH — turns a multi-hour wait-then-fail into a sub-second hard stop +// with a precise remediation pointer. +// +// Usage: +// node validate-deployment-settings.js +// --settingsFile (required) +// [--envUrl ] (optional — looks up env var +// types on the dev env to +// enforce Secret-format +// rules; without it, only +// structural checks run) +// [--stageLabel

+ + + + +
+ + +
+
+ + +
+
__STATUS_ICON__
+
+
__STATUS_LABEL__
+
__SOLUTION_NAME__ v__ARTIFACT_VERSION__ → __STAGE_NAME__
+
+
+ + +
+
+
Stage
+
__STAGE_NAME__
+
+
+
Version
+
v__ARTIFACT_VERSION__
+
+
+
Status
+
__STATUS_LABEL__
+
+
+
Deployed At
+
__DEPLOYED_AT__
+
+
+ + +
+

Deployment Details

+

Run metadata for this pipeline stage execution.

+
+ + + + + + + + + + + +
Solution__SOLUTION_NAME__ — __SOLUTION_FRIENDLY_NAME__
Artifact Version__ARTIFACT_VERSION__ (from __PREV_ARTIFACT_VERSION__)
Stage__STAGE_NAME__
Target Environment__TARGET_ENV_URL__
Pipeline__PIPELINE_NAME__
Stage Run ID__STAGE_RUN_ID__
Deployed At__DEPLOYED_AT__
Status__STATUS_LABEL__
+
+
+ + + __ACTIVATION_SECTION__ + +
+
+ + +
+
+ +
+

Solution

+

What was deployed — solution metadata, validation outcome, and component inventory.

+ + +
+ + + + __SOLUTION_META_ROWS__ + +
Solution Metadata
+
+ + + __VALIDATION_SECTION__ + + + __SOLUTION_CONTENTS_SECTION__ +
+ +
+
+ + +
+
+ +
+

Configuration & Notes

+

Environment variable overrides applied at deploy time, AI deployment notes, and post-deployment action items.

+ + __ENV_VARS_SECTION__ + __DEPLOYMENT_NOTES_SECTION__ + __POST_DEPLOY_WARNINGS__ + +
+
+
+ +
+ + + + + + + diff --git a/plugins/power-pages/skills/deploy-pipeline/scripts/validate-deploy-pipeline.js b/plugins/power-pages/skills/deploy-pipeline/scripts/validate-deploy-pipeline.js new file mode 100644 index 000000000..883c14160 --- /dev/null +++ b/plugins/power-pages/skills/deploy-pipeline/scripts/validate-deploy-pipeline.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +// Validates that deploy-pipeline completed: checks docs/alm/last-deploy.json for required fields. +// Blocks if status is "Failed" — a failed deployment requires investigation before retrying. +// Gracefully exits 0 when no deploy marker is found (not a deploy-pipeline session). + +const fs = require('fs'); +const { approve, block, runValidation, findProjectRoot, findPath, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); +const { almPath } = require('../../../scripts/lib/alm-paths'); + +runValidation(async (cwd) => { + if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. + const projectRoot = findProjectRoot(cwd) || cwd; + + const markerPath = almPath(projectRoot, 'lastDeploy'); + + // No deploy marker found — not a deploy-pipeline session + if (!fs.existsSync(markerPath)) return approve(); + + let marker; + try { + marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + return block('docs/alm/last-deploy.json exists but could not be parsed as JSON.'); + } + + if (!marker.pipelineId) { + return block('docs/alm/last-deploy.json is missing required field: pipelineId'); + } + if (!marker.stageRunId) { + return block('docs/alm/last-deploy.json is missing required field: stageRunId'); + } + if (!marker.solutionName) { + return block('docs/alm/last-deploy.json is missing required field: solutionName'); + } + if (!marker.status) { + return block('docs/alm/last-deploy.json is missing required field: status'); + } + if (!marker.deployedAt) { + return block('docs/alm/last-deploy.json is missing required field: deployedAt'); + } + + if (marker.status === 'Failed') { + return block( + `Last deployment to "${marker.stageName || 'unknown stage'}" failed (stageRunId: ${marker.stageRunId}). ` + + 'Investigate the failure in Power Platform (make.powerapps.com → Solutions → Pipelines) before retrying.' + ); + } + + // Check that deploy history HTML was written + if (marker.deployHistoryFile) { + const historyPath = findPath(projectRoot, marker.deployHistoryFile) + || require('path').join(projectRoot, marker.deployHistoryFile); + if (!fs.existsSync(historyPath)) { + return block( + `Deploy history file not found: ${marker.deployHistoryFile}. ` + + 'Phase 7.4 must write the deploy history HTML before the skill completes.' + ); + } + const size = fs.statSync(historyPath).size; + if (size < 500) { + return block(`Deploy history file is too small (${size} bytes): ${marker.deployHistoryFile}`); + } + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/diagnose-deployment/SKILL.md b/plugins/power-pages/skills/diagnose-deployment/SKILL.md new file mode 100644 index 000000000..70f8d5ab8 --- /dev/null +++ b/plugins/power-pages/skills/diagnose-deployment/SKILL.md @@ -0,0 +1,224 @@ +--- +name: diagnose-deployment +description: >- + Surfaces PAC CLI upload errors and Dataverse async operation errors, pattern-matches + against a known failure catalog, and optionally auto-fixes identified issues. Use when + asked to: "diagnose deployment", "debug deployment", "deployment failed", "show + deployment errors", "fix deployment issues", "show upload logs", "why did my deploy fail", + or "troubleshoot upload". +user-invocable: true +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# diagnose-deployment + +Surfaces and pattern-matches deployment errors against a known failure catalog. For each identified error with an available auto-fix, asks explicit user permission before applying any changes. Never auto-applies fixes without confirmation. + +## Prerequisites + +- Project root with `powerpages.config.json` +- PAC CLI installed (will report if missing) + +## Phases + +### Phase 1 — Verify Prerequisites and Locate Project + +**Create all tasks upfront at the start of this phase.** + +Tasks to create: +1. "Verify prerequisites and locate project" +2. "Collect deployment artifacts" +3. "Surface upload errors" +4. "Query solution import status" +5. "Diagnose and categorize findings" +6. "Offer auto-fixes" +7. "Present findings summary" + +Steps: +1. Locate project root: search for `powerpages.config.json` in cwd and parent directories +2. Check PAC CLI: `pac --version` (report version or "not installed") +3. Check PAC CLI auth: `pac env who` (report environment URL or "not authenticated") +4. Check Azure CLI: `az account show` (report subscription or "not logged in") + +Auth failures are non-blocking — report them as findings, continue collecting other artifacts. + +### Phase 1.5 — Ground in current ALM documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: `Power Pages deployment errors solution import troubleshooting`. +2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/solution-concepts-alm` (and at most one sister page on troubleshooting or known import errors) in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what Microsoft Learn currently says about common deployment failures and their resolution. Compare against `${CLAUDE_PLUGIN_ROOT}/references/deployment-error-catalog.md` and flag any new error patterns not yet captured in the catalog. +4. Use the summary to inform pattern-matching in Phase 5. If a new pattern is documented on Learn that isn't in the catalog, surface it to the user as a candidate addition rather than silently extending the catalog. + +### Phase 2 — Collect Deployment Artifacts + +Gather all available context: + +1. Read `powerpages.config.json` — extract `siteName`, `websiteRecordId`, `compiledPath` +2. Check `.powerpages-site/` folder exists +3. Glob for manifest files: `.powerpages-site/*-manifest.yml` — list all found, note their environment hostnames +4. Check if `.solution-manifest.json` exists (for solution-related diagnostics) +5. Check if `docs/alm/last-import.json` exists (for recent import failures) +6. Check build output: confirm `{compiledPath}/` exists and is non-empty + +Report: "Found project: `{siteName}`. Artifacts collected." + +### Phase 3 — Surface Upload Errors + +Re-run `pac pages upload-code-site` in capture mode to get fresh error output: + +```bash +pac pages upload-code-site --rootPath "." 2>&1 +``` + +> **Note**: This intentionally triggers the upload to capture any errors. If the upload succeeds cleanly, that is also a valid diagnostic result ("no errors found"). + +Capture stdout+stderr as a single string. Pass to `scripts/parse-deployment-errors.js`: + +```bash +echo "{escaped-output}" | node "${CLAUDE_PLUGIN_ROOT}/scripts/parse-deployment-errors.js" +``` + +Parse the JSON findings array. If the upload succeeded with no errors, note this and skip to Phase 5 with an empty findings list. + +### Phase 4 — Query Solution Import Status + +Only run if `.solution-manifest.json` exists. + +1. Acquire Azure CLI token for environment URL +2. Check recent async operations (last 24 hours): + ``` + GET {envUrl}/api/data/v9.2/asyncoperations?$filter=statecode eq 3 and statuscode eq 31 and createdon gt {yesterday}&$select=asyncoperationid,name,message,friendlymessage,statuscode,completedon&$orderby=completedon desc&$top=5 + ``` +3. Check recent import jobs: + ``` + GET {envUrl}/api/data/v9.2/importjobs?$select=solutionname,completedon,progress&$orderby=completedon desc&$top=3 + ``` +4. If failed operations found, pass each `message` field through `parse-deployment-errors.js` + +Skip gracefully if auth is not available (auth failure in Phase 1). + +### Phase 5 — Auto-Diagnose Known Issues + +Consolidate all findings from Phases 3 and 4. For each finding, categorize: + +- **Error**: Blocks deployment +- **Warning**: May cause issues +- **Info**: Informational + +Also add findings for missing artifacts discovered in Phase 2: +- Missing `websiteRecordId` → Error (patternId: `missing-website-record-id`) +- Empty build output → Error (patternId: `empty-build`) +- Multiple environment manifests → Warning (may indicate environment confusion) + +Present all findings in a table: + +| # | Severity | Type | Issue | Auto-fix? | +|---|---|---|---|---| +| 1 | Error | upload | JavaScript uploads blocked | Yes | +| 2 | Warning | config | Multiple manifest files found | No | + +### Phase 6 — Offer Auto-Fixes + +For each Error finding with `autoFixAvailable: true`, in order: + + +> 🚦 **Gate (consent · diagnose-deployment:6.auto-fix):** Per-finding consent before applying any auto-fix. **Loops once per Error finding with `autoFixAvailable: true`** — each finding gets its own Yes / No / Skip-all `AskUserQuestion`. The pattern ID surfaces in the prompt. **Never batch fixes** — three findings = three separate consent prompts (unless the user picks "Skip all" on the first, which short-circuits the loop). The Yes from finding 1 does NOT cover finding 2; each fix has its own blast radius (different files, different settings, different reversibility). + +1. Explain the issue and proposed fix +2. Ask explicit permission via `AskUserQuestion`: + > "Issue: {message} + > Proposed fix: {suggestedFix} + > Apply this fix? Yes / No / Skip all auto-fixes" + +3. If approved, execute the fix: + + **`stale-manifest`**: Delete `*-manifest.yml` file(s) in `.powerpages-site/` + ```bash + # Ask which manifest to delete if multiple found, then: + rm ".powerpages-site/{manifestFile}" + ``` + + **`blocked-js`**: Update `blockedattachments` setting + ```bash + # Get current setting first + pac env list-settings | grep -i blocked + # Remove .js from the blocked list (preserve other blocked types) + pac env update-settings --name blockedattachments --value "{updated-value}" + ``` + + **`missing-website-record-id`**: Retrieve and update record ID + ```bash + pac pages list + # Parse output, find matching site by name, then update powerpages.config.json + ``` + + **`auth-expired`**: Guide re-authentication + ```bash + pac auth create --environment "{envUrl}" + az login + ``` + + **`empty-build`**: Run build + ```bash + npm run build + ``` + +4. After each fix, re-run the relevant check to verify it resolved the issue. Update finding status to "Fixed" or "Manual required". + +> **Key Constraint**: Never apply any fix without explicit user permission. Each fix requires a separate confirmation. + +### Phase 7 — Present Findings Summary + +Display a final summary table of all findings: + +| # | Severity | Type | Issue | Status | +|---|---|---|---|---| +| 1 | Error | upload | JS uploads blocked | Fixed | +| 2 | Error | config | Missing websiteRecordId | Manual required | +| 3 | Warning | config | Multiple manifest files | Informational | + +**Status values**: +- **Fixed**: Auto-fix was applied and verified +- **Manual required**: No auto-fix available — show manual steps +- **Skipped**: User declined the fix +- **Informational**: Warning/Info, no action needed + +If all errors are resolved: suggest retrying deployment with `/power-pages:deploy-site`. + +If manual steps remain: list them explicitly with commands or links. + +### Record Skill Usage + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "DiagnoseDeployment"`. + +## Key Decision Points (Wait for User) + +1. **Phase 6**: Each individual auto-fix requires explicit confirmation before applying +2. **Phase 6**: If user says "Skip all auto-fixes", stop offering fixes and go to summary + +## Error Handling + +- If `pac pages upload-code-site` produces no output: report "No upload errors detected in current state" +- If Azure CLI token unavailable: skip Phase 4, note in summary +- If `parse-deployment-errors.js` returns no findings: report "No known error patterns detected" and show raw output for manual review + +## Progress Tracking Table + +| Task subject | activeForm | Description | +|---|---|---| +| Verify prerequisites and locate project | Verifying prerequisites | Check PAC CLI, Azure CLI, locate project root and powerpages.config.json | +| Collect deployment artifacts | Collecting deployment artifacts | Read config, list manifests, check build output, check solution manifest | +| Surface upload errors | Surfacing upload errors | Re-run pac pages upload-code-site in capture mode, parse stdout/stderr | +| Query solution import status | Querying solution import status | Check recent failed async operations and import jobs in Dataverse | +| Diagnose and categorize findings | Diagnosing findings | Pattern-match all errors against deployment-error-catalog, assign severity | +| Offer auto-fixes | Applying auto-fixes | For each fixable error, ask permission and execute fix, verify result | +| Present findings summary | Presenting summary | Show all findings table with severity and fix status, list manual steps | diff --git a/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md b/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md new file mode 100644 index 000000000..6852386bc --- /dev/null +++ b/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md @@ -0,0 +1,1010 @@ +--- +name: ensure-pipelines-host +description: >- + Ensures the tenant has a usable Power Platform Pipelines host environment + before any pipeline operation runs. Detects host state via the same + resolution order as the Power Apps UI (org-db setting → BAP env metadata → + default-custom-host setting); if any existing host (Platform or Custom) is + found, uses it. If no host is bound to the source env, provisions a new + **Platform Host** (recommended, idempotent) or a **Custom Host** via the + BAP env-create API with the `D365_ProjectHost` template, or guides the user + through PPAC install / `New custom host` (manual fallbacks). Polls + lifecycle operations, verifies the host responds to Pipelines API calls, + writes a host-check artifact other ALM skills consume. Use when asked to: + "set up pipelines host", "ensure pipelines host", "no pipelines host", + "install pipelines", "create pipelines host", "provision platform host", + "provision custom host". Also invoked transparently by + /power-pages:setup-pipeline when its host discovery step finds nothing. +user-invocable: true +argument-hint: "Optional: 'detect-only' to skip provisioning paths and report state; 'auto-platform' to run the Platform-Host fast-path (idempotent, ~3–5 min) without the path-decision prompt (still gated by tenant pre-call confirmation); 'auto-custom' to run the Custom-Host fast-path without the path-decision prompt (still gated by tenant + admin-role + pre-call-echo prompts)" +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + + + +# ensure-pipelines-host + +> **Scope:** When no host is bound to the source env, this skill detects any existing host (Custom or PE) for reuse, or — in `NoHost` state — offers three provisioning paths: a new **Platform Host** (recommended; idempotent, ~3–5 min); a new **Custom Host** (admin-only, ~5–10 min); or PPAC manual provisioning (fallback). Implementation details — endpoint names, template names, BAP audience — live in Phase 4.0 / 4.A / 4.C below; user-facing prose stays focused on outcomes. + +Power Platform Pipelines need a **host environment** — a Dataverse environment with the *Power Platform Pipelines* managed solution installed, where pipelines, stages, run history, and artifacts live. The existing `setup-pipeline` and `deploy-pipeline` skills assume a host is already configured. This skill closes that gap. + +## What we know (sources of truth) + +This plan is grounded in three primary sources, in priority order: + +1. **`useGetOrCreatePlatformEnvironment.v4.ts`** (Microsoft-internal client source — `power-platform-ux/packages/powerapps-appdeployment-ux/src/hooks/v4/`). Defines the exact HTTP contract for Platform Environment provisioning: endpoint, body, headers, polling. +2. **`ProjectHostProvider.tsx`** (same repo, `src/components/ProjectHostProvider/`). Defines the exact resolution order the Power Apps UI uses to determine which environment is the project host for a source environment. We mirror that order so this skill agrees with the UI. +3. **eng.ms `createcustompipelineshost`** (Microsoft-internal). Documents the Custom Host fast-path: a `D365_ProjectHost` org template that ships the Pipelines app pre-installed, callable through the standard environment-creation API. + +Public Microsoft Learn (`learn.microsoft.com/power-platform/alm/{platform-host-pipelines, custom-host-pipelines, set-a-default-pipelines-host}`) is the user-facing description of the same flows; we cite it for behaviors users will recognize. HARs in `PipelinesDeployScenario.har` and `Pipelines.har` confirm the read-side calls. + +## Three host shapes the tenant can be in + +| Shape | How it got there | Where it lives | Org template | +|---|---|---|---| +| **Platform Host (PE)** | Auto-provisioned by `getOrCreate` BAP call (or as a side-effect of first navigation to the Pipelines page in `make.powerapps.com`). Hidden from the env picker. One per tenant. | Microsoft-managed Dataverse env in tenant's home geo | `D365_1stPartyAdminApps` | +| **Custom Host** | Created by an admin via PPAC `Deployments → New custom host`, or via the standard env-create API with the `D365_ProjectHost` template, or by installing the Power Platform Pipelines app on an existing Dataverse env. | A regular Dataverse env in the tenant | `D365_ProjectHost` (or app-installed-onto-existing-env) | +| **No host bound to source env** | Tenant has not used Pipelines from this env. | — | — | + +The current `discover-pipelines-host.js` only checks the tenant-level `DefaultCustomPipelinesHostEnvForTenant` setting. That's one signal of many. This skill implements the full resolution order. + +## Resolution order (mirrors `ProjectHostProvider.tsx`) + +This is the load-bearing decision tree. It is what the Power Apps UI does. We replicate it so the skill agrees with the UI. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. GetOrgDbOrgSetting('ProjectHostEnvironmentId') on source env │ +└──────────────────────────┬──────────────────────────────────────────┘ + │ + ┌──────────────┴───────────────┐ + │ value present │ value empty + ▼ ▼ +┌───────────────────────┐ ┌────────────────────────────┐ +│ 2. Resolve env via │ │ 5a. Tenant-wide search: │ +│ BAP GET │ │ list envs + per-env │ +│ /environments/{id} │ │ /deploymentpipelines │ +└───────┬───────────────┘ │ probe. │ + │ │ │ + environmentSku? │ - 1 Custom Host found → │ + │ │ AvailableUnboundCustom │ + ┌────┴────────────┐ │ (3.C-pre) │ + │ Platform │ │ - >1 Custom Hosts → │ + │ │ │ MultipleUnboundCustom │ + │ │ │ (3.C-pre') │ + │ │ │ - PE only → │ + │ │ │ PlatformHostExists- │ + │ │ │ Unbound (3.C-pre'') │ + │ │ │ - none → NoHost (3.C) │ + │ │ │ │ + │ │ │ 5b. Decision tree paths │ + │ │ │ for create-new (3.C): │ + │ │ │ - Platform getOrCreate │ + │ │ │ (fast-path, no admin) │ + │ │ │ - Custom D365_ProjectHost│ + │ │ │ (fast-path, admin) │ + │ │ │ - Manual app install │ + │ │ │ - Manual PPAC create │ + │ │ └────────────────────────────┘ + ▼ │ +┌──────────────┐ │ +│ 3. Check │ │ environmentSku ≠ Platform (Custom Host) +│ Default- │ ▼ +│ Custom- │ ┌──────────────────────────────┐ +│ Pipelines- │ │ 4. Use the Custom Host │ +│ HostEnv- │ │ directly. Skip default- │ +│ ForTenant │ │ custom check. │ +└──────┬───────┘ └──────────────────────────────┘ + │ + ┌───┴────────────────────────┐ + │ admin set a custom default │ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────────┐ +│ default == │ │ default != │ +│ org setting? │ │ org setting │ +│ │ │ │ +│ → use default │ │ → CannotRedirect ERROR │ +│ custom │ │ (user locked to PE │ +└─────────────────┘ │ but admin overrode │ + │ at tenant scope) │ + └─────────────────────────┘ + + if no admin default → use PE +``` + +Source: `ProjectHostProvider.tsx` lines 100–213 (orgSetting fetch → defaultCustomPipelinesHost fetch → finalProjectHostEnvironmentId resolution). + +## What this skill does NOT do + +These are deliberate non-goals (each based on a hard constraint or a destructive blast-radius — see *Design Constraints* below): + +- **Does not silently provision anything.** Any action that creates an env or binds the source env to a host requires explicit user confirmation, with the tenant name + tenant ID echoed back. PE is tenant-singleton and admin-non-deletable, so the Phase 4.0 pre-call confirmation gate is the principal mitigation against wrong-tenant provisioning. The `getOrCreate` endpoint is idempotent — calling it on a tenant that already has a PE returns the existing one rather than creating a duplicate. +- **Does not call `Force Link`** to rebind an environment to a different host. Force Link is destructive (makers lose access to existing pipelines in the previous host) and is hidden behind a separate confirmation gate, only reachable when the user explicitly says "rebind". +- **Does not change the tenant-level `DefaultCustomPipelinesHostEnvForTenant` setting.** That setting is irreversible-adjacent (existing pipelines in the previous default become inaccessible — see `learn.microsoft.com/power-platform/alm/set-a-default-pipelines-host`). Out of scope. +- **Does not delete environments.** +- **Does not write `ProjectHostEnvironmentId` directly.** Binding is established through the documented Pipelines flow (creating a `deploymentenvironment` record in the host); writing the org setting directly bypasses validation. + +## Auth strategy: PAC-first with BAP fallback (`--source auto`) + +Read-side detection (Phase 2 resolution order, env list, env-by-id) defaults to `--source auto`: +1. If a BAP token is provided, **try BAP env-list / env-GET first** (richer data including `lastModifiedTime`, `permissions`, `tenantId`). +2. **On HTTP 401 or 403, fall back to `pac admin list --json`** via `pac-bap-shim.js`. PAC has its own first-party client-ID grants on BAP that Az CLI doesn't always inherit (verified 2026-04-28: `D365DemoTSCE53051106` demo tenant rejects Az tokens for BAP even with correct audience claims). +3. If no BAP token is provided at all, go straight to PAC. + +The PAC shim returns BAP-shaped data; downstream code (sku filter, ranking, classification) is unchanged. Fields not provided by PAC (`tenantId`, `lastModifiedTime`, `permissions`, `isManaged`) come back as `null` — none are critical for host detection. PAC also doesn't surface Platform Hosts (PE) since `pac admin list` doesn't include Platform-sku envs; PE detection requires `--source bap` with a working BAP token. + +**Write-side actions** (env-create POST in `provision-custom-host.js`, lifecycle op polling) still require BAP. Az CLI tokens with the right audience usually work for these even when env-list calls fail, because the BAP RP enforces different policy on actions than reads. If `provision-custom-host.js` returns 401, the user must register a service principal in the target tenant (or use the PPAC UI fallback path 4.C). + +## Design Constraints + +1. **JIT provisioning is required when a PE is selected — existing or freshly provisioned.** From `ProjectHostProvider.tsx` (line 232–240 comment): *"In the Platform Environment case, the user may not already be provisioned there, so BAP cannot discover it. So we'll use the org URL we retrieve from the getOrCreate call to make this first request so that user JIT can be triggered."* When Phase 2 detects an existing PE and the user accepts it (Phase 3.A) — or when Phase 4.0 provisions a new PE via `getOrCreate` — Phase 5's `WhoAmI` call against `instanceApiUrl` triggers JIT before any subsequent host op. (For Custom Host paths the caller has access by construction.) +2. **`CannotRedirect` is a real terminal state**, not a theoretical edge case. It happens when `ProjectHostEnvironmentId` (org setting on source env) points at PE but `DefaultCustomPipelinesHostEnvForTenant` (admin tenant setting) points elsewhere. The skill must detect this and surface it as a specific error — falling through silently would route pipeline ops at the wrong host. +3. **Admin-only Custom Host fast-path.** PPAC's `New custom host` flow is gated by `DeploymentHubCreatePipelinesHostForAdminsOnly` and shows only for Global / Power Platform / Dynamics admins (eng.ms doc). The BAP env-create API also needs the equivalent privilege. Non-admins get 403; the skill preflight-attestation-prompts and gracefully falls back to manual paths. +4. **404 from BAP env GET is ambiguous.** Returns 404 for *deleted*, *disabled*, *no-PE*, and *no-access* without distinguishing (`PowerPipelines_PE_Knowledge.md` §6.A). We never treat a single 404 as "no host exists" — we corroborate via list-environments and the org setting before acting. +5. **Each environment is bound to only one host at a time.** Rebinding requires Force Link, which is destructive in the previous host. Out of scope (see non-goals). +6. **The skill runs in user OAuth context** — same scope and audience the Power Apps UI uses. BAP calls use `https://service.powerapps.com/` audience. + +> **Idempotency of `getOrCreate`** — the BAP `getOrCreate` endpoint is idempotent (existing PE returns 200 + `provisioningState === 'Succeeded'`; new PE returns 202 + lifecycle op). Phase 4.0 leverages this — calling getOrCreate on a tenant that already has a PE is safe and just returns the existing one. The `provision-platform-host.js` helper surfaces the distinction via an `alreadyExisted: true | false` flag in its return value (recorded in the `docs/alm/last-host-check.json` telemetry block as `platformHostAlreadyExisted`). + +## Prerequisites + +- PAC CLI logged in (`pac env who` succeeds) +- Azure CLI logged in (`az account show` succeeds) +- A source Dataverse environment URL (read from `powerpages.config.json` if invoked from a Power Pages project; passed as arg otherwise) +- For Phase 4 admin-only paths: caller has Global / Power Platform / Dynamics admin (skill detects and surfaces 403 cleanly if missing) + +## Phases + +### Phase 1 — Detect prerequisites and gather tenant context + +**Create all tasks upfront at the start of this phase.** + +Tasks to create: + +1. "Check local cache and detect prerequisites" +2. "Run resolution order to find host" +3. "Confirm action with user" +4. "Execute chosen path" +5. "JIT-provision and verify host" +6. "Write host-check artifact" + +Steps: + +0. **Local cache fast-path.** If `docs/alm/last-host-check.json` exists AND `Date.now() - Date.parse(checkedAt) < cacheMaxAgeMs` (default 24h; configurable via `--cacheMaxAgeHours`): + - Acquire `HOST_TOKEN` for the cached `finalHostEnvUrl` origin. + - One cheap probe: `GET {finalHostEnvUrl}/api/data/v9.0/solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=version&$top=1` (proves Pipelines is installed AND captures version in one round-trip) + - 200 → cache is valid. Set `RESOLUTION` from the cached file. Set `ACTION_TAKEN = "none"`. Skip Phases 2–5; jump to Phase 6 with a "reused cached host" summary. + - 404 / 403 / timeout / network → cache is stale or no longer accessible. Continue to Step 1 (full resolution). Do NOT fail — stale cache is expected after env deletion or permission changes. + - If the file is missing, malformed, older than `cacheMaxAgeMs`, or contains `ready: false` → continue to Step 1. + - **Skip this step entirely** if `--no-cache` is passed (used in CI / smoke tests). + +1. Run `verify-alm-prerequisites.js`: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" + ``` + Capture `.envUrl` (`devEnvUrl`), `.token` (`DEV_TOKEN`), `.userId`, `.tenantId`, `.organizationId`. Stop on auth failure with the script's remediation message. + +2. Run `detect-project-context.js` (non-fatal — skill is also valid outside a Power Pages project): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/detect-project-context.js" + ``` + Capture `.siteName` and `.solutionManifest` for messaging. + +3. Acquire BAP token (different audience than Dataverse): + ```bash + az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv + ``` + Store as `BAP_TOKEN`. This is used by all BAP `/providers/Microsoft.BusinessAppPlatform/...` calls in Phases 2 and 4. + +3a. **Resolve tenant display name** (one-shot, best-effort). Phase 1.4 and Phase 4.0 echo a human-readable tenant name alongside the tenant GUID so the user can verify the target tenant. Acquire it from the Microsoft Graph organization endpoint: + + ```bash + az rest --method GET --url "https://graph.microsoft.com/v1.0/organization?$select=id,displayName" --resource "https://graph.microsoft.com/" --query "value[0].displayName" -o tsv + ``` + + Store as `TENANT_DISPLAY_NAME`. On any failure (no Graph permission, network error, multi-tenant ambiguity), fall back to `TENANT_DISPLAY_NAME = null` and continue — Phase 1.4 / 4.0 prompts handle a null display name by showing the tenant GUID alone. + + +> 🚦 **Gate (consent · ensure-pipelines-host:1.4.tenant-identity):** Echo tenant display name + tenant GUID + dev env URL before any host detection. First of the wrong-tenant guards. Cancel exits cleanly before any BAP/Dataverse call. + +4. **Tenant identity confirmation gate.** Echo back via `AskUserQuestion`: + + > "About to inspect Pipelines host configuration for tenant **{TENANT_DISPLAY_NAME}** (`{tenantId}`), org `{organizationId}`, dev env `{devEnvUrl}`. Continue? 1. Yes / 2. Cancel" + + (When `TENANT_DISPLAY_NAME` is null, drop the bold tenant-name segment and lead with the tenant GUID.) + + First of the consent gates that guard against wrong-tenant operations. + +### Phase 1.5 — Ground in current Pipelines host documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: `Power Platform Pipelines host environment Platform Host Custom Host`. +2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/pipelines` (and at most one sister page on host setup, default-custom-host configuration, or admin role requirements) in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what Microsoft Learn currently says about Platform vs Custom Host trade-offs, the resolution order (org-db setting → BAP env metadata → tenant default), and admin role requirements. Compare against this skill's own *Resolution order* section and `${CLAUDE_PLUGIN_ROOT}/references/cicd-pipeline-patterns.md`; flag any divergence (e.g. new Platform-Host SKU, changed default-custom-host setting name, new tenant policy controls). +4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 3 (Confirm action with user). + +### Phase 2 — Run resolution order to find host + +This phase is read-only. It produces a `RESOLUTION` object the user-confirm phase branches on. + +The phase mirrors `ProjectHostProvider.tsx` exactly. The `useState` variables in that hook map to fields in our `RESOLUTION`: + +| TS variable | Our field | +|---|---| +| `orgSetting.orgDbOrgSettingValue` | `orgSettingHostEnvId` | +| `initialProjectHostEnvironmentId` | (same) | +| `isInitialHostPlatformEnvironment` | `isPlatform` | +| `defaultCustomPipelinesHost` | `tenantDefaultCustomHostEnvId` | +| `finalProjectHostEnvironmentId` | `finalHostEnvId` | +| `projectHostStatus` | `status` | + +Steps: + +1. **Org-setting probe** (mirrors `useGetOrgDbOrgSetting('ProjectHostEnvironmentId')` line 103 in tsx). New helper `check-env-host-binding.js`: + + ``` + POST {devEnvUrl}/api/data/v9.0/GetOrgDbOrgSetting + Authorization: Bearer {DEV_TOKEN} + Body: { "SettingName": "ProjectHostEnvironmentId" } + ``` + - Empty `SettingValue` → no current binding. Skip to Step 4. + - Non-empty → store as `orgSettingHostEnvId`. Continue to Step 2. + +2. **Resolve env via BAP** (mirrors `useGetEnvironmentByName(initialProjectHostEnvironmentId)` line 483 in tsx). New helper `resolve-env-by-id.js`: + + ``` + GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/{envId}?api-version=2020-06-01&$expand=properties.linkedEnvironmentMetadata,properties.permissions + Authorization: Bearer {BAP_TOKEN} + ``` + - 200 → capture `environmentSku`, `displayName`, `linkedEnvironmentMetadata.instanceApiUrl`, `linkedEnvironmentMetadata.instanceUrl`. Set `RESOLUTION.isPlatform = (environmentSku === 'Platform')`. + - 404 → **disambiguate before acting** (Constraint 5). Run `list-tenant-envs.js` (Step 5) and check whether the env is in the list: + - If listed → user lacks access → set `RESOLUTION.status = "PermissionDenied"`, surface to user, stop. + - If not listed → env is genuinely deleted/disabled → set `RESOLUTION.status = "OrgSettingStale"`, recommend the user clear `ProjectHostEnvironmentId` and re-run, stop. + - 403 → set `RESOLUTION.status = "PermissionDenied"`, stop. + +3. **If `isPlatform === true`**, mirror the default-custom-tenant-setting check (lines 148–213 in tsx). Reuse the existing `discover-pipelines-host.js`: + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-pipelines-host.js" \ + --envUrl "{devEnvUrl}" --token "{DEV_TOKEN}" --userId "{userId}" + ``` + - `found: false` → tenant has no admin default custom host. `finalHostEnvId = orgSettingHostEnvId` (the PE). Set `RESOLUTION.status = "AvailableUsingPlatformHost"`. + - `found: true` AND `hostEnvUrl` matches `orgSettingHostEnvId` → admin-default agrees with org setting. `finalHostEnvId = orgSettingHostEnvId`. Set `RESOLUTION.status = "AvailableUsingCustomHostByAdminDefault"`. + - `found: true` AND `hostEnvUrl` does NOT match `orgSettingHostEnvId` → **`CannotRedirect`** (Constraint 3). Set `RESOLUTION.status = "CannotRedirect"`, capture both URLs. Stop with the specific error message — only an admin can resolve this. + + **If `isPlatform === false`** (Custom Host): use directly. `finalHostEnvId = orgSettingHostEnvId`. Set `RESOLUTION.status = "AvailableUsingCustomHost"`. Skip Step 4–5; jump to Step 6. + +4. **No org setting → tenant-wide search before declaring NoHost.** Source env isn't bound, but a usable Custom Host may already exist in the tenant (admin-created, or created by a prior run of this skill in another project). Always inventory before offering to create. + +5. **Tenant env inventory + Pipelines-presence probe** (decisional — feeds `RESOLUTION.status`). New helper `list-tenant-envs.js`: + + **Step 5a — list envs:** + ``` + GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2020-06-01&$expand=properties.linkedEnvironmentMetadata + Authorization: Bearer {BAP_TOKEN} + ``` + For each env capture `{ envId, displayName, environmentSku, instanceApiUrl, isManaged, hasDataverse: !!instanceApiUrl }`. + + **Step 5b — Pipelines-presence probe per env** (parallel, max 10 concurrent; bounded by sku filter + maxEnvsToProbe cap): + + **Pre-filter** (avoid probing every env in large tenants — recon found tenants with 1000+ envs): + - Skip envs without Dataverse (`linkedEnvironmentMetadata.instanceApiUrl == null`). + - Skip envs not in `--skus` (default: `Production,Sandbox` — both are valid hosts for the Pipelines app via Phase 4.B install-on-existing). PE always reports `environmentSku === 'Platform'` and is included regardless. Pass `--skus Production,Sandbox,Trial` to include Trial envs (eligible for app-install via 4.B but **not** for env-create via 4.A — Trial-license tenants get `NotEnoughCapacity_HasTrialLicense` from env-create). + - Sort remaining by `lastModifiedTime` desc. + - Cap at `--maxEnvsToProbe` (default 50; covers the typical-tenant 80% case in <5s with 10-concurrent). + - If cap is reached and no host found, surface a warning: `"Scanned N of M envs (filter: Production+Sandbox, sorted by lastModifiedTime). Pass --maxEnvsToProbe N+ or --skus Production,Sandbox,Trial to widen."` + + **Probe query** (single query covers presence-check AND version-capture): + ``` + GET {instanceApiUrl}/api/data/v9.0/solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=uniquename,version&$top=1 + Authorization: Bearer {HOST_TOKEN-per-env} + OData-Version: 4.0 + OData-MaxVersion: 4.0 + ``` + - 200 with `value.length === 1` → Pipelines installed. Capture `value[0].version` as `pipelinesSolutionVersion`. **Mark as Custom Host candidate** (or PE if `environmentSku === 'Platform'`). + - 200 with `value.length === 0` → no Pipelines. If Dataverse + caller has access, mark `eligible-for-app-install`. + - 404 → entity exists but Dataverse unreachable / wrong URL; treat as not-a-candidate. + - 403 → caller cannot access; do NOT count as a host candidate. Add to `inaccessibleEnvs[]` for warnings only. + - timeout / 5xx → log to warnings, treat as not-a-candidate; do not retry. + + > **Why not `deploymentpipelines?$top=0`?** Dataverse rejects `$top=0` on `deploymentpipelines` with HTTP 400 "Invalid value for $top query option" even on a properly installed host (verified against `pascalepipelineshost.crm.dynamics.com` 2026-04-28). The `solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'` query is the correct cheap probe — single round-trip, no rate-limit concerns at $top=1, and it returns the version we need anyway. + + **Token strategy for 5b**: acquire one HOST_TOKEN per distinct env origin via `az account get-access-token --resource "{origin}"`, cached in-memory for the run. Token acquisition itself shouldn't fail unless the resource doesn't exist (deleted env), in which case skip. + + **Output of step 5** (`RESOLUTION.candidates`): + ```js + { + existingCustomHosts: [{ envId, instanceApiUrl, displayName, environmentSku, pipelinesSolutionVersion }, ...], + existingPlatformHost: { envId, instanceApiUrl, displayName, ... } | null, // at most one + eligibleForAppInstall: [{ envId, instanceApiUrl, displayName }, ...], + inaccessibleEnvs: [{ envId, displayName, reason: "403" | "timeout" }, ...] + } + ``` + + **Decision logic** (sets `RESOLUTION.status`): + - `existingCustomHosts.length === 1` → `RESOLUTION.status = "AvailableUnboundCustomHost"`. Set `finalHostEnvId / finalHostEnvUrl` provisionally to that host (Phase 3.C-pre will confirm). + - `existingCustomHosts.length > 1` → `RESOLUTION.status = "MultipleUnboundCustomHosts"`. Phase 3.C-pre' will ask user to pick. + - `existingCustomHosts.length === 0` AND `existingPlatformHost !== null` → `RESOLUTION.status = "PlatformHostExistsUnbound"`. Phase 3.C-pre'' offers PE-use (no creation needed; a PE already lives in the tenant). Note: actual binding of source env to PE happens through the documented Pipelines flow when `setup-pipeline` registers source env in `deploymentenvironments`, same as Custom Host. + - All zero → `RESOLUTION.status = "NoHost"`. Phase 3.C decision tree (create-new). + + > **Self-detection note:** Custom Hosts created by previous runs of this skill (Phase 4.A `D365_ProjectHost` template) install the Pipelines solution as part of the template. They surface in `existingCustomHosts` on the *exact same signal* as admin-created hosts. We do not need a marker on hosts we created ourselves — the Pipelines-solution-installed signal is sufficient. + +6. **Pipelines solution version probe** (only when `finalHostEnvId` is known and not already populated by step 5b). On the resolved host instanceUrl: + + ``` + GET {hostEnvUrl}/api/data/v9.0/solutions?$filter=uniquename eq '{PIPELINES_SOLUTION_UNIQUE_NAME}'&$select=version,installedon + Authorization: Bearer {HOST_TOKEN} + ``` + + Where `HOST_TOKEN` is acquired against `{hostEnvUrl origin}` via `az account get-access-token --resource`. + + The solution's exact unique name is an open item — see *Open Items*. Working hypothesis: `msdyn_AppDeploymentAnchor`. Capture `PIPELINES_SOLUTION_VERSION`. If query returns empty (solution missing on a non-PE host) → `RESOLUTION.status = "HostWithoutPipelines"` — Phase 3.D path. + +Report findings to user: + +> "Tenant `{tenantId}` host status: **{RESOLUTION.status}**. {Status-specific summary line.}" + +### Phase 3 — Confirm action with user + +Branches by `RESOLUTION.status`. Each branch ends with either *"proceed to Phase 5"* (host already usable) or *"Phase 4 with chosen path"*. + +#### 3.A — Status `AvailableUsingCustomHost` / `AvailableUsingCustomHostByAdminDefault` / `AvailableUsingPlatformHost` + +Host is established. Confirm and skip ahead. + +> "Found existing host: `{finalHostEnvUrl}` (`{RESOLUTION.status}`, Pipelines solution v`{PIPELINES_SOLUTION_VERSION}`). Use this host? +> 1. Yes — proceed to verification +> 2. Cancel" + +- Yes → set `ACTION_TAKEN = "none"`, jump to Phase 5. +- Cancel → exit. + +#### 3.B — Status `CannotRedirect` + +Locked state. Cannot proceed. + +> "Cannot proceed: `ProjectHostEnvironmentId` on `{devEnvUrl}` points at the Platform Host (`{orgSettingHostEnvId}`), but the tenant admin set `DefaultCustomPipelinesHostEnvForTenant` to a different env (`{tenantDefaultCustomHostEnvId}`). The Pipelines UI cannot redirect this env to the admin's choice. Resolution requires a Power Platform admin to either (a) clear the tenant default, or (b) update the org setting on this env. Exiting." + +Stop. + +#### 3.C-pre — Status `AvailableUnboundCustomHost` (single existing Custom Host found) + +Tenant already has exactly one Custom Host with Pipelines installed. Source env isn't bound to it yet, but binding happens automatically when `setup-pipeline` registers the source env in the host's `deploymentenvironments` table. **Reusing avoids creating duplicate hosts.** + +> "Found an existing Custom Host in tenant `{tenantId}`: +> - **Display name:** `{displayName}` +> - **URL:** `{instanceApiUrl}` +> - **Pipelines solution:** v`{pipelinesSolutionVersion}` +> +> Source env `{devEnvUrl}` is not yet bound to it — that will happen automatically the first time `setup-pipeline` runs against this host. Use this host? +> 1. Yes — use existing host (recommended; avoids duplicates) +> 2. No — show me the create-new decision tree (Phase 3.C) +> 3. Cancel" + +- Yes → set `finalHostEnvUrl/Id`, `ACTION_TAKEN = "reuse-existing-custom"`, jump to Phase 5. +- No → fall through to Phase 3.C `NoHost` decision tree (still allows creating another). +- Cancel → exit. + +#### 3.C-pre' — Status `MultipleUnboundCustomHosts` (multiple existing Custom Hosts found) + +> "Found {N} existing Custom Hosts in tenant `{tenantId}` with Pipelines installed. Pick one to use, or create new: +> +> 1. `{host[0].displayName}` (`{host[0].instanceApiUrl}`, Pipelines v`{host[0].pipelinesSolutionVersion}`) +> 2. `{host[1].displayName}` (...) +> ... +> N. ... +> N+1. **Create new Custom Host instead** — go to Phase 3.C decision tree +> N+2. Cancel" + +- Selection 1..N → set `finalHostEnvUrl/Id` from picked host, `ACTION_TAKEN = "reuse-existing-custom"`, jump to Phase 5. +- N+1 → fall through to Phase 3.C `NoHost` decision tree. +- N+2 → exit. + +#### 3.C-pre'' — Status `PlatformHostExistsUnbound` (PE already exists, no Custom Host) + +A PE already exists in the tenant (one is provisioned automatically the first time anyone navigated to the Pipelines page). Per scope decision, this iteration does NOT auto-provision a PE, but if one already exists, we offer to use it. + +> "Tenant `{tenantId}` already has a Platform Host (`{instanceApiUrl}`, Pipelines v`{pipelinesSolutionVersion}`). Source env is not yet bound to it. Use this host? +> 1. Yes — use existing Platform Host (idempotent — already provisioned in this tenant) +> 2. No — create a Custom Host instead (Phase 3.C decision tree) +> 3. Cancel" + +- Yes → set `finalHostEnvUrl/Id` from PE, `ACTION_TAKEN = "reuse-existing-pe"`, jump to Phase 5. (Phase 5's WhoAmI call triggers JIT — Constraint 1.) +- No → fall through to Phase 3.C `NoHost` decision tree (admin-created Custom Host preferred for governance). +- Cancel → exit. + +#### 3.C — Status `NoHost` (host-type decision tree) + +The prompt asks the user to pick the **host type** first (Platform Host, Custom Host, PPAC manual, or cancel). Picking Custom Host opens a sub-prompt for the install method (existing env vs. create-new). The Platform-Host path is the lowest-friction default and is presented first. + +**Skip rule — caller already collected the answer.** When this skill is invoked from `setup-pipeline` and `docs/alm/last-pipeline.json` carries a `hostResolution` block populated by plan-alm Phase 2 Q4, inspect those flags before showing the prompt: + +| Upstream signal | Action | +|---|---| +| `hostResolution.willProvisionPlatform === true` | Skip Phase 3.C entirely. Route directly to **Phase 4.0** (provision new Platform Host). The pre-call confirmation gate in 4.0 still runs — see 4.0 "Pre-call confirmation (NON-SKIPPABLE)" below. | +| `hostResolution.chosenEnvUrl` is a non-empty URL | Skip Phase 3.C entirely. Set `CHOSEN_ENV_URL = hostResolution.chosenEnvUrl`, route directly to **Phase 4.B** with that env (4.B step 1's "already chosen" path applies). Set `ACTION_TAKEN` per the existing routing table (`"user-installed-app-on-dev"` when origin matches `devEnvUrl`, else `"user-installed-app"`). | +| `hostResolution.willProvisionCustom === true` AND `chosenEnvUrl` empty | Skip Phase 3.C. Route directly to **Phase 4.A** (provision new Custom Host). The pre-call confirmation gate in 4.A still runs. | +| `hostResolution.willUsePpac === true` AND `chosenEnvUrl` empty | Skip Phase 3.C. Route directly to **Phase 4.C** (PPAC manual). | +| None of the above | Run Phase 3.C as written below. | + +**Why this skip rule exists.** plan-alm Phase 2 Q4 NoHost branch presents the same host-type menu so the user makes the choice once, at planning time, with the rendered ALM plan in front of them. Re-prompting in 3.C at execution time would force a second answer to the same question and risks the agent treating one of the answers as authoritative and ignoring the other (the bug behind the trial-license-409 → wrong-env-fallback chain that surfaced on 2026-05-05). Whenever the upstream signal is present, trust it. + +**Step 1: present the top-level host-type prompt.** + +> "No Pipelines host bound to `{devEnvUrl}`. Which environment should host Pipelines? +> +> Pipelines lives in one env per tenant; pipelines, stages, and run history are stored there. Source envs deploy through it. +> +> 1. **Provision a Platform Host (recommended)** — Microsoft-managed Dataverse env auto-provisioned in your tenant home geo. Pipelines app pre-installed. Idempotent (safe to re-run). ~3–5 min. +> +> 2. **Set up a Custom Host** — Pipelines lives in a Dataverse env you control. We'll ask whether to use an existing env or create a brand-new dedicated one. +> +> 3. **Open PPAC and create one manually** — fallback if option 2 doesn't work for you. +> +> 4. **Cancel** — exit." + +Top-level routing: + +| Selection | Action | +|---|---| +| Option 1 | Phase 4.0 (with pre-call confirmation gate). `ACTION_TAKEN = "fast-path-platform-getorcreate"`. | +| Option 2 | Show the Custom-Host sub-prompt at Step 2 below. | +| Option 3 | Phase 4.C. `ACTION_TAKEN = "user-created-custom-ppac"`. | +| Option 4 | Exit. | + +**Step 2: build the eligible-env list and apply role labels** (only needed when the user picks Option 2 → sub-option `a`; do this lazily after Option 2 is selected). + +Take `RESOLUTION.candidates.eligibleForAppInstall[]` from Phase 2 (envs with Dataverse, caller has access, Pipelines NOT yet installed, sku ∈ default filter — `{Production, Sandbox}`; widen to `Production,Sandbox,Trial` via `--skus` for trial-license tenants). + +For each entry, decorate with project-context labels at presentation time. Match by **URL origin** (lowercase, trailing slash stripped, path/query ignored). Multiple labels join with ` · `. + +| Label | Source signal | +|---|---| +| `dev env` | The env URL the skill is running against (`devEnvUrl` from caller / `pac env who`) | +| `source env` | `sourceEnvironmentUrl` from `docs/alm/last-pipeline.json` (typically same env as dev) | +| `staging env` | `targetEnvironmentUrl` of any stage in `docs/alm/last-pipeline.json` whose stage name matches `/stag\|test\|uat/i` | +| `production env` | `targetEnvironmentUrl` of any stage whose name matches `/prod/i` | + +Envs with no role match show without a label. + + + +**Step 2a: rank and cap the eligible-env list.** `AskUserQuestion` becomes unusable past about 7–8 options. Apply a **5-env presentation cap** with role-aware ranking: + +1. **Always-visible role-labeled envs** (highest priority): include any eligible env that carries a `dev env`, `source env`, `staging env`, or `production env` label from Step 2's role decoration. Dedupe by URL origin. +2. **Fill remaining slots up to 5** from the rest of the eligible list, in `list-tenant-envs.js`'s native order (name-hint pattern → admin-perms → lastModifiedTime). +3. **Always append** an "Other (paste URL)" entry as the last item. + +Track the total eligible count separately — when `eligible.length > 5`, surface the gap inline. + +**Step 2b: empty-list collapse.** If the eligible list has zero entries after the filters, first try widening the SKU filter to include Trial (re-invoke `ensure-pipelines-host-detect.js` with `--skus Production,Sandbox,Trial`). If still zero, **drop sub-option `a`** from the Custom-Host sub-prompt — present only `b` (create new) and `c` (Back). Print the SKU-filter detail so the user can override: + +> *"No existing environments matched the SKU filter (Production, Sandbox, Trial). Run `--skus ` to widen further, or pick `b` to create a brand-new env, or `c` to go back."* + +**Step 2c: present the Custom-Host sub-prompt** (when user picks Option 2): + +> "How would you like to set up the Custom Host? +> +> a. **Use an existing environment** — install the Pipelines app on it.{eligibleCountSuffix} Pick from your eligible envs: +> - `{env[0].displayName}` (`{env[0].instanceApiUrl}`) — `{environmentSku}` — *{labels if any}* +> - `{env[1].displayName}` (...) +> - … (up to 5 entries) +> - *Other (paste URL) — for any eligible env not on this short list* +> +> *⚠ Sandbox-sku envs trigger a confirmation prompt before install.* +> +> b. **Create a brand-new dedicated env** — automated env-create with template `D365_ProjectHost`. Pipelines app pre-installed. Requires Global / Power Platform / Dynamics admin. ~5–10 min. +> +> c. **Back** — return to the top-level host-type menu." + +`{eligibleCountSuffix}` substitution rules: +- `eligible.length <= 5` → empty string (no suffix; all envs visible). +- `eligible.length > 5` → ` Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.` (leading space). + +When the user picks "Other (paste URL)", **pre-fill** the URL input with `pac env list --output json` results so they can paste-or-pick from the full tenant inventory rather than typing a URL by hand. + +**Test scenarios to verify when changing this prompt:** +- 0 eligible → sub-option `a` dropped (sub-prompt shows only `b` / `c`). +- 1–5 eligible → list all inline, no suffix. +- 6+ eligible with role-labeled envs (dev/staging/prod) present → all role-labeled envs surface first; remaining slots filled by ranking; suffix shows count gap. +- 6+ eligible with NO role-labeled envs → top 5 by ranking; suffix shows count gap. + +**Step 3: route the sub-prompt answer.** + +| Selection | Phase 4 path | `ACTION_TAKEN` | +|---|---|---| +| Option 1 (top-level Platform Host) | **4.0** (pre-call confirmation, then `provision-platform-host.js`) | `"fast-path-platform-getorcreate"` | +| Option 2 → sub-option `a` — picked env from list, URL matches `devEnvUrl` (origin-equal) | **4.B** (skip the "which env" sub-prompt; pass the picked env URL through as `CHOSEN_ENV_URL`) | `"user-installed-app-on-dev"` | +| Option 2 → sub-option `a` — picked any other listed env | **4.B** (skip sub-prompt; pass `CHOSEN_ENV_URL`) | `"user-installed-app"` | +| Option 2 → sub-option `a` — "Other (paste URL)" | **4.B** with user-supplied URL as `CHOSEN_ENV_URL` | `"user-installed-app"` | +| Option 2 → sub-option `a` — picked env where `environmentSku === "Sandbox"` | **Step 4 Sandbox confirmation gate**, then 4.B if confirmed | (deferred until confirmation) | +| Option 2 → sub-option `b` | **4.A** (sub-prompts: name, region, admin confirmation) | `"fast-path-custom-d365projecthost"` | +| Option 2 → sub-option `c` (Back) | re-show top-level menu | n/a | +| Option 3 | **4.C** | `"user-created-custom-ppac"` | +| Option 4 | exit | n/a | + +For Option 1 and sub-option `b`, a **non-skippable pre-call confirmation gate** echoes the tenant identity before firing. Option 1 → see Phase 4.0 "Pre-call confirmation". Sub-option `b` → see Phase 4.A "Pre-call confirmation (NON-SKIPPABLE)". + +**Step 4 (conditional): Sandbox confirmation gate.** + +If the env picked in sub-option `a` has `environmentSku === "Sandbox"`, present: + +> "⚠ `{displayName}` is a **Sandbox** env (`environmentSku: Sandbox`). +> +> Power Platform Pipelines is documented to run on Production envs. Sandbox should work but isn't on the supported matrix. +> +> Proceed? +> 1. Yes, proceed at my own risk +> 2. Pick a different env +> 3. Cancel" + +- Yes → continue to 4.B with the Sandbox env. Set `ACTION_TAKEN` per the dev-env-match rule above. +- Pick a different env → re-show sub-option `a`'s env list (keep the rest of the picker state). +- Cancel → back to the top-level Phase 3.C prompt. + +**Telemetry note.** Splitting `"user-installed-app"` and `"user-installed-app-on-dev"` lets us see, after rollout, how often users co-locate Pipelines with their dev env vs. dedicating a separate env. The new `"fast-path-platform-getorcreate"` value lets us measure adoption of the new lowest-friction path; the `telemetry.platformHostAlreadyExisted` flag in the artifact distinguishes idempotent-existing (200) from newly-provisioned (202) outcomes — useful for tuning Phase 2.5's `--maxEnvsToProbe` defaults if we see Phase 4.0 hitting 200 frequently. + +#### 3.D — Status `HostWithoutPipelines` (rare) + +Host env exists but Pipelines solution is missing. + +> "Found host environment `{finalHostEnvUrl}` but the Pipelines solution is not installed. Install it now via PPAC? +> 1. Yes — open PPAC and install (guided manual) +> 2. No — exit" + +- Yes → Phase 4.C with pre-selected env. +- No → exit. + +#### 3.E — Status `OrgSettingStale` / `PermissionDenied` + +Surface the specific failure to the user. Out of automated remediation scope. Recommend manual cleanup. + +### Phase 4 — Execute chosen path + +#### 4.0 — Fast-path: Platform Host via `getOrCreate` + +The lowest-friction host-provisioning path. Calls the idempotent BAP `getOrCreate` endpoint with a `D365_1stPartyAdminApps` + `Platform` body. Same call `make.powerapps.com → Pipelines` page makes when a user clicks "Get started" — we just invoke it directly. Spec from `useGetOrCreatePlatformEnvironment.v4.ts`. New helper `provision-platform-host.js`. + +**No sub-prompts.** BAP picks tenant home geo + default display name; no admin role required. + +**Pre-call confirmation (NON-SKIPPABLE single consent gate):** + +> "About to provision a Platform Host for tenant **{TENANT_DISPLAY_NAME}** (`{tenantId}`). +> +> A Platform Host is a Microsoft-managed Dataverse environment in your tenant's home region. One per tenant, idempotent — if you already have one, it'll be returned. New provisioning takes about 3–5 minutes. +> +> Proceed? 1. Yes / 2. Cancel" + +(When `TENANT_DISPLAY_NAME` is null, drop the bold tenant-name segment and lead with the tenant GUID.) + +**Why this gate runs even when upstream `hostResolution.willProvisionPlatform === true`** — same rationale as 4.A's pre-call gate: this echoes the *exact tenant identity*, the user's last chance to catch a wrong-tenant operation. PE is tenant-singleton and admin-non-deletable; the gate is the principal mitigation. Implementation details (BAP endpoint, body shape) are intentionally kept out of the user-facing prompt and live in this SKILL.md / `provision-platform-host.js` source. + +**Call:** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/provision-platform-host.js" \ + --bapToken "{BAP_TOKEN}" \ + --correlationId "{uuid v4}" +``` + +**Response handling** (delegated to the helper, but the routing decision lives here): + +- `200` + `properties.provisioningState === 'Succeeded'` → tenant already had a PE (idempotent path). Helper returns `{ status: 'Succeeded', alreadyExisted: true, instanceApiUrl, ... }`. Set `RESOLUTION.finalHostEnvUrl`, `finalHostEnvId`, `RESOLUTION.isPlatform = true`, `actionTaken = "fast-path-platform-getorcreate"`, `telemetry.platformHostAlreadyExisted = true`. Continue to Phase 5. *Defensive note:* this means Phase 2.5 enumeration missed the PE (probably because `--maxEnvsToProbe` capped before the PE was reached, or a race between Phase 2 and 4.0). Surface a debug-level note; don't fail. +- `202` + Location → helper polls until `provisioningState === 'Succeeded'` and returns `{ status: 'Succeeded', alreadyExisted: false, instanceApiUrl, ... }`. Set the same fields with `telemetry.platformHostAlreadyExisted = false`. +- `403` → helper throws with the BAP body verbatim; recommend `az logout && az login`. Offer fallback to Options 2/3/4. (Do NOT reuse the 4.A admin-required copy — getOrCreate does not require admin.) +- `4xx` / `5xx` other → helper throws; surface, ask retry or switch path. + +**On success:** `RESOLUTION.isPlatform = true` (so Phase 5 takes the JIT branch — Constraint 1). Proceed to Phase 5. + +#### 4.A — Fast-path: Custom Host via `D365_ProjectHost` template + +Standard env-create API with the `D365_ProjectHost` template (eng.ms-documented; same template PPAC `New custom host` uses internally). New helper `provision-custom-host.js`. + +**Sub-prompts (collected before the API call):** + +1. Display name (default suggestion: `"{tenant displayName} Pipelines Host"`) +2. Region (default: tenant home geo from BAP `tenant` endpoint; offer override) +3. Confirm caller is admin — single AskUserQuestion *"Are you a Global / Power Platform / Dynamics admin in this tenant? Yes / No / Not sure"*. If No or Not sure, recommend Path 4.B/4.C and fall back. + +**Pre-call confirmation (NON-SKIPPABLE second consent gate):** + +> "About to call `POST https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2021-04-01` for tenant `{tenantId}` with body: +> ```json +> { +> "location": "{region}", +> "properties": { +> "displayName": "{display name}", +> "environmentSku": "Production", +> "databaseType": "CommonDataService", +> "linkedEnvironmentMetadata": { "templates": ["D365_ProjectHost"] } +> } +> } +> ``` +> Provisions a Custom Host with the Pipelines app pre-installed (~5–10 min). Proceed? 1. Yes / 2. Cancel" + +> **This gate must always run.** Do NOT skip it because plan-alm Q4 already received a "continue with PP Pipelines" confirmation, or because `hostResolution.willProvisionCustom === true` upstream, or because the user said yes to the admin attestation moments earlier. plan-alm Q4 and the admin attestation are about *strategy*; this gate echoes the *exact API call body* (URL, region, tenant, template) and is the user's last chance to catch a wrong-tenant or wrong-region provisioning. The bug fixed on 2026-05-05 surfaced precisely because the agent treated the plan-alm pre-confirmation as covering this gate. + +**Call:** + +``` +POST https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2021-04-01 +Authorization: Bearer {BAP_TOKEN} +Content-Type: application/json +x-ms-correlation-id: {uuid v4} + +{ + "location": "{region}", + "properties": { + "displayName": "{display name}", + "environmentSku": "Production", + "databaseType": "CommonDataService", + "linkedEnvironmentMetadata": { "templates": ["D365_ProjectHost"] } + } +} +``` + +**Response handling:** + +- `202` + `Location` header + `Retry-After` header → poll the Location URL until lifecycle op completes. +- `200` + immediate body (rare for env-create) → capture URLs. +- `403` from initial POST → stop with *"Custom Host fast-path requires Global / Power Platform / Dynamics admin. Suggest Path 2 (Pipelines app on existing env) if you have system-admin on a Dataverse env, or Path 3 (PPAC UI) if you can request admin assistance."* Offer seamless fallback to 4.B / 4.C. +- `409` with a capacity-related code (e.g. `NotEnoughCapacity_HasTrialLicense_ProvisionEnvironment`, `NotEnoughCapacity`, `NotEnoughCapacity_OrganizationDisabled`, `EnvironmentCapacityExceeded`) → **offer a SKU-fallback prompt before falling back to Path 4.B**. The license / capacity constraint usually applies only to the requested SKU; smaller SKUs (Sandbox, Developer, Trial) often succeed on the same tenant and produce a Pipelines host that works identically (the Pipelines app installs on any SKU — only license allocation differs). See sub-step "4.A — SKU fallback prompt" below. Only after the user declines a SKU fallback OR the fallback also fails should we re-enter Phase 3.C / Path 4.B. +- 4xx / 5xx other → surface error, ask user to retry or switch path. On switch to 4.B, follow the same "use original eligible-env list" rule above. + +**Polling:** + +``` +GET {Location} +Authorization: Bearer {BAP_TOKEN} +``` + +Interval = `Retry-After` seconds (default 10s). Timeout = 15min (configurable). On each response, read `provisioningState` (and/or operation `state` field — confirm during execution): +- `Creating` / `InProgress` → continue polling +- `Succeeded` → done; capture `instanceApiUrl`, `name` (env GUID), `displayName` +- `Failed` / `Canceled` → surface error, stop + +**On success:** set `RESOLUTION.finalHostEnvUrl`, `finalHostEnvId`, `instanceApiUrl`, `actionTaken = "fast-path-custom-d365projecthost"`. Proceed to Phase 5. + +##### 4.A — SKU fallback prompt (capacity-error remediation) + +When env-create returns a 409 capacity-related error, the user's tenant doesn't have spare license/capacity for the requested SKU but may have it for a smaller SKU. Surface the constraint clearly and offer fallback SKUs **before** suggesting Path 4.B: + +1. Read the error body. Extract: + - `error.code` (e.g. `NotEnoughCapacity_HasTrialLicense_ProvisionEnvironment`) + - `error.message` (the human-readable explanation from BAP, e.g. *"Trial licenses are limited to creating Trial environments only."*) + +2. Build the fallback SKU list. The default order is **Production (recommended) → Sandbox → Developer → Trial**, dropping the SKU that just failed. Each SKU carries a different caveat: + + | SKU | When to suggest | Caveat to surface | + |---|---|---| + | `Production` | Default first choice | None — the documented Pipelines host SKU | + | `Sandbox` | Tenants with subscription but no spare Production capacity | *"Sandbox SKU works for Pipelines but is documented as non-production. Microsoft may apply different SLAs to Sandbox-hosted apps."* | + | `Developer` | Individual-developer tenants | *"Developer SKU is single-user. Other team members will not be able to deploy through this host. Use only if this is a personal/dev-only ALM setup."* | + | `Trial` | Trial-license tenants (no other option works) | *"Trial environments expire after 30 days unless converted. The Pipelines host will need to be re-provisioned at expiry."* | + +3. Tell the user the constraint and present the prompt: + + > "Custom Host provisioning failed with `{error.code}`: {error.message} + > + > The Pipelines app installs identically on any SKU — only the license allocation differs. You can retry env-create with a smaller SKU, or fall back to installing the Pipelines app on an existing env (Path 4.B)." + + + > 🚦 **Gate (plan · ensure-pipelines-host:4.A.sku-fallback):** Capacity error on env-create — retry with different SKU, fall back to Path 4.B (install on existing env), or cancel. + + `AskUserQuestion` (build the option list dynamically — drop the SKU that just failed, append the caveats inline): + + | Question | Header | Options | + |---|---|---| + | Retry env-create with a different SKU? | SKU fallback | (one option per remaining SKU, with caveat in the description), Fall back to Path 4.B (install Pipelines app on existing env), Cancel | + +4. Branch on the answer: + - **Picked SKU**: re-issue the 4.A pre-call confirmation gate (NON-SKIPPABLE — see "Pre-call confirmation" above) with the new SKU substituted into the body, then re-call `provision-custom-host.js` with `--environmentSku `. If that also fails with a capacity error, present the prompt again with the next SKU dropped. After two consecutive capacity failures, stop offering SKU fallbacks and route to Path 4.B. + - **Path 4.B**: route to 4.B per the "original eligible-env list" rule below. + - **Cancel**: exit cleanly. + + **Always** discard any env GUID returned in the 409 response body — provisioning failed, so the GUID either doesn't represent a usable env or is an artifact. Path 4.B must use the Phase 2 eligible-env inventory exclusively. + +#### 4.B — Install Pipelines app on an existing env (automated) + +This path was previously a manual click-through to PPAC. As of 2026-05-08 it's fully automated: `install-pipelines-app.js` calls the BAP `applicationPackages/install` endpoint (the same API that backs PPAC's *"Install app"* button) and falls back to `pac application install` when BAP returns 401/403/5xx. + +**Sub-prompt: pick the target env (skipped when env is already chosen):** + +1. **If the env was already chosen** (`CHOSEN_ENV_URL` is set, either from Phase 3.C Option 2 → sub-option `a` OR from the upstream skip rule on `hostResolution.chosenEnvUrl`): skip the sub-prompt — proceed directly to the install step with the chosen env. Set `ACTION_TAKEN` per the Phase 3.C Step 3 routing table (`"user-installed-app-on-dev"` when `CHOSEN_ENV_URL` origin matches `devEnvUrl`, else `"user-installed-app"`). + + **Otherwise** (legacy entry path — caller invoked 4.B directly without going through 3.C, or arrived here via the 4.A failure fallback): present the sub-prompt *"Which env will host Pipelines? (Auto-detected envs from Phase 2 inventory):"* with the eligible-for-app-install list **from `RESOLUTION.candidates.eligibleForAppInstall[]`** as choices, plus "Other (paste URL)". When arriving from 4.A's 409 trial-license fallback, the eligible list still applies — discard any env GUID surfaced in the 409 response body. + +> **Env GUID sanity check.** The GUID passed to the install helper must come from the chosen env's `name` field as enumerated in Phase 2 (or from `pac env list` for a user-pasted URL). Never substitute a GUID from a 4.A failure response, from `docs/alm/last-host-check.json` of a different run, or from any other state path. If you cannot determine the GUID with confidence, ask the user to confirm via `AskUserQuestion` showing the env display name + URL + GUID before invoking the helper. + +**Pre-call confirmation gate** (NON-SKIPPABLE — same rationale as Phase 4.0 / 4.A): + +> "About to install the **Power Platform Pipelines** application on `{displayName}` (`{instanceApiUrl}`) in tenant **{TENANT_DISPLAY_NAME}** (`{tenantId}`). This is the same install PPAC's *Install app* button performs — the agent calls the BAP API directly so no manual click-through is needed. Takes ~2–5 minutes. Proceed? 1. Yes / 2. Cancel" + +**Call the helper:** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/install-pipelines-app.js" \ + --bapToken "{BAP_TOKEN}" \ + --envId "{envId}" \ + --instanceApiUrl "{instanceApiUrl}" \ + --hostToken "{HOST_TOKEN}" \ + --correlationId "{uuid}" +``` + +`HOST_TOKEN` is acquired against the chosen env's origin (`az account get-access-token --resource "{instanceApiUrl origin}"`) and passed through so the helper's post-install verification probe (`solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'`) can run end-to-end without the skill having to chain a separate verification step. + +**Response handling** (delegated to the helper, but routing decisions live here): + +- `{ status: 'Succeeded', alreadyInstalled: true, installPath: 'cached' }` — the package was already installed on this env (idempotent path; rare in 4.B since Phase 2.5 should have classified the env as a host already, but defensive). Set `ACTION_TAKEN` per the dev-env-match rule above and proceed to Phase 5. +- `{ status: 'Succeeded', alreadyInstalled: false, installPath: 'bap' }` — the BAP `applicationPackages/install` POST succeeded (200 sync or 202 + Location poll). Set `ACTION_TAKEN` per the dev-env-match rule above and proceed to Phase 5. +- `{ status: 'Succeeded', alreadyInstalled: false, installPath: 'pac', pacFallbackReason: '...' }` — BAP returned 401/403/5xx (typically token-audience mismatch in tenants where Az → BAP is rejected, same scenario `pac-bap-shim.js` covers for env enumeration); the helper fell through to `pac application install`. Same outcome, log the fallback reason in `docs/alm/last-host-check.json` telemetry. Proceed to Phase 5. +- Helper throws — both BAP and PAC failed. Surface the combined error message to the user. **Last-resort fallback** (manual): print the PPAC URL `https://admin.powerplatform.microsoft.com/manage/environments/{envId}/dynamics365apps` and the four manual steps (*Install app → Power Platform Pipelines → Next → accept terms → Install*) with a follow-up *"Done — proceed"* AskUserQuestion. After confirmation, run `verify-host-readiness.js` against the env URL and proceed to Phase 5 only when the Pipelines solution is detected. This branch should be rare; if it fires often, file an issue with the helper's combined error so we can tune the BAP/PAC paths. + +**On success:** capture the helper's `pipelinesSolutionVersion` (populated when `instanceApiUrl` + `hostToken` were passed). Set `RESOLUTION.finalHostEnvUrl/Id`. Proceed to Phase 5. + +#### 4.C — Guided manual: PPAC `New custom host` + +1. Print: `https://admin.powerplatform.microsoft.com/deployments` and instructions: *"Click 'New custom host' → fill name (suggested: '{tenant} Pipelines Host') → choose Production environment in tenant home region → Create. Provisioning takes 5–10 min."* + + > Per eng.ms doc: *"the panel will default to the Production environment type. Adding Dataverse is also required... template and sample apps options are hidden here, as we use a specific organization template for this scenario."* (The template is `D365_ProjectHost` — same one Path 4.A automates.) + +2. Two-option AskUserQuestion: *"Done — provisioning kicked off"* / *"Cancel"*. +3. After confirmation, poll BAP `list-tenant-envs.js` every 15s looking for a new env with the Pipelines marker. On detection, capture URLs, `actionTaken = "user-created-custom-ppac"`. Proceed to Phase 5. + +#### Common: Timeout handling + +15-min default per path (configurable). On timeout: ask user to extend (another 15min), switch path, or exit. + +### Phase 5 — JIT-provision (PE-detected only) and verify host + +Always runs, regardless of how `finalHostEnvUrl` was obtained. + +**JIT step (only when an existing PE was detected and selected — `RESOLUTION.isPlatform === true`):** Per Constraint 2, the calling user may have been JIT-provisioned in the PE long ago, or never. To ensure auth works on the host before we hand off, we issue one `WhoAmI` against `instanceApiUrl`. (The same step is required for Custom Host paths but is naturally satisfied by `verify-host-readiness.js` step 1 below — admin who created the env has access by construction; for `user-installed-app` the user already had access to the env.) + +``` +GET {instanceApiUrl}/api/data/v9.0/WhoAmI +Authorization: Bearer {HOST_TOKEN} +``` + +Where `HOST_TOKEN = az account get-access-token --resource "{instanceApiUrl origin}"`. + +Expected: 200 with `UserId`. If 404 / 403 on first call: retry every 5s up to 60s — JIT propagation is sometimes async. + +**Verification (`verify-host-readiness.js`)** — checks in order: + +1. `WhoAmI` returns `UserId` (proves auth — and triggers JIT for PE detection case). +2. `GET {hostEnvUrl}/api/data/v9.0/solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=version&$top=1` returns one row → capture `PIPELINES_SOLUTION_VERSION`. (One query covers both Pipelines-installed check AND version capture; `deploymentpipelines?$top=0` rejected by Dataverse with 400.) + +Compare against `MIN_PIPELINES_VERSION` (constant in `scripts/lib/alm-thresholds.js`). + +- All checks pass → `READY = true`. +- Solution version below minimum → emit a warning (non-fatal). +- Any check fails → stop with check-specific remediation. + +### Phase 6 — Write host-check artifact + +Write `docs/alm/last-host-check.json` (create the `docs/alm/` directory first if missing — `node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"`; or use `--outputPath` when invoked outside a project): + +```json +{ + "schemaVersion": 2, + "checkedAt": "2026-04-28T...", + "tenantId": "...", + "sourceEnvUrl": "{devEnvUrl}", + "sourceEnvId": "...", + "resolutionStatus": "AvailableUsingPlatformHost" | "AvailableUsingCustomHost" | "AvailableUsingCustomHostByAdminDefault" | "AvailableUnboundCustomHost" | "MultipleUnboundCustomHosts" | "PlatformHostExistsUnbound" | "CannotRedirect" | "NoHost" | "OrgSettingStale" | "PermissionDenied" | "HostWithoutPipelines", + "finalHostEnvUrl": "...", + "finalHostEnvId": "...", + "finalHostInstanceApiUrl": "...", + "isPlatformHost": true | false, + "tenantDefaultCustomHostEnvId": "...", + "actionTaken": "none" | "reuse-existing-custom" | "reuse-existing-pe" | "fast-path-platform-getorcreate" | "fast-path-custom-d365projecthost" | "user-installed-app" | "user-installed-app-on-dev" | "user-created-custom-ppac", + "pipelinesSolutionVersion": "9.x.y.z", + "ready": true, + "warnings": [ + "Pipelines solution version 9.0.0.1 is below recommended 9.1.0.0 — RetrieveDeploymentPipelineInfo may not be available." + ], + "candidates": { + "existingCustomHosts": [ + { "envId": "...", "instanceApiUrl": "...", "displayName": "...", "pipelinesSolutionVersion": "..." } + ], + "existingPlatformHost": null, + "eligibleForAppInstall": [ + { "envId": "...", "instanceApiUrl": "...", "displayName": "..." } + ], + "inaccessibleEnvs": [ + { "envId": "...", "displayName": "...", "reason": "403" } + ] + }, + "telemetry": { + "correlationId": "{uuid passed to env-create, if applicable}", + "platformHostAlreadyExisted": true | false + } +} +``` + +> **`telemetry.platformHostAlreadyExisted`** — only present when `actionTaken === "fast-path-platform-getorcreate"`. `true` when the BAP `getOrCreate` returned 200 + `Succeeded` (idempotent — tenant already had a PE); `false` when the call returned 202 and we polled to completion. Lets us measure how often Phase 2.5 enumeration misses an existing PE so we can tune `--maxEnvsToProbe` defaults. + +> **Schema version bump (1 → 2):** added `candidates.*` block to record the tenant-wide enumeration result. Cache fast-path (Phase 1 step 0) reads `finalHostEnvUrl` regardless of schemaVersion; the candidates block is informational and helps debug / re-run decisions. Old v1 files remain readable — any missing field is treated as "not yet enumerated". + +> **`actionTaken` enum — value definitions:** +> - `"none"` — host already established before this run; no install/provision performed (Phase 3.A path or cache fast-path). +> - `"reuse-existing-custom"` — user picked an unbound Custom Host that already existed in the tenant (Phase 3.C-pre or 3.C-pre'). +> - `"reuse-existing-pe"` — user picked the existing Platform Host (Phase 3.C-pre''). +> - `"fast-path-platform-getorcreate"` — Phase 4.0 invoked the BAP `getOrCreate` endpoint. The `telemetry.platformHostAlreadyExisted` flag distinguishes idempotent-existing (200) vs. newly-provisioned (202) outcomes. +> - `"fast-path-custom-d365projecthost"` — Phase 4.A provisioned a new Custom Host via the env-create API. +> - `"user-installed-app"` — Phase 4.B installed Pipelines on an existing env that is **not** the same as the dev env. +> - `"user-installed-app-on-dev"` — Phase 4.B installed Pipelines on the **dev env itself** (URL origin matches `devEnvUrl`). Telemetry-distinct from `"user-installed-app"` so we can see how often users co-locate Pipelines with their dev env. +> - `"user-created-custom-ppac"` — Phase 4.C — user created the env via the PPAC UI; flow detected it post-create. + +This file is consumed by `setup-pipeline` and `deploy-pipeline`. + +Record skill usage: + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "EnsurePipelinesHost"`. + +Present summary table: + +| Field | Value | +|---|---| +| Tenant | `{tenantId}` | +| Source env | `{devEnvUrl}` | +| Resolution status | `{resolutionStatus}` | +| Final host | `{finalHostEnvUrl}` | +| Host type | `Platform` / `Custom` | +| Action taken | `{actionTaken}` | +| Pipelines version | `{pipelinesSolutionVersion}` | +| Warnings | `{warnings}` | + +If `actionTaken !== "none"`: + +> "**Next:** Run `/power-pages:setup-pipeline` to create your first pipeline against this host." + +## Integration with existing skills + +### setup-pipeline (✅ wired) + +`setup-pipeline/SKILL.md` Phase 1 step 4 calls `ensure-pipelines-host-detect.js` (the orchestrator wrapper) and branches on `resolutionStatus`: +- `AvailableUsing*` → use `finalHostEnvUrl` directly, continue. +- `*Unbound*` / `NoHost` → delegate to `/power-pages:ensure-pipelines-host` (this skill) for reuse-or-provision; resume after `docs/alm/last-host-check.json` shows `ready: true`. +- `CannotRedirect` / `OrgSettingStale` / `PermissionDenied` → stop with the specific admin-resolution message. + +The old "ask user for host URL manually" fallback in Phase 3 has been removed — `HOST_ENV_URL` is always populated by Phase 1, or the skill stops before Phase 3. + +### deploy-pipeline + +No change. `deploy-pipeline` reads `hostEnvUrl` from `docs/alm/last-pipeline.json` written by `setup-pipeline`. + +### plan-alm (✅ wired) + +`plan-alm` Phase 1 step 12 invokes `ensure-pipelines-host-detect.js` and stores the result as `HOST_RESOLUTION` (skipped when `PIPELINE_DONE = true`). Phase 2 Q4 branches on `HOST_RESOLUTION.status`. The generated `docs/alm-plan.html` includes a "Pipelines Host" card and (when `willEnsureDuringExecution: true`) a sub-bullet under the "Setup pipeline" checklist step. See `references/cicd-pipeline-patterns.md` and the `plan-alm-update-PLAN.md` spec. + +## Threat model — built-in mitigations + +| Risk | Mitigation in this skill | +|---|---| +| Confused-deputy / silent provisioning | Phase 1.4 tenant identity gate + Phase 3 explicit choice + Phase 4.A pre-call confirmation echoing the exact request body | +| Duplicate host creation | Phase 2.5 tenant-wide enumeration finds any existing Custom Host before Phase 3 offers to create. Phase 3.C-pre / 3.C-pre' surface existing hosts for reuse. User must explicitly decline reuse (option "No") to reach the create-new tree. | +| Stale local cache → using a deleted host | Phase 1.0 cache fast-path validates with a live `solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$top=1` probe before reusing — 404/403/timeout falls through to full Phase 2 | +| 404 ambiguity → unintended action | Phase 2.2 disambiguation rule — never act on a single 404; corroborate with list-tenant-envs | +| Wrong-tenant provisioning | Phase 1.4 echoes tenantId, organizationId, and dev env URL; Phase 4.A echoes tenantId in the pre-call body | +| `CannotRedirect` masked | Phase 2.3 explicitly detects this and stops with a specific error rather than continuing into a wrong-host write | +| JIT-provisioning miss → silent 404 chains | Phase 5 makes WhoAmI call against `instanceApiUrl` before any other host op (relevant when an existing PE was detected) | +| Stale solution → silent failure | Phase 5 reads `PIPELINES_SOLUTION_VERSION` and warns if below `MIN_PIPELINES_VERSION` | +| Non-admin tries Custom Host fast-path | Phase 4.A pre-prompts for admin role; gracefully falls back to Phase 4.B / 4.C on 403 | +| Tenant-singleton PE created accidentally | Phase 1.4 tenant-identity gate (echoes tenant display name + tenant ID + dev env URL) + Phase 4.0 pre-call confirmation gate (echoes tenant display name + tenant ID again, immediately before the call). The `getOrCreate` endpoint is idempotent — calling it on a tenant that already has a PE returns the existing one (200 + `alreadyExisted = true`) rather than creating a duplicate. | +| Telemetry leakage | All probe results stay in `docs/alm/last-host-check.json`; correlation ID is the standard `x-ms-correlation-id` UUID we generated; `update-skill-tracking.js` writes only counters + authoring-tool name | +| Privilege boundary | All paths run in user OAuth context; 403/401 surfaces as a stop with "this requires X admin role" message; no escalation attempted | +| Rate-limit | 15-min total timeout per path; respect `Retry-After` from BAP; minimum 10s poll interval | +| Force-link irreversibility | Out of scope — see *What this skill does NOT do* | + +## Key decision points (wait for user) + +1. **Phase 1.4** — Tenant identity confirmation (read-only intent) +2. **Phase 3.A/B/C-pre/C-pre'/C-pre''/C/D/E** — Branch decision based on `RESOLUTION.status` +3. **Phase 3.C-pre** — Reuse single existing Custom Host (Y/N/Cancel) +4. **Phase 3.C-pre'** — Pick from multiple existing Custom Hosts or create new +5. **Phase 3.C-pre''** — Use existing PE or create Custom Host instead +6. **Phase 3.C** — Create-new path selection (4 options: Custom-fast, app-install, PPAC-UI, cancel) +7. **Phase 4.A** — Admin-role self-attestation (No / Not sure → fall back to 4.B / 4.C) +8. **Phase 4.A** — Pre-call confirmation echoing exact API request body +9. **Phase 4.B/C** — User performs UI step → confirms back via "Done — proceed" +10. **Phase 5** — Warning acknowledgement if Pipelines solution version is below minimum + +## Error handling + +- `verify-alm-prerequisites.js` fails → stop with remediation (`az login`, `pac auth create`) +- BAP token acquisition fails → stop; suggest `az logout && az login` +- BAP env GET returns 404 → run disambiguation (Phase 2.2 fallback) +- BAP env GET returns 403 → `RESOLUTION.status = "PermissionDenied"`, surface tenant ID + env ID, stop +- Custom Host env-create returns 403 → seamless fallback to 4.B (app install) or 4.C (PPAC UI) +- Custom Host env-create returns 4xx other → log status + body, ask user to retry or switch path +- Lifecycle-op polling timeout (15 min default) → ask: extend (another 15min) / switch path / exit +- `RetrieveSetting` returns 404 → treated as "no admin default custom host" (current `discover-pipelines-host.js` behavior) +- `GetOrgDbOrgSetting` returns 404 → treated as "not bound" (matches UI behavior) +- WhoAmI on host returns 403 after JIT retries → likely `CannotRedirect` race or genuine perm issue; stop with both error message +- `verify-host-readiness.js` reports `Pipelines tables not found` after user-claimed install (4.C) → ask user to recheck PPAC or extend polling + +## Progress tracking table + +| Task subject | activeForm | Description | +|---|---|---| +| Check local cache and detect prerequisites | Checking cache and detecting prerequisites | Phase 1.0 read docs/alm/last-host-check.json; if fresh probe finalHostEnvUrl with solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$top=1 — on 200 reuse and skip to Phase 6. Otherwise run verify-alm-prerequisites.js + detect-project-context.js; acquire BAP_TOKEN; tenant identity confirmation gate | +| Run resolution order to find host | Running resolution order | GetOrgDbOrgSetting('ProjectHostEnvironmentId'); BAP env GET; if Platform check tenant default custom host; detect CannotRedirect; if no org binding run tenant-wide list+probe via list-tenant-envs.js (parallel max 10) to find existing Custom Hosts and PE; classify into AvailableUnboundCustomHost / MultipleUnboundCustomHosts / PlatformHostExistsUnbound / NoHost | +| Confirm action with user | Confirming action with user | Branch by resolutionStatus; for AvailableUnboundCustomHost / MultipleUnboundCustomHosts / PlatformHostExistsUnbound surface reuse prompt FIRST; only fall through to NoHost create-new tree if user declines reuse; collect explicit consent for Phase 4.A with pre-call body echo | +| Execute chosen path | Executing chosen path | Run path A (Custom D365_ProjectHost env-create)/B (manual app install)/C (PPAC New custom host); poll lifecycle ops at Retry-After interval; honor 15-min timeout | +| JIT-provision and verify host | Verifying host | WhoAmI against instanceApiUrl (triggers JIT only when existing PE was detected); deploymentpipelines table probe; Pipelines solution version probe; READY flag | +| Write host-check artifact | Writing host-check artifact | Write docs/alm/last-host-check.json with full RESOLUTION + actionTaken + correlationId; update skill tracking; present summary; suggest /power-pages:setup-pipeline next | + +## Open items (resolve during execution phase) + +These need real-environment validation: + +1. ~~**Pipelines solution `uniquename`.**~~ ✅ **RESOLVED 2026-04-28**: confirmed `msdyn_AppDeploymentAnchor` v9.1.2026034.260325188 via live query against SIP host (`pascalepipelineshost.crm.dynamics.com`). Stored as `PIPELINES_SOLUTION_UNIQUE_NAME` constant. +2. **`MIN_PIPELINES_VERSION`.** Set after testing which Pipelines features fail on the lowest in-the-wild solution version. Initial conservative guess: `"9.0.0.0"`. +3. ~~**Custom Host detection marker in `list-tenant-envs.js`.**~~ ✅ **RESOLVED 2026-04-28**: confirmed via live BAP env-list query (1000 envs in test tenant) — `linkedEnvironmentMetadata.templates` is **never returned** even with `$expand=properties.linkedEnvironmentMetadata`. Per-env Dataverse probe is mandatory. Probe query corrected to `solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=version&$top=1` (covers presence + version in one call). PE detection still straightforward via `environmentSku === 'Platform'`. +4. **`BapApiVersion` value for env-create.** The `D365_ProjectHost` template was onboarded for Pegasus / BAP-RP / Neptune (per eng.ms PR list). The PPAC UI uses `2021-04-01` for env operations. Confirm during execution by capturing a fresh HAR from `New custom host`. +5. **Lifecycle-op response shape.** Need to confirm whether the Location URL returns `{ properties: { provisioningState } }` or `{ state }` or both. To be HAR'd. +6. **Tenant home geo discovery.** Default region for 4.A. Options: `BAP_TOKEN` claims (`tid`, `xms_tcdt`?), BAP `/tenant?api-version=2021-04-01` endpoint, or `properties.azureRegionHint` from existing envs. Pick one during execution. +7. **Cold-tenant test env.** Need a tenant with no prior Pipelines usage to validate end-to-end. A personal MSDN tenant works. +8. **BAP env-list filter on `linkedEnvironmentMetadata.templates`?** If supported, we can pre-filter to envs created with `D365_ProjectHost` and skip the per-env Dataverse probe in Phase 2.5b. To be tested. If unsupported, the per-env probe with bounded concurrency stands. +9. **Does env-list response actually include `linkedEnvironmentMetadata.templates`?** If yes, even without server-side filter we can client-filter cheaply. If no, the per-env Dataverse probe is the only signal. Verify by capturing a fresh BAP env-list HAR including the `$expand=properties.linkedEnvironmentMetadata` query parameter (already used by `resolve-env-by-id.js`). +10. ~~**Per-env probe rate-limit budget.**~~ ✅ **PARTIALLY RESOLVED 2026-04-28**: Microsoft-internal test tenant has 1000 envs (526 Production sku, 453 Sandbox). Naïve probe-all is too slow even with 10-concurrent. Adopted multi-tier filter: + - Pre-filter envs without Dataverse (recovers ~3 envs in test tenant — minor) + - Filter by `--skus` (default `Production`; PE always included) + - Sort by `lastModifiedTime` desc + - Cap at `--maxEnvsToProbe` (default 50; ~5s wall time at 10-concurrent) + - Surface "scanned N of M (filter: ...)" warning when cap is hit and no host found + Remaining: validate cap defaults against typical customer tenants (5–50 envs) — should be no-op overhead there. +11. **`cacheMaxAgeMs` default.** 24h is a starting guess. May need tightening if hosts change frequently in dev tenants. Make it configurable via `--cacheMaxAgeHours` and document. + +## Scripts + +All shipped under `plugins/power-pages/scripts/lib/` (or as noted). Each is single-purpose Node, parses argv, uses `validation-helpers.js` for HTTPS, prints JSON to stdout. + +| Script | Purpose | Args | Output | +|---|---|---|---| +| `check-env-host-binding.js` | `POST GetOrgDbOrgSetting('ProjectHostEnvironmentId')` on the source env | `--envUrl`, `--token` | `{ bound, hostEnvId }` | +| `resolve-env-by-id.js` | BAP env GET with `$expand=properties.linkedEnvironmentMetadata,properties.permissions`, with PAC shim fallback on 401/403. | `--source` (`auto`\|`bap`\|`pac`; default `auto`), `--bapToken` (required for `bap`), `--envId` | `{ found, envId, instanceUrl, instanceApiUrl, displayName, environmentSku, isManaged, permissions, sourceUsed, fallbackReason, ... }`; on 404 returns `{ found: false, reason: "404-ambiguous" }`; on PAC-not-listed returns `{ found: false, reason: "not-in-pac-list" }` | +| `pac-bap-shim.js` | Wraps `pac admin list --json` into a BAP-shaped env-list. Used as fallback when Az→BAP returns 401 (some tenants reject Az CLI's first-party client ID for BAP). Derives `instanceApiUrl` from `EnvironmentUrl`; maps PAC's `Type` → BAP's `environmentSku`. Cannot surface PE (PAC doesn't list Platform-sku envs); BAP-only fields (`tenantId`, `lastModifiedTime`, `permissions`) are returned as `null`. | n/a (CLI prints all envs) | BAP-shaped env array | +| `list-tenant-envs.js` | List + per-env Pipelines-presence probe (`solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=version&$top=1`), parallel max 10 concurrent. Pre-filter: sku + has-Dataverse + optional `--includeName`. Ranking: name-hint pattern + admin-perms + recency. Cap: `--maxEnvsToProbe` (default 30). | `--source` (`auto`\|`bap`\|`pac`; default `auto`), `--bapToken` (required for `bap`), `--skus` (default `Production`; PE always included), `--maxEnvsToProbe`, `--maxConcurrency`, `--probeTimeoutMs`, `--includeName`, `--firstHitWins` | `{ existingCustomHosts[], existingPlatformHost, eligibleForAppInstall[], inaccessibleEnvs[], inaccessibilityBreakdown, totalEnvsInTenant, envsAfterFilter, envsProbed, hitProbeCap, earlyExitOnFirstHit, probeDurationMs, sourceUsed, fallbackReason }` | +| `verify-host-readiness.js` | `WhoAmI` (proves auth + triggers JIT in PE-detection path) → solutions filter for `msdyn_AppDeploymentAnchor` (one call covers presence + version) | `--hostEnvUrl`, `--hostToken`, `--skipWhoAmI` (opt), `--minPipelinesVersion` (opt) | `{ ready, pipelinesSolutionVersion, checks: { whoami, solutions }, warnings[] }` | +| `provision-custom-host.js` | POST BAP env-create with `D365_ProjectHost` template + `Production` sku + `CommonDataService` databaseType. Polls lifecycle op via `Location` header at `Retry-After` interval. Handles `properties.provisioningState` / `state` / `status.code` shapes. 5xx-transient retry. 401/403 with explicit guidance. | `--bapToken`, `--displayName`, `--region`, `--correlationId` (opt), `--timeoutSec` (opt, default 900), `--apiVersion` (opt, default 2021-04-01) | `{ status, envId, instanceUrl, instanceApiUrl, displayName, environmentSku, provisioningState, durationSec, correlationId, pollAttempts, locationHeader }` | +| `provision-platform-host.js` | POST BAP `getOrCreate` with `D365_1stPartyAdminApps` template + `Platform` sku. Returns `alreadyExisted: true` on the 200 idempotent path (existing PE returned) or `alreadyExisted: false` on the 202 + Location-poll path (newly provisioned). Used by Phase 4.0. | `--bapToken`, `--correlationId` (opt), `--timeoutSec` (opt, default 600), `--apiVersion` (opt, default `2021-04-01`), `--bapBase` (opt) | `{ status, alreadyExisted, envId, instanceUrl, instanceApiUrl, displayName, environmentSku, provisioningState, durationSec, correlationId, pollAttempts, locationHeader }` | +| `install-pipelines-app.js` | Discover + install the Power Platform Pipelines application package on an existing env. Resolution: BAP `applicationPackages` LIST + `/install` POST → 200 sync / 202 + Location poll, with PAC CLI fallback on 401/403/5xx (`pac application install --environment-id ... --application-list msdyn_AppDeploymentAnchor`). 409 on install POST treated as idempotent (already-installed). Optional post-install Dataverse verification probe. Used by Phase 4.B (replaced the manual PPAC click-through on 2026-05-08). | `--bapToken`, `--envId`, `--instanceApiUrl` (opt — for verification), `--hostToken` (opt — for verification), `--no-pac-fallback` (opt; default: PAC fallback enabled), `--correlationId` (opt), `--timeoutSec` (opt, default 600), `--apiVersion` (opt, default `2022-03-01-preview`), `--bapBase` (opt) | `{ status, alreadyInstalled, installPath: 'bap'\|'pac'\|'cached', packageUniqueName, pipelinesSolutionVersion, durationSec, correlationId, pollAttempts, locationHeader, pacFallbackReason }` | +| `ensure-pipelines-host-detect.js` | Detection-only orchestrator wrapper. Runs Phase 1.0 (cache fast-path) + Phase 2 (resolution order including tenant-wide enumeration) + Phase 5 (verify if host found). Always emits `actionTaken: "none"`. Used by `plan-alm` Phase 1 and `setup-pipeline` Phase 1. **`--source auto` (default) tries BAP first; on 401/403 falls back to PAC CLI shim** — works in tenants where Az CLI tokens are rejected by BAP. | `--envUrl`, `--token`, `--userId`, `--bapToken` (optional with `--source auto` or `pac`), `--source` (`auto`\|`bap`\|`pac`), `--projectRoot`, `--cacheMaxAgeHours` (opt), `--no-cache`, `--includeName`, `--maxEnvsToProbe`, `--skus`, `--minPipelinesVersion` | `docs/alm/last-host-check.json` schema (with `sourceUsed`, `fallbackReason`) | +| `validate-ensure-host.js` (skill `scripts/`) | PostToolUse Stop-hook validator. Schema v1+v2 forward-compat. Treats `CannotRedirect` / `OrgSettingStale` / `PermissionDenied` as documented terminal-error states (skill ran successfully even if host isn't usable). | n/a (reads stdin JSON `{cwd}`) | exit 0 (approve) or exit 2 (block) | + +Existing helpers reused (no changes): +- `verify-alm-prerequisites.js` +- `detect-project-context.js` +- `discover-pipelines-host.js` (the tenant-default-custom-host probe; called from Phase 2 step 3 inside the wrapper) +- `update-skill-tracking.js` + +## Validation script + +`skills/ensure-pipelines-host/scripts/validate-ensure-host.js` (PostToolUse Stop hook, registered via `TRACKED_SKILLS` in `scripts/lib/powerpages-hook-utils.js`): + +- If no `docs/alm/last-host-check.json` in cwd → exit 0 (not an ensure-host session). +- If present: validate `schemaVersion === 1` or `2` (forward-compatible); required fields populated (`tenantId`, `sourceEnvUrl`, `resolutionStatus`); `ready === true` for non-terminal-error statuses; `finalHostEnvUrl` populated when `ready === true`. +- Terminal-error statuses (`CannotRedirect` / `OrgSettingStale` / `PermissionDenied`) are accepted with `ready: false` — the skill ran successfully and surfaced a state requiring manual / admin resolution. +- The `candidates` block (v2) is optional — its absence does not fail validation. + +The companion prompt-hook checks: +1. Either (a) Phase 1.0 cache fast-path succeeded and we reused the cached host, OR (b) the full flow ran: + - Tenant identity gate was confirmed. + - `RESOLUTION.status` was determined via the full resolution order including tenant-wide enumeration when source env was unbound. + - If status was `AvailableUnbound*`, `MultipleUnboundCustomHosts`, or `PlatformHostExistsUnbound`, the user explicitly chose reuse-or-create-new. + - If status indicated provisioning was needed (`NoHost`), an explicit user-chosen path completed (`actionTaken` is one of the `fast-path-*`, `user-installed-*`, or `user-created-*` values — i.e. not `"none"` and not `"reuse-existing-*"`). +2. `verify-host-readiness.js` reported `ready: true` (or a documented terminal-error state was reached). +3. `docs/alm/last-host-check.json` was written with `schemaVersion: 2` and the `candidates` block populated when tenant-wide enumeration ran. +4. Summary was presented. diff --git a/plugins/power-pages/skills/ensure-pipelines-host/scripts/validate-ensure-host.js b/plugins/power-pages/skills/ensure-pipelines-host/scripts/validate-ensure-host.js new file mode 100644 index 000000000..4820af988 --- /dev/null +++ b/plugins/power-pages/skills/ensure-pipelines-host/scripts/validate-ensure-host.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +// Stop-hook validator for the ensure-pipelines-host skill. +// Reads docs/alm/last-host-check.json from the project and verifies the skill ran +// to a documented terminal state. Gracefully exits 0 when no marker exists +// (not an ensure-host session). +// +// Pass conditions (exit 0): +// - File missing. +// - schemaVersion is 1 or 2. +// - tenantId, sourceEnvUrl, resolutionStatus populated. +// - Either ready === true (host is usable), OR +// resolutionStatus is a documented terminal-error state where finalHostEnvUrl +// is null and the user has been told what to do (CannotRedirect / +// OrgSettingStale / PermissionDenied). +// +// Block conditions (exit 2): +// - Schema invalid / required fields missing. +// - ready === false AND resolutionStatus is not in the terminal-error allowlist. + +const fs = require('fs'); +const { + approve, + block, + runValidation, + findProjectRoot, +} = require('../../../scripts/lib/validation-helpers'); +const { almPath } = require('../../../scripts/lib/alm-paths'); + +const TERMINAL_ERROR_STATES = new Set([ + 'CannotRedirect', + 'OrgSettingStale', + 'PermissionDenied', +]); + +const VALID_STATUSES = new Set([ + 'AvailableUsingPlatformHost', + 'AvailableUsingCustomHost', + 'AvailableUsingCustomHostByAdminDefault', + 'AvailableUnboundCustomHost', + 'MultipleUnboundCustomHosts', + 'PlatformHostExistsUnbound', + 'CannotRedirect', + 'NoHost', + 'OrgSettingStale', + 'PermissionDenied', + 'HostWithoutPipelines', +]); + +runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd) || cwd; + const markerPath = almPath(projectRoot, 'lastHostCheck'); + + if (!fs.existsSync(markerPath)) return approve(); // Not an ensure-host session. + + let marker; + try { + marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + return block('docs/alm/last-host-check.json exists but could not be parsed as JSON.'); + } + + if (marker.schemaVersion !== 1 && marker.schemaVersion !== 2) { + return block(`docs/alm/last-host-check.json has unsupported schemaVersion: ${marker.schemaVersion}. Expected 1 or 2.`); + } + if (!marker.tenantId) { + return block('docs/alm/last-host-check.json is missing required field: tenantId'); + } + if (!marker.sourceEnvUrl) { + return block('docs/alm/last-host-check.json is missing required field: sourceEnvUrl'); + } + if (!marker.resolutionStatus) { + return block('docs/alm/last-host-check.json is missing required field: resolutionStatus'); + } + if (!VALID_STATUSES.has(marker.resolutionStatus)) { + return block(`docs/alm/last-host-check.json has unknown resolutionStatus: ${marker.resolutionStatus}`); + } + + // Acceptable terminal-error: ready may be false but resolution itself was correct. + if (TERMINAL_ERROR_STATES.has(marker.resolutionStatus)) { + return approve(); + } + + // Otherwise the host must be usable. + if (marker.ready !== true) { + return block(`docs/alm/last-host-check.json has ready=${marker.ready} but resolutionStatus "${marker.resolutionStatus}" requires a usable host. The skill did not complete successfully.`); + } + + // Sanity: when ready=true, finalHostEnvUrl must be set. + if (!marker.finalHostEnvUrl) { + return block('docs/alm/last-host-check.json has ready=true but finalHostEnvUrl is missing.'); + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/export-solution/SKILL.md b/plugins/power-pages/skills/export-solution/SKILL.md new file mode 100644 index 000000000..61ac541bb --- /dev/null +++ b/plugins/power-pages/skills/export-solution/SKILL.md @@ -0,0 +1,387 @@ +--- +name: export-solution +description: >- + Exports a Dataverse solution containing Power Pages site components as a zip file, + ready for deployment to another environment. Use when asked to: "export solution", + "download solution", "export managed", "export unmanaged", "package for deployment", + "create solution zip", "export site package", or "build deployment artifact". +user-invocable: true +argument-hint: "Optional: 'managed' or 'unmanaged' (default: asks)" +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# export-solution + +Triggers an async Dataverse solution export, polls until complete, downloads the solution zip, and verifies it. Reads `.solution-manifest.json` to identify the solution; falls back to asking the user. + +## Prerequisites + +- PAC CLI installed and authenticated +- Azure CLI installed and logged in +- Solution exists in the environment (run `setup-solution` first if needed) + +## Phases + +### Phase 0 — ALM plan gate + +> **`plan-alm` is the front door.** When the user expresses an ALM intent (*promote / ship / deploy / set up CI-CD / move to staging / push to prod*), the orchestrator (`/power-pages:plan-alm`) should run first. This Phase 0 enforces that and is meant to fail closed when there's no plan, not to be a one-time check the user can dismiss forever. + +**Skip rule.** If this skill was invoked *as part of an active `plan-alm` orchestration*, skip Phase 0 entirely and proceed to Phase 1. The gate helper exposes this via its `inExecution` block — pass through silently to Phase 1 when: + +``` +inExecution.status === "active" +``` + +The helper computes this from `docs/.alm-plan-data.json` — `PLAN_STATUS === "In Execution"` AND `LAST_INVOCATION_AT` within the last 60 minutes. `check-alm-plan.js` refreshes `LAST_INVOCATION_AT` automatically on every invocation that finds the plan in execution, so each in-chain skill keeps the chain alive for the next one — even multi-hour deploys (deploy-pipeline alone can take 60 min per stage) survive the window without the chain incorrectly de-classifying. Stalled chains (no heartbeat for > 60 min) reclassify as `stale-heartbeat` and Phase 0 gates fire normally so an abandoned plan doesn't silently bypass user confirmation. + +When `inExecution.status` is anything other than `"active"` (`"not-running"`, `"stale-heartbeat"`, `"no-plan"`), run the Phase 0 gate flow below. Branch on the remaining helper fields: + +**Step 1 — Run the gate helper.** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" \ + --projectRoot "." \ + --envUrl "{envUrl from .solution-manifest.json or pac env who, if available}" \ + --token "{token, if Phase 1 already acquired one}" \ + --solutionId "{solutionId from .solution-manifest.json, if available}" +``` + +The helper returns JSON with `{ exists, deferred, stale, staleness: { reason, detail }, generatedAt, planStatus, ... }`. The freshness check requires env credentials + solutionId; without those the helper does an existence-only check. + +**Step 2 — Branch on the result.** + +| Result | Behavior | +|---|---| +| `deferred: true` | The user has explicitly deferred ALM for this project (`.alm-deferred` marker present). Pass through silently to Phase 1 — do not nag. | +| `exists: false` | The user hasn't run `plan-alm` yet. See Step 3. | +| `exists: true, stale: false` | Plan is current. Pass through silently to Phase 1. | +| `exists: true, stale: true` (reason: `solution-modified`) | The solution changed after the plan was generated. See Step 4. | + +**Step 3 — No plan.** Tell the user: + +> "No ALM plan exists for this project. `/power-pages:plan-alm` builds one — it detects the project state, asks about your promotion strategy (PP Pipelines vs Manual export/import), and orchestrates the right skills (including this one) in the right order. Want me to run plan-alm now?" + + +> 🚦 **Gate (intent · export-solution:0.no-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `exists:false`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Run `/power-pages:plan-alm` first? | ALM plan gate | Yes — run /power-pages:plan-alm now (Recommended), Continue without a plan (advanced — I know what I'm doing), Cancel | + +- **Yes (Recommended)** → invoke `/power-pages:plan-alm`. plan-alm's Phase 7 dispatches back into this skill at the appropriate stage. +- **Continue without a plan** → set `BYPASSED_PLAN_GATE = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Step 4 — Stale plan.** Tell the user: + +> "ALM plan exists from `{generatedAt}` but the source solution has been modified since (at `{solution.modifiedon}`). Components may have changed. Re-running `plan-alm` will refresh the analysis and the rendered HTML." + + +> 🚦 **Gate (intent · export-solution:0.stale-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `stale:true`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Refresh the plan first? | ALM plan freshness | Refresh — re-run /power-pages:plan-alm (Recommended), Continue with the existing plan, Cancel | + +- **Refresh (Recommended)** → invoke `/power-pages:plan-alm`. After completion, re-run the Phase 0 helper once to confirm freshness; if still stale, surface the detail and proceed to Phase 1 anyway (don't infinite-loop). +- **Continue** → set `STALE_PLAN_ACK = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Why this gate exists.** Direct invocation of `export-solution` produces a zip without the orchestrator's pre-export completeness check. Users running this skill standalone often miss components that should have been added to the solution (cloud flows, env var values referenced by site settings, sample data references) and ship a zip that imports cleanly into staging but produces a broken site post-deploy. The pre-plan completeness check surfaces those gaps before any zip is built. The gate ensures `plan-alm` either ran (so completeness was verified and the export was scoped to the right solution lineage) or the user explicitly chose to bypass it. + +### Phase 1 — Verify Prerequisites + +**Create all tasks upfront at the start of this phase.** + +Tasks to create: +1. "Verify prerequisites" +2. "Identify solution" +3. "Configure export" +4. "Trigger async export" +5. "Download solution zip" +6. "Verify export" +7. "Present summary" + +Steps: +1. Run `verify-alm-prerequisites.js` with `--require-manifest` to confirm PAC CLI auth, acquire a token, verify API access, and validate that `.solution-manifest.json` exists: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --require-manifest + ``` + Capture output as JSON; extract `.envUrl` (store as `envUrl`) and `.token` (store as `token`). If the script exits non-zero, stop and explain what is missing (reference `${CLAUDE_PLUGIN_ROOT}/references/dataverse-prerequisites.md`). + +### Phase 1.5 — Ground in current ALM documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: `Power Pages solution export managed unmanaged ExportSolutionAsync ALM`. +2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/solution-concepts-alm` (and at most one sister page on managed vs unmanaged or solution layering) in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what Microsoft Learn currently says about export semantics, managed vs unmanaged implications, and async export polling. Compare against `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` and flag any divergence in `ExportSolutionAsync` / `DownloadSolutionExportData` signatures. +4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 3. + +### Phase 2 — Identify Solution + +1. Look for `.solution-manifest.json` in project root (use `findProjectRoot` or `glob('**/.solution-manifest.json')`) +2. If found: read `solution.uniqueName`, `solution.solutionId`, `environmentUrl` + - Verify environment URLs match (warn if different — may be cross-environment export) +3. If not found: ask user for solution unique name via `AskUserQuestion` +4. Confirm solution exists in environment: + ``` + GET {envUrl}/api/data/v9.2/solutions?$filter=uniquename eq '{solutionName}'&$select=solutionid,uniquename,friendlyname,version,ismanaged + ``` +5. Present solution details and confirm with user. + +### Phase 2.5 — Pre-export Completeness Check + +Before exporting, run the shared site-inventory helper to detect any components that exist on the site but are not in the solution. Catching this here avoids shipping an incomplete package to staging/prod. + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-site-components.js" \ + --envUrl "{envUrl}" --token "{token}" \ + --siteId "{websiteRecordId}" \ + --publisherPrefix "{publisherPrefix from .solution-manifest.json}" \ + --solutionId "{solutionId}" +``` + +Parse stdout and evaluate `missing`. **Before doing anything else**, capture the **pre-sync state** so a post-sync re-confirmation gate can show what changed: + +``` +PRE_SYNC_VERSION = solutionManifest.solution.version // from .solution-manifest.json read in Phase 2 +PRE_SYNC_MISSING = { siteComponents, siteLanguages, cloudFlows, envVarDefinitions, customTables, ... } // from the discovery stdout above +``` + +Then: + +- **All `missing.*` arrays empty** → report "Solution contents match the site — no gaps detected." Proceed to Phase 3. +- **Any non-empty `missing.*` array** → present a concise summary: + > "The solution is **missing {N}** component(s) that exist on the site: + > + > - **{X}** site components (e.g. {first 3 names}, …) + > - **{Y}** cloud flows + > - **{Z}** environment variable definitions with your publisher prefix + > - **{W}** custom tables" + + + > 🚦 **Gate (progress · export-solution:2.5.completeness):** Source solution incomplete vs live site. Sync first, export as-is (gap recorded), or abort. + + Then ask via `AskUserQuestion`: + > "How would you like to proceed? + > 1. **Run `/power-pages:setup-solution` in sync mode now** — adopts missing components, bumps the solution version, then re-confirms with you before exporting (Recommended) + > 2. **Export as-is** — ship what's currently in the solution; missing components won't travel + > 3. **Abort** — I want to investigate before exporting" + + - **Option 1 — Sync first, then re-confirm before export:** + 1. Invoke `/power-pages:setup-solution` (auto-detects the existing manifest, enters sync mode, adopts missing components, bumps the version). Wait for completion. setup-solution's final refresh step writes `LAST_SYNC_AT` into `docs/.alm-plan-data.json` so subsequent `check-alm-plan.js` calls do NOT falsely flag the plan as stale just because the sync bumped `solutions.modifiedon` past `GENERATED_AT` — the freshness reference becomes `max(GENERATED_AT, LAST_SYNC_AT)`. + 2. Re-read `.solution-manifest.json` and capture `POST_SYNC_VERSION = solutionManifest.solution.version`. + 3. Re-run the discovery helper. If any `missing.*` remain non-empty, repeat the Phase 2.5 prompt above. + 4. Otherwise compute `NEWLY_ADOPTED` as a per-category set difference between `PRE_SYNC_MISSING` and the second discovery run's `missing.*` (the items that disappeared are what setup-solution just adopted into the solution). Total count = sum of all category lengths. + + > 🚦 **Gate (progress · export-solution:2.5.post-sync):** Post-sync re-confirm. Solution version bumped + components adopted — user inspects delta before export proceeds. + + 5. **Re-confirm with the user before proceeding to Phase 3** — the solution about to be exported is now different from what the user originally saw when they started the export. Use `AskUserQuestion`: + + > "Sync complete. + > + > **{solutionUniqueName}** is now **v{POST_SYNC_VERSION}** (was v{PRE_SYNC_VERSION}) with **{NEWLY_ADOPTED.total} newly-adopted components**: + > - {first 3-5 names by category — prefer high-signal categories: cloud flows, server logic, env var definitions, then site components} + > - {if more remain: `+ {N} more across {category list}`} + > + > About to export this updated solution to a zip file. + > + > Continue with the export?" + + | Question | Header | Options | + |---|---|---| + | Continue with the export? | Post-sync approval | Yes — export v{POST_SYNC_VERSION} (Recommended), Pause — I want to review the new solution contents first, Cancel — abort the export | + + - **Yes** → proceed to Phase 3 with the post-sync solution. + - **Pause** → exit export-solution cleanly with a short note ("Paused after sync. Re-run `/power-pages:export-solution` when you're ready to export v{POST_SYNC_VERSION}.") so the user can inspect the synced manifest / Dataverse state and resume manually. **Do not** write any export artifacts — no export happened. Skip the skill-tracking call too. + - **Cancel** → stop the skill. Same no-artifact / no-tracking rule applies. + - **Option 2** — record the gap in the export manifest (see Phase 7 summary) so the user has an audit trail of what was intentionally left out. + - **Option 3** — stop the skill. + +> **Why the post-sync gate exists**: when sync mode runs mid-export, it produces a different solution version than the one the user had in mind when they invoked the skill. Re-confirming after sync gives the user an explicit chance to inspect the version bump and the list of newly-adopted components before the zip is produced and (typically) shipped onward via `import-solution`. The Phase 2.5 trigger is intentional; the post-sync re-confirmation is the safety on top of it. This mirrors the same gate in `deploy-pipeline` Phase 3.5 — same shape, same options, same audit-trail rules — so users see consistent behavior whether they take the PP Pipelines path or the Manual export/import path. + +> **Why Phase 2.5 exists in the first place**: historically, components created after `setup-solution` (server logic from `add-server-logic`, flows from `add-cloud-flow`, env vars from `configure-env-variables` / `setup-auth`) were silently left out of the export zip and didn't travel to target environments. The ALM-aware-by-default principle in `AGENTS.md` requires this check at every gate where a solution leaves its source environment. + +### Phase 3 — Configure Export + + +> 🚦 **Gate (consent · export-solution:3.export-type):** Managed vs Unmanaged — irreversible for the produced zip. Managed cannot be edited in target; Unmanaged can. Mismatch with stage strategy ships the wrong artifact downstream. + +Invoke `AskUserQuestion` immediately — do NOT describe this choice as chat text. The user must answer live before export proceeds. + +| Question | Header | Options | +|---|---|---| +| How would you like to export this solution? **Managed** solutions cannot be edited in the target environment and support clean upgrade/delete cycles — recommended for staging and production. **Unmanaged** solutions can be edited in the target environment — use for dev-to-dev deployments. | Export Type | Managed — for staging/production (Recommended), Unmanaged — for development environments | + +Use the answer to set `"Managed": true` or `"Managed": false` in the `ExportSolutionAsync` request body. + +Also ask (separate `AskUserQuestion`): +- Output directory (default: current project root) + +### Phase 4 — Trigger Async Export + +**Step 4.0 — Bump source solution version (always-on).** + +Before exporting, bump the patch segment (4th segment) of the source solution's version. Without this, two consecutive exports without intervening `setup-solution` sync produce zips that carry the **same** version string — and importing the second zip into a target that already has the first installed is unreliable for managed solutions (no clean upgrade path) and depends on `OverwriteUnmanagedCustomizations: true` for unmanaged. + +> **Why always-on, not "only when sync mode added components"**: `setup-solution` only bumps when it has new components to add. A user who modifies content of an already-in-solution component (a web template, a site setting value, a web file) and then re-exports must still get a strictly-increasing version label — otherwise the manual export/import path quietly ships stale-version zips. See the `Why this step exists` callout in `setup-solution` Phase 4. + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/bump-solution-version.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --uniqueName "{solutionUniqueName}" \ + --projectRoot "." +``` + +Capture output as JSON; store `.previous` as `PRE_EXPORT_VERSION`, `.next` as `EXPORT_VERSION`, and inspect `.manifestUpdated` / `.manifestUpdateReason` to confirm the manifest sync succeeded. Report: "Bumped solution `{solutionUniqueName}` from v{PRE_EXPORT_VERSION} to v{EXPORT_VERSION} for export." + +`--projectRoot "."` makes the helper update `.solution-manifest.json`'s `solution.version` (single-solution) or matching `solutions[].version` (multi-solution) field atomically as part of the bump operation — no separate `Edit` step needed. If the manifest doesn't exist or has no matching entry, `manifestUpdated: false` and `manifestUpdateReason` tells you why (`no-manifest`, `no-matching-entry`, etc.); the bump itself still succeeded. + +> **If the bump already happened earlier in this session** (e.g. `setup-solution` sync mode ran with adopted components in Phase 2.5 and bumped the version, then handed back here): the helper still runs and bumps again. This is intentional — sync's bump is paired with new components; export's bump is paired with the produced zip. They're independent concerns and double-bumping is cheap (just an extra patch segment). The skill-skipping logic for "the manifest version already matches the live source version" is intentionally NOT added here; it would create a class of "I edited content but no sync was needed and no bump happened, so the export shipped a stale version" failures. + +**Step 4.1 — Trigger async export.** + +Run `scripts/lib/export-solution-async.js` to POST `ExportSolutionAsync`, poll until terminal state, and return the `AsyncOperationId`: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/export-solution-async.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --solutionName "{solutionUniqueName}" \ + --managed {true|false} +``` + +Capture stdout as JSON; extract `.asyncOperationId` (store as `asyncOperationId`). + +Report: "Export job started. Polling for completion..." + +Handle script exit code: +- Exit 0: job succeeded — proceed to Phase 5 with `asyncOperationId` +- Exit 1: stderr contains the failure message — report it and stop +- Timeout / polling exhausted: inform user the export is still running, advise checking admin center + +### Phase 5 — Download Solution Zip + +Run `scripts/lib/download-export-data.js` to POST `DownloadSolutionExportData`, decode the base64 zip, and write it to disk: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/download-export-data.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --asyncOperationId "{asyncOperationId}" \ + --outputPath "{outputDir}/{SolutionUniqueName}_{managed|unmanaged}.zip" +``` + +Capture stdout as JSON; extract `.zipPath` (store as `zipPath`) and `.fileSizeBytes`. + +Report: "Downloading solution zip..." + +Handle script exit code: +- Exit 0: zip written — proceed to Phase 6 with `zipPath` and `fileSizeBytes` +- Exit 1: stderr contains the failure message — report it and stop + +### Phase 6 — Verify Export + +1. Confirm zip file exists on disk: check `fs.existsSync(zipPath)` +2. Confirm file size > 1000 bytes +3. Verify `Solution.xml` is inside the zip: + - Run `unzip -l "{zipPath}" | grep -i solution.xml` or read zip TOC via Node.js (use `Bash` with unzip) + - If solution.xml not found: report error — the zip may be corrupt or the download was truncated + +### Phase 7 — Present Summary + +**Step 7.1 — Write `docs/alm/last-export.json` marker.** + +Ensure `docs/alm/` exists, then write the marker so downstream skills (`import-solution` skew advisory, `refresh-alm-plan-data.js` rendering the Manual-path tab, future "modified-since-last-export" gates, audit trail) can reason about what was last shipped from this source. + +```bash +node -e "require('fs').mkdirSync('docs/alm',{recursive:true})" +``` + +Then write `docs/alm/last-export.json`: + +```json +{ + "exportedAt": "", + "solutionUniqueName": "", + "solutionId": "", + "previousVersion": "", + "version": "", + "managed": , + "sourceEnvironmentUrl": "", + "zipPath": "", + "fileSizeBytes": , + "asyncOperationId": "" +} +``` + +The path is registered in `scripts/lib/alm-paths.js` under the key `lastExport` — programmatic consumers should resolve via `almPath(projectRoot, 'lastExport')` rather than re-inlining the path string. (Skill prose inlines the path verbatim for readability, matching the convention used for `last-deploy.json`, `last-import.json`, and the other ALM markers.) + +**Step 7.2 — Display the summary.** + +| Item | Value | +|---|---| +| Solution | `{solutionUniqueName}` v`{EXPORT_VERSION}` (was v`{PRE_EXPORT_VERSION}`) | +| Export type | Managed / Unmanaged | +| File | `{zipPath}` | +| File size | `{size} KB` | +| Export job | `{AsyncJobId}` | +| Marker written | `docs/alm/last-export.json` | + +**Suggested next steps**: +- Run `/power-pages:import-solution` to deploy this zip to another environment +- Run `/power-pages:setup-pipeline` to automate this process in CI/CD + +### Record Skill Usage + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "ExportSolution"`. + +### Refresh the ALM plan (if one exists) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase export-solution \ + --render +``` + +Re-renders `docs/alm-plan.html` so any step-status updates the agent made during this skill (`Export solution` → `status-completed`) flow through. When `docs/.alm-plan-data.json` is absent (standalone invocation, not via plan-alm), the helper returns `ok:false` as a soft no-op — safe to run unconditionally. + +## Key Decision Points (Wait for User) + +1. **Phase 2**: Solution identification — confirm before triggering export +2. **Phase 2.5**: Completeness-gap prompt (sync-first / export-as-is / abort) when the live site has components missing from the solution +3. **Phase 2.5**: **Post-sync approval gate** — only fires after a mid-export sync (Option 1). Shows the new solution version + newly-adopted components and asks the user to confirm the post-sync solution before exporting the zip. Pause exits cleanly; Cancel aborts. +4. **Phase 3**: Managed vs unmanaged — affects downstream importability (irreversible choice for this export) +5. **Phase 4 Step 4.0**: No user prompt — version bump runs automatically before `ExportSolutionAsync`. The bumped version (`PRE_EXPORT_VERSION → EXPORT_VERSION`) is surfaced in the Phase 7 summary so the user can see what version landed in the zip. + +## Error Handling + +- If export job fails: show `message` and `friendlyMessage` from the async operation +- If download returns empty `ExportSolutionFile`: report error, suggest re-exporting +- Never retry automatically — report failure and let user decide + +## Progress Tracking Table + +| Task subject | activeForm | Description | +|---|---|---| +| Verify prerequisites | Verifying prerequisites | Confirm PAC CLI auth, acquire Azure CLI token, verify API access | +| Identify solution | Identifying solution | Read .solution-manifest.json or ask user, confirm solution exists in environment | +| Configure export | Configuring export | Ask user: managed vs unmanaged, output directory | +| Trigger async export | Triggering async export | Bump source solution version (Step 4.0) via bump-solution-version.js so the zip carries a strictly-increasing version label; POST ExportSolutionAsync, capture AsyncJobId, poll until complete | +| Download solution zip | Downloading solution zip | POST DownloadSolutionExportData, decode base64, write zip to disk | +| Verify export | Verifying export | Confirm zip exists, size > 0, Solution.xml present inside | +| Present summary | Presenting summary | Write docs/alm/last-export.json marker (via alm-paths.js); show zip path, size, type, version bump, and suggested next steps | diff --git a/plugins/power-pages/skills/export-solution/scripts/validate-export.js b/plugins/power-pages/skills/export-solution/scripts/validate-export.js new file mode 100644 index 000000000..fd34487ca --- /dev/null +++ b/plugins/power-pages/skills/export-solution/scripts/validate-export.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +// Validates that export-solution completed: checks that a solution zip was written to disk +// and that Solution.xml is present inside it. +// Gracefully exits 0 when no solution zip is found (not an export-solution session). + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { approve, block, runValidation, findProjectRoot, findPath, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); + +runValidation(async (cwd) => { + if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. + const projectRoot = findProjectRoot(cwd) || cwd; + + // Search for solution zip files written this session + // Look for *_managed.zip or *_unmanaged.zip patterns in the project root and subdirs + const zipFiles = []; + + function scanForZips(dir, depth = 0) { + if (depth > 2) return; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '.git') { + scanForZips(path.join(dir, entry.name), depth + 1); + } else if (entry.isFile() && (entry.name.endsWith('_managed.zip') || entry.name.endsWith('_unmanaged.zip'))) { + zipFiles.push(path.join(dir, entry.name)); + } + } + } catch {} + } + + scanForZips(projectRoot); + + // No solution zip found — not an export-solution session + if (zipFiles.length === 0) return approve(); + + // Validate each zip found + for (const zipPath of zipFiles) { + const stat = fs.statSync(zipPath); + + if (stat.size < 1000) { + return block(`Solution zip '${path.basename(zipPath)}' is too small (${stat.size} bytes). The export may have been truncated or failed.`); + } + + // Verify Solution.xml is inside the zip + try { + const output = execSync(`unzip -l "${zipPath}" 2>/dev/null | grep -i solution.xml`, { + encoding: 'utf8', + timeout: 10000, + }); + if (!output || !output.toLowerCase().includes('solution.xml')) { + return block(`Solution zip '${path.basename(zipPath)}' does not contain solution.xml. The export appears corrupt.`); + } + } catch { + // unzip not available or grep returned no match + // Fall back to just checking file size — already done above + // Don't block if unzip is unavailable + } + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/force-link-environment/SKILL.md b/plugins/power-pages/skills/force-link-environment/SKILL.md new file mode 100644 index 000000000..6d4069e28 --- /dev/null +++ b/plugins/power-pages/skills/force-link-environment/SKILL.md @@ -0,0 +1,250 @@ +--- +name: force-link-environment +description: >- + Force-links a development or target environment to a Power Platform Pipelines + host, overriding any existing association with a previous host. Use when + creating a deploymentenvironments record fails with "this environment is + already associated with another pipelines host", or when intentionally + migrating an environment from one host to another (e.g., Platform Host → + Custom Host, or between two Custom Hosts). Calls the documented + `ManageEnvironmentStamp` Dataverse action (the API behind the "Force Link" + button in the Deployment Pipeline Configuration app). DESTRUCTIVE to the + previous host: makers lose access to any pipelines in that host that used + this environment. Reversible by running Force Link from the previous host. + Use when asked to: "force link environment", "force-link to new host", + "switch pipelines host", "environment already associated with another host", + "take over pipelines association", "relink environment to host". +user-invocable: true +argument-hint: "Optional: '--host ' to skip the host resolution step; '--dev-env ' to skip the dev env prompt. With both flags supplied, the skill still pauses for destructive-action confirmation." +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# force-link-environment + +Move a dev or target environment's Power Platform Pipelines host association from one host to another. This is the documented remediation when `deploymentenvironments` create fails with *"this environment is already associated with another pipelines host"*, and also the right tool when intentionally migrating environments between hosts. + +**Microsoft Learn (ground truth):** [Using Force Link to associate an environment with a new host](https://learn.microsoft.com/en-us/power-platform/alm/custom-host-pipelines#using-force-link-to-associate-an-environment-with-a-new-host) + +## What this skill changes + +In the **target host** (the new host the user wants to use): +- Marks the existing `deploymentenvironments` record as the active stamp for the BAP environment. +- Re-runs validation; on success, `validationstatus` flips to `Succeeded` (200000001). + +In the **previous host** (the host the env was previously linked to): +- The corresponding `deploymentenvironments` row is **delinked**. Its `validationstatus` is left stale until refreshed in the previous host's UI. +- Makers who could run pipelines through that environment in the previous host **lose access** to those pipelines via this environment. + +The action is reversible by running Force Link again from the previous host. + +## Phase 1.5 — Microsoft Learn grounding (required) + +Before any Dataverse call, refresh the agent's grounding by fetching the doc above via `mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch`. If the doc has updated behaviors (e.g., new permission requirements, new warning text), surface them to the user before continuing. See `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` for the shared pattern. + +## Phases + +| # | Phase | Output | +|---|---|---| +| 1 | Prerequisites | Azure CLI token for the host environment; PAC CLI authenticated | +| 1.5 | MCP Learn grounding | Confirmed current behavior of Force Link / `ManageEnvironmentStamp` | +| 2 | Identify host + dev env | `hostEnvUrl`, target host's `deploymentEnvironmentId`, source BAP env GUID | +| 3 | Resolve `deploymentenvironments` record | Either an existing record on the new host, or a freshly created one | +| 4 | Confirm destructive action | Explicit user consent via `AskUserQuestion` | +| 5 | Execute Force Link | 204 from `ManageEnvironmentStamp` + post-validation Succeeded | +| 6 | Write marker + summary | `docs/alm/last-force-link.json` + human-readable summary | + +Create all tasks at Phase 1 start with `TaskCreate`. Mark each `in_progress` when starting and `completed` when done. + +--- + +## Phase 1 — Prerequisites + +Reuse the shared verifier: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" +``` + +Specifically required: +- **PAC CLI auth** — `pac env who` must report an authenticated environment (for `--dev-env` auto-discovery). +- **Azure CLI auth** — `az account show` succeeds. +- **Host-scoped token** — the caller must have Deployment Pipeline Administrator on the target host (the host the env is being linked TO). Without it, `ManageEnvironmentStamp` returns 403. + +Fetch the host token from Azure CLI using the host's Dataverse URL as the resource. Reuse `getAuthToken` from `scripts/lib/validation-helpers.js`. + +## Phase 1.5 — MCP Learn grounding + +Call: + +``` +mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch(url= + "https://learn.microsoft.com/en-us/power-platform/alm/custom-host-pipelines") +``` + +Confirm the *"Using Force Link…"* section's current warnings before proceeding. If the section now mentions new prerequisites or rollback constraints not covered in this skill, surface them to the user. + +## Phase 2 — Identify host + dev env + + + + +Resolution order for `hostEnvUrl`: +1. `--host ` argument, if supplied. +2. `docs/alm/last-host-check.json` (written by `ensure-pipelines-host`) — read `finalHostEnvUrl`. +3. `docs/alm/last-pipeline.json` — read `hostEnvUrl`. +4. Prompt user via `AskUserQuestion`. + +Resolution order for the source dev env's BAP env GUID: +1. `--dev-env ` argument, if supplied. +2. `pac env who` (current PAC CLI env) — but ONLY if the user confirms this is the env to relink. +3. Prompt user via `AskUserQuestion`. + +## Phase 3 — Resolve `deploymentenvironments` record on the new host + +**Goal of this phase:** obtain the `deploymentEnvironmentId` (the new host's record ID) regardless of whether it already exists, just got created, or got created in a Failed state. Force Link in Phase 5 cannot run without that GUID. + +### Step 3.1 — Look up by BAP env GUID + +```bash +GET {hostEnvUrl}/api/data/v9.1/deploymentenvironments?$filter=environmentid eq '{bapEnvId}'&$select=deploymentenvironmentid,name,environmenttype,validationstatus,errormessage +``` + +| Result | Action | +|---|---| +| One hit, `validationstatus = 200000001` (Succeeded) | Already linked to this host. Skip to Phase 6 with a no-op summary; no Force Link needed. | +| One hit, `validationstatus = 200000002` (Failed) | This is the *"already associated with another pipelines host"* state. Capture `deploymentenvironmentid` + `errormessage`. Skip to Phase 4 with those values. | +| One hit, `validationstatus = 200000000` (Pending) | Wait briefly (3–5 s) and re-query. If still Pending after ~20 s, abort with a "validation still in progress; retry later" message. | +| Zero hits | Continue to Step 3.2 — the record needs to be created first. | + +### Step 3.2 — Create the record on the new host (when Step 3.1 returned zero hits) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-deployment-environment.js" \ + --hostEnvUrl \ + --token \ + --name "" \ + --bapEnvId \ + --environmentType <200000000|200000001> +``` + +The helper polls `validationstatus` and **throws on Failed** without returning the new record's GUID in the error payload. Three outcomes to handle: + +| Helper outcome | Action | +|---|---| +| Resolves with `validationStatus = Succeeded` | Record is fully linked. Skip to Phase 6 — no Force Link needed. | +| Throws with message containing *"already associated with another pipelines host"* (or similar host-claim wording) | The record **was** created in Failed state but the helper's error doesn't surface the new GUID. **Re-run Step 3.1's GET to recover the just-created record's `deploymentenvironmentid`**, then proceed to Phase 4 with that ID + the captured errormessage. Do NOT retry the create — it would log a duplicate `name`. | +| Throws with any other message | Surface the error verbatim and abort. Force Link is not the right tool — this is a different failure (e.g., 403 on create = caller lacks role on host; 400 = bad `bapEnvId`). | + +**Why the re-query is necessary:** `create-deployment-environment.js` is idempotent on subsequent calls (it short-circuits via `findExistingByBapId`), but on the *first* call that lands in Failed validation it raises before the return path runs. Re-querying by `environmentid eq '{bapEnvId}'` is the canonical recovery — the same query Step 3.1 already uses. + +After this phase ends, you must hold a non-null `deploymentEnvironmentId`. If you don't, abort Phase 4 with a clear "could not resolve record on new host" message. + +## Phase 4 — Confirm destructive action + + +> 🚦 **Gate (consent · force-link-environment:4.destructive):** Mandatory consent before `ManageEnvironmentStamp` cross-host stamp move. Previous host loses pipeline access for this env. Reversible only by re-running Force Link from the previous host. **Fires fresh on every skill invocation.** Each invocation force-links exactly one env to one host. If a maker needs to migrate multiple envs across hosts, they invoke this skill once per env — each invocation requires its own consent prompt with its own env identity echoed back. No `--yes` flag, no batch mode, no consent carry-over. + +This is the **mandatory** gate. Use `AskUserQuestion` with both options and a clear destructive-action warning in the question text. Required fields to display before asking: + +- Target host (the new host) +- Source environment name + BAP env GUID +- The error message from the previous host's stamp (from Phase 3), if any +- Documented side effects: + - "Makers in the previous host lose pipeline access for this environment" + - "The previous host's environment record is left with a stale validation status" + - "Reversible by running Force Link from the previous host" + +Question structure: + +``` +question: "Force-link this environment to ? This will remove its association with the previous host." +options: + - "Yes — force link" (Recommended only if user is intentionally migrating) + - "Cancel" +``` + +If the user picks Cancel, exit cleanly (no marker file written) and recommend `/power-pages:ensure-pipelines-host detect-only` for further diagnosis. + +## Phase 5 — Execute Force Link + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/force-link-environment.js" \ + --hostEnvUrl \ + --token \ + --deploymentEnvironmentId +``` + +The helper: +1. Calls `ManageEnvironmentStamp` (returns 204 No Content on success). +2. Re-polls `validationstatus` on the same record every 3s up to 20 attempts. +3. Resolves on Succeeded (200000001), throws on Failed (200000002) with the captured `errormessage`. + +If the helper throws with status 403, the caller lacks Deployment Pipeline Administrator on the target host — surface that as the remediation message. + +If the helper throws with status 404, the `deploymentenvironments` record doesn't exist on the target host — Phase 3 must have failed silently; loop back. + +## Phase 6 — Write marker + summary + +Ensure the `docs/alm/` directory exists (`node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"`), then write `docs/alm/last-force-link.json`: + +```json +{ + "schemaVersion": 1, + "hostEnvUrl": "https://...", + "deploymentEnvironmentId": "...", + "bapEnvId": "...", + "previousHostEnvUrl": "https://...", + "validationStatus": 200000001, + "forcedAt": "2026-05-11T..." +} +``` + +`previousHostEnvUrl` is best-effort. Derive in this order; leave `null` if none of these yield a value: +1. **From `docs/alm/last-host-check.json`** (written by `ensure-pipelines-host`): if `finalHostEnvUrl` is set AND differs from the current `hostEnvUrl`, the discovery flow had already bound this env to that previous host — record it. +2. **From Phase 3's errormessage**: scan the captured `errormessage` for the pattern `https?://[^\s'"]+\.(crm\d*\.dynamics\.com|dynamics-int\.com|crm\.microsoftdynamics\.us)` and pick the first match that is **not** the current `hostEnvUrl`. Microsoft's error wording on the "already associated" path sometimes includes the prior host's URL, sometimes only its display name; treat the regex as opportunistic, not authoritative. +3. **Otherwise**: leave `null`. The marker schema permits this — validator does not require the field. + +Do NOT prompt the user to fill `previousHostEnvUrl`; it's informational only for the post-run summary. + +Present a summary table with: +- Environment force-linked +- Old host → new host +- Validation status +- Reminder: "You can undo this by running `/power-pages:force-link-environment` from the previous host." + +Record skill usage per `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md`. + +--- + +## Failure modes & remediation + +| Failure | Surface to user | Next step | +|---|---|---| +| `403 Forbidden` on `ManageEnvironmentStamp` | "You need Deployment Pipeline Administrator role on ." | Ask host admin to grant the role; documented in [share with pipeline administrators](https://learn.microsoft.com/en-us/power-platform/alm/custom-host-pipelines#share-with-pipeline-administrators). | +| `404 Not Found` on the deployment env record | "No deploymentenvironments record exists yet on this host." | Re-run Phase 3's create step. | +| Post-link validation status flips to Failed | Show the `errormessage` verbatim. | If the message mentions the env is still associated with a host, the previous host may have an immediate-reapply policy — check with the previous host's admin. | +| User cancels at Phase 4 | "Force Link not performed; previous association preserved." | Suggest `/power-pages:ensure-pipelines-host detect-only` for a wider diagnosis. | + +## What this skill does NOT do + +- It does not install the Pipelines application on the new host — use `/power-pages:ensure-pipelines-host` for that. +- It does not create the new host environment itself. +- It does not re-link pipeline definitions; only the env↔host stamp is moved. Pipelines that referenced this env in the previous host stay there and lose this env as a participant. +- It does not modify the user's solution. No `.solution-manifest.json` updates; no `AddSolutionComponent` calls. + +## Progress tracking + +| Phase | Status | +|---|---| +| 1 — Prerequisites | ⏳ | +| 1.5 — MCP Learn grounding | ⏳ | +| 2 — Identify host + dev env | ⏳ | +| 3 — Resolve deploymentenvironments record | ⏳ | +| 4 — Confirm destructive action | ⏳ | +| 5 — Execute Force Link | ⏳ | +| 6 — Write marker + summary | ⏳ | + +Update this table as phases complete. diff --git a/plugins/power-pages/skills/force-link-environment/scripts/validate-force-link.js b/plugins/power-pages/skills/force-link-environment/scripts/validate-force-link.js new file mode 100644 index 000000000..e2ad05d58 --- /dev/null +++ b/plugins/power-pages/skills/force-link-environment/scripts/validate-force-link.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +// Stop-hook validator for the force-link-environment skill. +// Reads docs/alm/last-force-link.json from the project and verifies the marker +// reflects a completed Force Link. Gracefully exits 0 when no marker exists +// (not a force-link session). +// +// Pass conditions (exit 0): +// - File missing. +// - schemaVersion is 1. +// - hostEnvUrl, deploymentEnvironmentId, validationStatus, forcedAt populated. +// - validationStatus === 200000001 (Succeeded). +// +// Block conditions (exit 2): +// - File present but missing required fields or unsupported schemaVersion. +// - validationStatus === 200000002 (Failed) — Force Link's post-link +// validation didn't succeed; surface to investigation rather than silently +// pass. + +'use strict'; + +const fs = require('fs'); +const { + approve, + block, + runValidation, + findProjectRoot, + readDeferralMarker, +} = require('../../../scripts/lib/validation-helpers'); +const { almPath } = require('../../../scripts/lib/alm-paths'); + +const VALIDATION_STATUS_SUCCEEDED = 200000001; +const VALIDATION_STATUS_FAILED = 200000002; + +runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd) || cwd; + if (readDeferralMarker(projectRoot)) return approve(); + + const markerPath = almPath(projectRoot, 'lastForceLink'); + if (!fs.existsSync(markerPath)) return approve(); // Not a force-link session. + + let marker; + try { + marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + return block('docs/alm/last-force-link.json exists but could not be parsed as JSON.'); + } + + if (marker.schemaVersion !== 1) { + return block(`docs/alm/last-force-link.json has unsupported schemaVersion: ${marker.schemaVersion}. Expected 1.`); + } + if (!marker.hostEnvUrl) { + return block('docs/alm/last-force-link.json is missing required field: hostEnvUrl'); + } + if (!marker.deploymentEnvironmentId) { + return block('docs/alm/last-force-link.json is missing required field: deploymentEnvironmentId'); + } + if (!marker.forcedAt) { + return block('docs/alm/last-force-link.json is missing required field: forcedAt'); + } + if (typeof marker.validationStatus !== 'number') { + return block('docs/alm/last-force-link.json is missing required field: validationStatus (number)'); + } + + if (marker.validationStatus === VALIDATION_STATUS_FAILED) { + return block( + `docs/alm/last-force-link.json reports validationStatus=Failed (${VALIDATION_STATUS_FAILED}). Re-run force-link-environment or investigate the host's environment record.`, + ); + } + + if (marker.validationStatus !== VALIDATION_STATUS_SUCCEEDED) { + return block( + `docs/alm/last-force-link.json has non-terminal validationStatus=${marker.validationStatus}. Expected Succeeded (${VALIDATION_STATUS_SUCCEEDED}).`, + ); + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/import-solution/SKILL.md b/plugins/power-pages/skills/import-solution/SKILL.md new file mode 100644 index 000000000..e4bd9eafd --- /dev/null +++ b/plugins/power-pages/skills/import-solution/SKILL.md @@ -0,0 +1,583 @@ +--- +name: import-solution +description: >- + Imports a Dataverse solution zip into a target environment, with optional staged import + for dependency checking before committing. Use when asked to: "import solution", + "install solution", "deploy solution zip", "push solution to environment", + "deploy to staging", "deploy to production", or "install site in new environment". +user-invocable: true +argument-hint: "Optional: path to solution zip file" +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# import-solution + +Imports a solution zip into a target Dataverse environment via `ImportSolutionAsync`. Supports optional staged import via `StageSolution` to check for missing dependencies before committing. + +## Prerequisites + +- PAC CLI installed and authenticated to the **target** environment +- Azure CLI installed and logged in +- Solution zip file exists on disk (produced by `export-solution`) + +## Phases + +### Phase 0 — ALM plan gate + +> **`plan-alm` is the front door.** When the user expresses an ALM intent (*promote / ship / deploy / set up CI-CD / move to staging / push to prod*), the orchestrator (`/power-pages:plan-alm`) should run first. This Phase 0 enforces that and is meant to fail closed when there's no plan, not to be a one-time check the user can dismiss forever. + +**Skip rule.** If this skill was invoked *as part of an active `plan-alm` orchestration*, skip Phase 0 entirely and proceed to Phase 1. The gate helper exposes this via its `inExecution` block — pass through silently to Phase 1 when: + +``` +inExecution.status === "active" +``` + +The helper computes this from `docs/.alm-plan-data.json` — `PLAN_STATUS === "In Execution"` AND `LAST_INVOCATION_AT` within the last 60 minutes. `check-alm-plan.js` refreshes `LAST_INVOCATION_AT` automatically on every invocation that finds the plan in execution, so each in-chain skill keeps the chain alive for the next one — even multi-hour deploys (deploy-pipeline alone can take 60 min per stage) survive the window without the chain incorrectly de-classifying. Stalled chains (no heartbeat for > 60 min) reclassify as `stale-heartbeat` and Phase 0 gates fire normally so an abandoned plan doesn't silently bypass user confirmation. + +When `inExecution.status` is anything other than `"active"` (`"not-running"`, `"stale-heartbeat"`, `"no-plan"`), run the Phase 0 gate flow below. Branch on the remaining helper fields: + +**Step 1 — Run the gate helper.** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "." +``` + +The helper returns JSON with `{ exists, deferred, stale, staleness: { reason, detail }, generatedAt, planStatus, ... }`. Pass `--envUrl`, `--token`, `--solutionId` once Phase 1 has acquired them if you also want a freshness check; otherwise the helper does an existence-only check, which is sufficient for the gate decision below. + +**Step 2 — Branch on the result.** + +| Result | Behavior | +|---|---| +| `deferred: true` | The user has explicitly deferred ALM for this project (`.alm-deferred` marker present). Pass through silently to Phase 1 — do not nag. | +| `exists: false` | The user hasn't run `plan-alm` yet. See Step 3. | +| `exists: true, stale: false` | Plan is current. Pass through silently to Phase 1. | +| `exists: true, stale: true` (reason: `solution-modified`) | The solution changed after the plan was generated. See Step 4. | + +**Step 3 — No plan.** Tell the user: + +> "No ALM plan exists for this project. `/power-pages:plan-alm` builds one — it detects the project state, asks about your promotion strategy (PP Pipelines vs Manual export/import), and orchestrates the right skills (including this one) in the right order. Want me to run plan-alm now?" + + +> 🚦 **Gate (intent · import-solution:0.no-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `exists:false`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Run `/power-pages:plan-alm` first? | ALM plan gate | Yes — run /power-pages:plan-alm now (Recommended), Continue without a plan (advanced — I know what I'm doing), Cancel | + +- **Yes (Recommended)** → invoke `/power-pages:plan-alm`. plan-alm's Phase 7 dispatches back into this skill at the appropriate stage. +- **Continue without a plan** → set `BYPASSED_PLAN_GATE = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Step 4 — Stale plan.** Tell the user: + +> "ALM plan exists from `{generatedAt}` but the source solution has been modified since (at `{solution.modifiedon}`). Components may have changed. Re-running `plan-alm` will refresh the analysis and the rendered HTML." + + +> 🚦 **Gate (intent · import-solution:0.stale-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `stale:true`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Refresh the plan first? | ALM plan freshness | Refresh — re-run /power-pages:plan-alm (Recommended), Continue with the existing plan, Cancel | + +- **Refresh (Recommended)** → invoke `/power-pages:plan-alm`. After completion, re-run the Phase 0 helper once to confirm freshness; if still stale, surface the detail and proceed to Phase 1 anyway (don't infinite-loop). +- **Continue** → set `STALE_PLAN_ACK = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Why this gate exists.** Direct invocation of `import-solution` deploys a zip into a target environment without the orchestrator's deployment-strategy selection or post-import validation steps. Users running this skill standalone often skip the staged-import dependency check, miss env var override values for the target environment, and have no plan-tracked record of which environment received which artifact version. The gate ensures `plan-alm` either ran (so the strategy was selected, per-stage values were captured in `deployment-settings.json`, and the deployment is reproducible) or the user explicitly chose to bypass it. + +### Phase 1 — Verify Prerequisites + +**Create all tasks upfront at the start of this phase.** + +Tasks to create: +1. "Verify prerequisites" +2. "Locate solution file" +3. "Configure import" +4. "Stage solution (dependency check)" +5. "Import solution" +6. "Verify import" +7. "Detect cloud flows" +8. "Present summary" + +> **Note**: If the import fails with an `AttachmentBlocked` error, a Phase 5b remediation flow runs inline — no additional task is needed (it continues within the "Import solution" task). The "Detect cloud flows" task is skipped automatically if no Workflows/*.json files are present in the solution zip. + +Steps: +1. Run `pac env who` — extract `environmentUrl` (verify this is the **target** environment) +2. Run `az account get-access-token --resource "{environmentUrl}" --query accessToken -o tsv` — capture token +3. Verify API access: `GET {environmentUrl}/api/data/v9.2/WhoAmI` +4. Present target environment URL and ask user to confirm this is correct before proceeding. + +> **Important**: Confirm the target environment with the user — importing to the wrong environment can be disruptive. + +If any check fails, stop (reference `${CLAUDE_PLUGIN_ROOT}/references/dataverse-prerequisites.md`). + +### Phase 1.5 — Ground in current ALM documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: `Power Pages solution import staging missing dependencies ImportSolutionAsync ALM`. +2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/solution-concepts-alm` (and at most one sister page on staged imports or dependency handling) in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what Microsoft Learn currently says about staging vs direct import, dependency resolution, and component-level error handling. Compare against `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` and flag any divergence in `ImportSolutionAsync` / `StageSolution` signatures. +4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 4 (the actual import). + +### Phase 2 — Locate Solution File + +1. If a zip path was provided as an argument, use it directly +2. Otherwise, search for solution zips: `glob('**/*.zip', { ignore: ['**/node_modules/**'] })` +3. For each found zip, verify it contains `solution.xml`: + - Use `Bash`: `unzip -l "{zipPath}" 2>/dev/null | grep -qi solution.xml` +4. If multiple valid zips found, ask user to choose: + + + > 🚦 **Gate (plan · import-solution:2.multiple-zips):** More than one valid solution zip was discovered under the project root. User picks which one to import. Cancel exits before any target-env interaction. + + Use `AskUserQuestion` with one option per valid zip — show filename + size + modified date so the user can identify the right one (most-recent export is usually the intended target). +5. If no valid zip found: stop and explain — run `export-solution` first or provide the zip path + +**Step 5a — Pre-import content inspection** (run after zip is confirmed, before presenting to user): + +Inspect the zip to surface post-import manual requirements. Run all checks in sequence: + +```bash +# 1. Connection references (require user-binding post-import) +unzip -p "{zipPath}" customizations.xml 2>/dev/null | grep -c '/dev/null | grep -qi "bots/" && echo "found" || echo "none" + +# 3. Cloud flows with hardcoded environment URLs (will silently fail in target) +unzip -p "{zipPath}" "Workflows/*.json" 2>/dev/null | grep -o '"organization":\s*"https://[^"]*"' | head -5 +``` + +Build a `postImportWarnings` list from the results: + +| Finding | Warning to surface | +|---|---| +| `connectionreference` count > 0 | "⚠️ **Connection references**: This solution includes N connection(s) (e.g. Dataverse connector). After import, you must bind each connection reference to a live connection in the target environment, or cloud flows that depend on them will be disabled." | +| `bots/` folder present | "⚠️ **Copilot Studio bot**: This solution includes a bot. After import, the bot must be republished in the target environment to complete provisioning." | +| Hardcoded org URL found | "⚠️ **Hardcoded environment URL**: The cloud flow contains a hardcoded environment URL (`{foundUrl}`). This will cause the flow to call the source environment after import. Edit the flow in the target environment to update the URL." | + +If `postImportWarnings` is non-empty, display all warnings inline (not via `AskUserQuestion`) before presenting the zip details. The user should see these before confirming. + +Present the selected zip file details (name, size, path), any pre-import warnings, and confirm with user. + +### Phase 3 — Configure Import + +**Step 3.0 — Version-skew advisory (read-only check before any prompt).** + +Before asking the user about staged/direct import, query the target environment for the solution unique name carried in the zip and compare the installed version against the zip's version. The goal is to surface "you're about to import the same version that's already installed" before the user clicks through — this is the most common silent-failure pattern for the manual export/import path, because the source-side bump is what produces an unambiguously promotable artifact. + +1. **Extract the zip's `uniqueName` + `version`** from `solution.xml` inside the zip: + ```bash + unzip -p "{zipPath}" solution.xml 2>/dev/null | head -50 + ``` + Parse `` and `` from the XML. Store as `ZIP_SOLUTION_NAME` and `ZIP_SOLUTION_VERSION`. + +2. **Query the target for the installed solution**: + ``` + GET {envUrl}/api/data/v9.2/solutions?$filter=uniquename eq '{ZIP_SOLUTION_NAME}'&$select=solutionid,uniquename,version,ismanaged + ``` + Store the result as `INSTALLED` (or `null` if the filter returns an empty `value` array). + +3. **Compare versions via the shared helper, then branch on the result.** + + **Precondition — skip this entire step when `INSTALLED` is `null`.** That's the first-time-install case: there's nothing on the target to compare against. Do NOT call the helper with `null` substituted into `'{INSTALLED.version}'` — it would throw "version is required" and leave the agent without a branch. Jump straight to the staged/direct prompt below and treat the import as a fresh install. + + Otherwise (`INSTALLED` is non-null), compare versions: + + Critical: **do not compare version strings with raw `>` / `<` / `===`** — Dataverse versions are 4-segment integer tuples (`1.0.0.9` vs `1.0.0.10`) and lexical comparison reports `1.0.0.10` as **lower than** `1.0.0.9`, flipping the skew gate on the 10th deploy of the day. Use the canonical helper instead: + + ```bash + node -e "console.log(require('${CLAUDE_PLUGIN_ROOT}/scripts/lib/bump-solution-version').compareVersions('{ZIP_SOLUTION_VERSION}', '{INSTALLED.version}'))" + ``` + + The helper returns `-1` when ZIP < INSTALLED, `0` when equal, `1` when ZIP > INSTALLED. Same segment-wise integer rules as `bumpPatchSegment` (pad-with-zero, max-4-segments, reject non-integer). Capture stdout, trim, and store the integer as `VERSION_CMP`. If the helper throws (malformed version on either side), surface the stderr to the user and stop — the version comparison is a precondition for safe import. + + | `INSTALLED` | `VERSION_CMP` | Behavior | + |---|---|---| + | `null` | (helper not called — see precondition above) | First-time install. Continue silently to the staged/direct prompt below. | + | not null | `1` (zip is strictly greater) | Normal upgrade. Report: *"Target has v{INSTALLED.version} installed; this zip is v{ZIP_SOLUTION_VERSION}. Importing will upgrade."* | + | not null | `0` (zip equals installed) | **Surface the warning below** (same-version skew — applies to both managed and unmanaged). | + | not null | `-1` (zip is strictly less) | **Surface the warning below** (downgrade — applies to both managed and unmanaged). | + + + > 🚦 **Gate (consent · import-solution:3.0.version-skew):** Zip version is equal-to or lower-than the installed solution's version on the target. Importing produces unpredictable upgrade semantics; the source `export-solution` is supposed to bump the version on every export. Re-export with bumped version, force the import anyway, or cancel. + + **Warning prompt** — `AskUserQuestion`: + + > "The target environment already has **`{ZIP_SOLUTION_NAME}` v`{INSTALLED.version}`** installed ({INSTALLED.ismanaged ? 'managed' : 'unmanaged'}). The zip you are about to import carries version `{ZIP_SOLUTION_VERSION}`. + > + > Importing the same or a lower version is unreliable: + > - **Managed**: no upgrade lineage; the platform may apply or reject the import depending on internal heuristics. + > - **Unmanaged**: behavior depends entirely on `OverwriteUnmanagedCustomizations: true`, and any in-target edits that happen to match the zip's component IDs get silently overwritten without a version change to point to. + > + > `/power-pages:export-solution` always bumps the source version before producing a zip (since 2026-05-25). If this zip was produced before that change, re-exporting will give you a clean, strictly-greater version. + > + > How would you like to proceed?" + > + > | Question | Header | Options | + > |---|---|---| + > | What to do? | Version skew | Re-export with a bumped version (Recommended — invokes /power-pages:export-solution), Import anyway (proceed at your own risk), Cancel | + + - **Re-export**: invoke `/power-pages:export-solution`. After it completes, restart this skill from Phase 2 with the freshly-produced zip. + - **Import anyway**: set `SKEW_ACK = true` and proceed to the staged/direct prompt below. Record the acknowledged skew in `docs/alm/last-import.json` under `versionSkew: { zipVersion, installedVersion, isManaged, acknowledged: true }` for the audit trail. + - **Cancel**: stop the skill cleanly. No target-env writes happened in Step 3.0 — it was read-only. + +4. **If the comparison is the normal upgrade case** (or the zip's solution isn't installed yet), continue silently — do not present a prompt, just record the version delta in the eventual `docs/alm/last-import.json` for the summary. + + +> 🚦 **Gate (plan · import-solution:3.config):** Staged vs Direct import, overwrite options. Cancel exits before any target-env mutation. + +Ask user (via `AskUserQuestion`): + +> **Key Decision Point**: **Staged vs Direct import** +> - **Staged (Recommended for managed solutions)**: Runs `StageSolution` first to check for missing dependencies. Shows issues before committing the import. Safer — the stage step is fully reversible. +> - **Direct**: Skips staging and imports immediately. Faster but may fail mid-import if dependencies are missing. + +Also ask: +- **Overwrite unmanaged customizations?** (default: Yes) — needed when target has customized the same components +- **Publish workflows after import?** (default: Yes) + +### Phase 4 — Stage Solution (Conditional) + +Only run this phase if the user chose staged import in Phase 3. + +Refer to `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` Section 5a. + +1. Base64-encode the zip file: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/encode-solution-file.js" --zipPath "{zipPath}" + ``` +2. `POST {envUrl}/api/data/v9.2/StageSolution` with `CustomizationFile: {base64}` +3. Parse `StageSolutionResults`: + - Extract `StageSolutionUploadId` (used in Phase 5 instead of re-encoding the file) + - Check `MissingDependencies` array +4. If `MissingDependencies` is non-empty: + - List each missing dependency with its type and name + - Ask user: "These dependencies are missing in the target environment. Proceed anyway (may fail) or cancel to install dependencies first?" + - If cancel: stop and advise installing missing dependencies +5. If `MissingDependencies` is empty: report "No missing dependencies found. Ready to import." + +### Phase 5 — Import Solution + +Refer to `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` Section 5b. + +1. Prepare request body (always use `CustomizationFile` — `ImportSolutionAsync` does not accept `StageSolutionUploadId`): + - Encode the zip: `node "${CLAUDE_PLUGIN_ROOT}/scripts/encode-solution-file.js" --zipPath "{zipPath}"` + - Use `{ CustomizationFile: "{base64}", OverwriteUnmanagedCustomizations: {choice}, PublishWorkflows: {choice} }` + +2. `POST {envUrl}/api/data/v9.2/ImportSolutionAsync` +3. Extract `AsyncOperationId` and `ImportJobKey` (note: field is `ImportJobKey`, not `ImportJobId`) +4. Report: "Import job started: `{AsyncOperationId}`. Polling for completion..." + +Run `scripts/poll-async-operation.js`: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/poll-async-operation.js" \ + --asyncJobId "{AsyncOperationId}" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --intervalMs 8000 \ + --maxAttempts 75 +``` + +Handle poll result: +- `Succeeded`: proceed to Phase 6 +- `Failed` with `AttachmentBlocked` / error code `-2147188706`: proceed to **Phase 5b** below +- `Failed` (other): show error message, query import job for component-level errors, stop +- `Timeout`: inform user, advise checking admin center + +### Phase 5b — Resolve Attachment Restrictions (conditional) + +Only run this phase if Phase 5 poll failed with `AttachmentBlocked` (`-2147188706` or message contains `AttachmentBlocked` or `not a valid type`). + +#### 5b.1 Identify Blocked Extensions in the Solution Zip + +List all files in the zip and extract unique extensions: +```bash +unzip -l "{zipPath}" | awk '{print $4}' | grep '\.' | sed 's/.*\.//' | sort -u +``` + +Get the current blocked attachments list from the environment: +```bash +pac env list-settings +``` + +Find the `blockedattachments` row in the output — it contains a semicolon-separated list (e.g., `ade;adp;js;zip;...`). + +Compute the **intersection**: which extensions from the solution zip appear in the blocked list. These are the types that need to be unblocked. + +#### 5b.2 Explain the Issue + +Tell the user: +> "The solution import failed because the target environment blocks certain file types that are included in this solution. The following file extensions in the solution are currently blocked: **`{comma-separated list}`**. This is an environment-level security setting. To import this solution, these restrictions need to be temporarily relaxed." + +#### 5b.3 Ask for Permission + + +> 🚦 **Gate (consent · import-solution:5b.blocked-attachments):** Reactive `AttachmentBlocked` remediation — modify env-level `blockedattachments` setting (tenant-wide impact). Reversible from PPAC. **Fires fresh on every skill invocation that hits the failure.** When `plan-alm` Manual path orchestrates multi-target imports (Staging then Production), it invokes `import-solution` **once per target** — if both targets block the same extensions, the gate fires once per target (each is a separate skill invocation against a separate env). Consent for Staging does NOT cover Production. + +Invoke `AskUserQuestion` immediately — do NOT present this as a chat message. The user must answer live before the skill proceeds. + +| Question | Header | Options | +|---|---|---| +| The solution contains file types (`{list}`) that are blocked by this environment's attachment security settings. Would you like to remove the block for these specific types so the solution can be imported? | Unblock Attachment Types | Yes, unblock `{list}` for this import (Recommended), No, do not change environment settings | + +**If "No"**: Stop and tell the user: "The import cannot proceed while these file types are blocked. To unblock manually: Power Platform Admin Center → Environments → {env} → Settings → Product → Features → Blocked Attachments." + +**If "Yes"**: Proceed to 5b.4. + +#### 5b.4 Update Blocked Attachments + +1. Parse the `blockedattachments` value (semicolon-separated) +2. Remove **only** the extensions identified in 5b.1 — preserve all others +3. Update the setting: + ```bash + pac env update-settings --name blockedattachments --value "{updated-list-with-types-removed}" + ``` +4. Confirm the update succeeded. + +#### 5b.5 Retry Import + +Re-encode the zip and retry `ImportSolutionAsync` (repeat Phase 5 steps 1–4 and poll again). + +- If `Succeeded`: proceed to Phase 6 +- If failed again with a different error: show the new error message and stop — do not retry further + +### Phase 6 — Verify Import + +1. Query solution to confirm it exists and version matches: + ``` + GET {envUrl}/api/data/v9.2/solutions?$filter=uniquename eq '{solutionName}'&$select=solutionid,uniquename,version,ismanaged + ``` + +2. Query import job for component results (use `ImportJobKey` from the import response): + ``` + GET {envUrl}/api/data/v9.2/importjobs({ImportJobKey})?$select=solutionname,completedon,progress,data + ``` + - Parse the `data` XML field for per-component results (look for `result="failure"` entries) + - Count: imported successfully / warnings / failures + +3. Ensure `docs/alm/` exists, then write `docs/alm/last-import.json` marker (`node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"`): + ```json + { + "importedAt": "", + "solutionName": "", + "version": "", + "targetEnvironment": "", + "asyncOperationId": "", + "importJobId": "", + "status": "", + "componentResults": { "success": N, "warning": N, "failure": N }, + "versionSkew": null + } + ``` + + The `status` field drives `refresh-alm-plan-data.js`'s step-sync (`completed` vs `failed`) for the rendered ALM plan's per-stage checklist. Set it to: + - `Succeeded` when the import job's `statecode` is 3 (Succeeded) AND component-results show `failure === 0`. + - `Partial` when `statecode` is 3 but `componentResults.failure > 0` (the solution landed but some components didn't import — usually managed-property conflicts or dependency gaps). + - `Failed` when `statecode` is 4 (Failed) OR the import never reached terminal state OR all components failed. Without this field, every import shows `completed` in the rendered plan regardless of actual outcome. + + **If the user acknowledged a same-version or downgrade import in Step 3.0** (`SKEW_ACK = true`), set `versionSkew` to: + ```json + { "zipVersion": "", "installedVersion": "", "isManaged": , "acknowledged": true } + ``` + Otherwise leave `versionSkew: null`. + +### Phase 6b — Set Environment Variable Values (if any) + +After a successful import, env var **definitions** travel in the solution but their **values do not**. The target environment will have the definition records but blank values until explicitly set. + +Query the target environment for any env var definitions from this solution: +``` +GET {envUrl}/api/data/v9.2/environmentvariabledefinitions?$filter=introducedversion ne null&$select=schemaname,displayname,type,defaultvalue,environmentvariabledefinitionid +``` + +Filter to only those whose `schemaname` starts with the publisher prefix (from `.solution-manifest.json`), or cross-reference with `solutioncomponents` if available. + +For each definition found, check if a value already exists in the target: +``` +GET {envUrl}/api/data/v9.2/environmentvariablevalues?$filter=_environmentvariabledefinitionid_value eq '{id}'&$select=value +``` + + +> 🚦 **Gate (plan · import-solution:6b.env-vars):** Imported env var definitions need per-target values. User supplies values or skips (uses default). Without values, runtime reads default which may be dev-only. + +**If any definitions have no existing value**, present them to the user via `AskUserQuestion`: + +> "The imported solution contains **{N} environment variable(s)** with no value set in this environment. Enter the target value for each (leave blank to skip and use the default): +> +> 1. `{schemaname}` ({displayname}) — default: `{defaultvalue ?? 'none'}` +> 2. ..." + +For each value the user provides, POST an `environmentvariablevalue` record: +``` +POST {envUrl}/api/data/v9.2/environmentvariablevalues +{ + "schemaname": "{schemaname}", + "value": "{userValue}", + "EnvironmentVariableDefinitionId@odata.bind": "/environmentvariabledefinitions({id})" +} +``` + +> **Note on Secret type (type 100000005):** Secret values are stored encrypted. The POST behaves the same but the value will be masked in the UI. The user should provide the actual secret value for the target environment (e.g. the OAuth client secret for the production tenant's app registration — different from the dev value). + +If the user skips all values: inform them the site may not function correctly until values are set, and provide the direct Power Platform URL to set them manually: +`https://{targetEnvHost}/main.aspx?appid=...&etn=environmentvariabledefinition` + +**6b.verify — Confirm values landed.** After the per-variable POSTs complete, verify each `environmentvariablevalues` record actually exists on the target. The shared helper `scripts/lib/verify-env-var-values.js` does this read-only check and returns a structured JSON result per schema (`landed` / `missing-value-record` / `missing-definition` / `value-mismatch` / `query-error`): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-env-var-values.js" \ + --envUrl "{targetEnvUrl}" \ + --schemaNames "{comma-separated schema names that the user supplied values for}" +``` + +Capture stdout as JSON. If `summary.missing > 0` or `summary.error > 0`, surface a single warning to the user with the affected schema names — but do not block the import summary, the import itself succeeded. The same helper is used at deploy time (deploy-pipeline Phase 7.6.5) and at configure time (configure-env-variables Phase 7); centralizing the check keeps the user-visible message consistent across skills. + +### Phase 6c — Detect Cloud Flows (if any) + +After import succeeds, check whether the solution contains cloud flow JSON files. Use the zip path located in Phase 2. + +```bash +unzip -l "{zipPath}" | grep -i "^.*Workflows/.*\.json" +``` + +If no matching files are found, skip this phase entirely. + +If cloud flow files are found: +1. Extract the flow name from each path (pattern: `Workflows/FlowName-GUID.json` — strip the path prefix and GUID suffix to get the display name) +2. Inform the user: + + > "This solution contains **{N} cloud flow(s)**. Cloud flows must be registered with the Power Pages site in the target environment after import." + > + > Flows detected: + > - `{FlowName1}` + > - `{FlowName2}` + > ... + > + > To register: **Power Pages Management** → target environment → Edit site → **Set up** → **Cloud flows** → register each flow listed above. + > + > Direct link: `https://make.powerpages.microsoft.com/` + + + > 🚦 **Gate (plan · import-solution:6c.cloud-flow-register):** Cloud flows in imported solution need manual registration in target env. Acknowledge / defer. + +3. Invoke `AskUserQuestion`: + + | Question | Header | Options | + |---|---|---| + | Have you registered the cloud flow(s) listed above with the Power Pages site in `{targetEnvUrl}`? | Cloud Flow Registration | Flows registered — continue, I'll register them later | + +4. Record the user's response as `cloudFlowStatus`: `"Registered"` or `"Pending registration"`. This status is shown in the Phase 7 summary row — either answer allows the skill to continue. + +### Phase 6d — Check Site Activation (if Power Pages solution) + +Only run this phase if the solution contains Power Pages website components (componentType `10374`): + +``` +GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq '{solutionId}' and componenttype eq 10374&$select=objectid +``` + +If no componentType 10374 records found, skip this phase entirely. + +If found, run the shared activation status check (PAC CLI is already authenticated to the target environment): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/check-activation-status.js" --projectRoot "." +``` + +Evaluate the result: + +- **`activated: true`**: Store `siteUrl` for the Phase 7 summary. No further action needed. + + +> 🚦 **Gate (plan · import-solution:6d.activate):** Site imported but not activated in target env. Offer to invoke activate-site now or defer. + +- **`activated: false`**: Ask the user via `AskUserQuestion`: + + | Question | Header | Options | + |---|---|---| + | The Power Pages site was imported but is not yet activated (provisioned) in `{envUrl}`. Activate it now to make it publicly accessible. | Activate Site | Yes, activate now (Recommended), No, I'll activate later | + + - **If "Yes"**: Invoke `/power-pages:activate-site`. The activate-site skill will handle subdomain selection, confirmation, and provisioning. + - **If "No"**: Note in the Phase 7 summary that activation is pending and remind the user to run `/power-pages:activate-site` when ready. + +- **`error` present**: Skip silently — do not block the summary. Note in Phase 7 that activation status could not be determined. + +### Phase 7 — Present Summary + +Display a summary table: + +| Item | Value | +|---|---| +| Solution | `{solutionName}` v`{version}` | +| Target environment | `{envUrl}` | +| Managed | Yes / No | +| Components imported | N success, N warning, N failure | +| Env var values set | N of N | +| Cloud flows | `{cloudFlowStatus}` (Registered / Pending registration / Not applicable) | +| Site activation | Activated at `{siteUrl}` / Pending / Not applicable | +| Import job | `{importJobId}` | + +### Record Skill Usage + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "ImportSolution"`. + +### Refresh the ALM plan (if one exists) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase import-solution \ + --stageName "{targetLabel}" \ + --render +``` + +`{targetLabel}` is the Manual-path target stage (e.g. `Staging`, `Production`) the just-completed import was for — usually carried in by the orchestrator (plan-alm Phase 7) or derivable from the target env URL. The helper reads `docs/alm/last-import.json`, captures the import outcome (status, version, component count, component failures) into `planData.manualImports[targetLabel]`, and re-renders `docs/alm-plan.html` so the matching `Import to {targetLabel}` checklist step shows an `IMPORTED` / `FAILED` badge with version + component count inline. Subsequent imports to OTHER targets each get their own entry — the renderer surfaces a per-target history rather than overwriting on each call. + +If `--stageName` is omitted the helper falls back to matching `docs/alm/last-import.json`'s `targetEnvironment` URL against `planData.stages[].envUrl`. When the match fails (rare — usually a stage-label/env-URL mismatch in planData), the import is captured under a synthetic key so it isn't silently lost; pass `--stageName` explicitly to keep the rendered plan clean. When `docs/.alm-plan-data.json` is absent, the helper returns `ok:false` as a soft no-op. + +## Key Decision Points (Wait for User) + +1. **Phase 1**: Confirm target environment — import is not easily undoable for managed solutions +2. **Phase 2**: Select zip file if multiple found +3. **Phase 3 Step 3.0**: **Version-skew advisory** — only fires when the target already has the solution installed AND the zip's version is `≤` the installed version. Offers re-export with bumped version (Recommended), import-anyway (acknowledged in `last-import.json`), or cancel. Read-only check; cancelling here makes no target-env writes. +4. **Phase 3**: Staged vs direct import; overwrite customizations +5. **Phase 4**: Proceed despite missing dependencies +6. **Phase 5b**: Consent to unblock attachment types — never modify environment settings without explicit approval +7. **Phase 6b**: Env var values — always prompted if solution contains env var definitions with no existing value in the target; Secret type definitions require the user's target-environment-specific secret value +8. **Phase 6c**: Cloud flow registration — non-blocking; user may register later; status recorded in summary +9. **Phase 6d**: Site activation — only if Power Pages website components present and site not yet activated + +## Error Handling + +- Component-level import failures: report in summary, do not block overall completion +- If import async operation fails with `AttachmentBlocked` (-2147188706): run Phase 5b remediation flow (identify blocked types, get consent, unblock, retry) +- If import async operation fails with other error: show `friendlyMessage` from async operation record, stop +- Never attempt rollback — report what succeeded and what failed +- Never modify environment settings (`blockedattachments`) without explicit user approval + +## Progress Tracking Table + +| Task subject | activeForm | Description | +|---|---|---| +| Verify prerequisites | Verifying prerequisites | Confirm PAC CLI auth, acquire token, verify target environment with user | +| Locate solution file | Locating solution file | Find and validate solution zip, confirm Solution.xml present | +| Configure import | Configuring import | Step 3.0: extract zip uniqueName+version from solution.xml, query target's installed version, surface a version-skew advisory if zip version ≤ installed (offer re-export / import-anyway / cancel); then ask: staged vs direct, overwrite customizations, publish workflows | +| Stage solution (dependency check) | Staging solution | Run StageSolution to check for missing dependencies before committing | +| Import solution | Importing solution | POST ImportSolutionAsync, poll until complete; if AttachmentBlocked: identify blocked types, get user consent, unblock via pac env update-settings, retry | +| Verify import | Verifying import | Confirm solution version in target, parse component results, write docs/alm/last-import.json | +| Detect cloud flows | Detecting cloud flows | List Workflows/*.json entries in zip; if found, prompt user to register flows with Power Pages site; record status (Registered / Pending registration) | +| Check site activation | Checking site activation | If solution has componentType 10374: run check-activation-status.js; if not activated, ask user and invoke /power-pages:activate-site | +| Present summary | Presenting summary | Show component counts (success/warning/failure), cloud flow registration status, site activation status, env var values set | diff --git a/plugins/power-pages/skills/import-solution/scripts/validate-import.js b/plugins/power-pages/skills/import-solution/scripts/validate-import.js new file mode 100644 index 000000000..898adfde5 --- /dev/null +++ b/plugins/power-pages/skills/import-solution/scripts/validate-import.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +// Validates that import-solution completed: checks for docs/alm/last-import.json marker. +// Verifies the import completed without failures. +// Gracefully exits 0 when no import marker is found (not an import-solution session). + +const fs = require('fs'); +const { approve, block, runValidation, findProjectRoot, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); +const { almPath } = require('../../../scripts/lib/alm-paths'); + +runValidation(async (cwd) => { + if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. + const projectRoot = findProjectRoot(cwd) || cwd; + const markerPath = almPath(projectRoot, 'lastImport'); + + // No import marker — not an import-solution session + if (!fs.existsSync(markerPath)) return approve(); + + let marker; + try { + marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + return block('docs/alm/last-import.json exists but could not be parsed. The import-solution skill may have failed to write the marker.'); + } + + // Check required fields + if (!marker.solutionName) { + return block('docs/alm/last-import.json is missing solutionName. The import may not have completed.'); + } + if (!marker.targetEnvironment) { + return block('docs/alm/last-import.json is missing targetEnvironment. The import may not have completed.'); + } + if (!marker.importedAt) { + return block('docs/alm/last-import.json is missing importedAt timestamp. The import may not have completed.'); + } + + // Check for component failures + if (marker.componentResults) { + const { failure = 0, success = 0 } = marker.componentResults; + if (failure > 0 && success === 0) { + return block(`Solution import for '${marker.solutionName}' had ${failure} component failure(s) and 0 successes. The import did not complete successfully.`); + } + // Partial failures are warnings, not blocks — the import may still be usable + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/plan-alm/SKILL.md b/plugins/power-pages/skills/plan-alm/SKILL.md new file mode 100644 index 000000000..d75ee8431 --- /dev/null +++ b/plugins/power-pages/skills/plan-alm/SKILL.md @@ -0,0 +1,1347 @@ +--- +name: plan-alm +description: >- + Creates an ALM (Application Lifecycle Management) plan for deploying a Power Pages + site across environments. Gathers your promotion strategy, target environments, and + approval requirements upfront, generates a visual HTML plan document for review, then + — after your approval — executes the plan by calling setup-solution, setup-pipeline, + export-solution, and deploy-pipeline (or import-solution) in sequence. + Use when asked to: "plan my alm", "set up alm", "create deployment plan", + "plan my deployments", "help me deploy to multiple environments", + "set up promotion strategy", "create cicd plan", "plan site promotion", + "help me go to production", "set up pipeline for my site". +user-invocable: true +argument-hint: "Optional: 'pipelines' or 'manual' to skip strategy selection" +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# plan-alm + +An 8-phase orchestrator that gathers ALM strategy from the user, generates an HTML deployment plan, gets approval, then executes the plan by calling existing skills in sequence. + +## Overview + +This skill detects the current project state (existing solution, pipeline), asks targeted questions about the desired promotion strategy (Power Platform Pipelines or Manual export/import), generates a visual `docs/alm-plan.html`, gets user approval, and then invokes `setup-solution`, `setup-pipeline` (or `export-solution`), and `deploy-pipeline` (or `import-solution`) in the correct order. + +**Do NOT create tasks at the start** — strategy is unknown until Phase 2 completes. Create all tasks in Phase 3 once the strategy is determined. + +--- + +## Phase 1 — Detect Project State + +**Do NOT create tasks yet.** Use natural language progress reporting only during this phase. + +Steps: + +0. **Detect prior ALM deferral for this project.** Before any discovery work, check whether the project root contains a `.alm-deferred` marker file. The marker is written by users who explicitly opted ALM-skill validators out of "missing artifacts" warnings (e.g. *"this site is handled separately"* or *"ni-dev — no ALM"*). If a user is now invoking `plan-alm`, we should surface that the marker is present and ask what to do, rather than silently proceeding (which would build a plan the user previously decided not to maintain) or silently removing the marker (which would re-enable nags on every other ALM skill). + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "." + ``` + + + > 🚦 **Gate (progress · plan-alm:1.deferral):** `.alm-deferred` marker present — continue and remove, continue and keep marker, or cancel. Determines whether downstream ALM skills resume gate enforcement. + + The helper returns `{ deferred, deferral, ... }`. If `deferred === true`, read the deferral reason (`deferral.reason` or the raw marker text) and ask via `AskUserQuestion`: + + > "This project has an `.alm-deferred` marker — `{reason}`. ALM was previously deferred here, so the other ALM skills (`setup-solution`, `setup-pipeline`, `deploy-pipeline`, …) skip their plan-completeness checks for this project. How would you like to proceed?" + + | Question | Header | Options | + |---|---|---| + | How would you like to proceed? | ALM deferral marker | Continue planning and remove the marker (Recommended), Continue planning but keep the marker (record deferral context in plan), Cancel | + + - **Continue and remove marker (Recommended)** → delete `.alm-deferred` (the user is re-engaging with ALM). Set `DEFERRAL_CLEARED = true` and proceed to step 1. + - **Continue and keep marker** → set `DEFERRAL_PRESERVED = true` and `DEFERRAL_REASON = {reason}`. Proceed to step 1. Surface a one-line note in the Phase 1 step 9 user report (e.g. *"Note: `.alm-deferred` is preserved — other ALM skills will continue to skip plan-completeness checks for this project."*) so the user remembers the marker remains in effect after planning. + - **Cancel** → exit cleanly (don't touch the marker). + + If `deferred === false`, skip this step silently and proceed to step 1. + +1. **Resolve the site identity from the local project.** ALM skills are normally invoked from a site-root directory where `pac pages download-code-site` (or a create-site scaffold followed by a deploy) has written `.powerpages-site/website.yml`. That YAML file is the source of truth for `websiteRecordId` and `siteName`. + + **Resolution order** (first match wins): + 1. **`.powerpages-site/website.yml`** (preferred, present for every deployed site) — read with the `Read` tool and extract: + - `id` field → `websiteRecordId` + - `name` field → `siteName` (the file uses short keys; it is `name:`, not `adx_name:`) + 2. **`powerpages.config.json`** (fallback, used during plugin development from this repo root or for sites scaffolded but not yet deployed) — read `siteName` and `websiteRecordId`. + + If neither is found, stop with: + > "No Power Pages site found in the current directory. Run this skill from your site project root (where `.powerpages-site/` exists after `pac pages download-code-site`). If you haven't created the site yet, run `/power-pages:create-site` first." + + `environmentUrl` is always re-confirmed from `pac env who` in step 4 — it does not need to come from either source. + +2. Check for `.solution-manifest.json` in the project root: + - Store `SOLUTION_DONE = true` if found, `false` otherwise + - If found, read `solution.uniqueName` and store as `SOLUTION_UNIQUE_NAME` + +3. Check for `docs/alm/last-pipeline.json` in the project root: + - Store `PIPELINE_DONE = true` if found, `false` otherwise + - If found, read `pipelineName` and `stages[]` for later use + +4. Run silently: + ```bash + pac env who + ``` + Capture the `Environment URL` and display name. Store as `DEV_ENV_URL` and `DEV_ENV_NAME`. + +5. Run silently: + ```bash + pac env list --output json 2>/dev/null + ``` + Store output as `ENV_LIST` for pre-filling environment URLs in Phase 2. + +6. Acquire dev environment token (silently): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{DEV_ENV_URL}" + ``` + Store `.token` as `DEV_TOKEN` and `.userId` as `userId`. If this fails (auth error), set `DEV_TOKEN = null` and continue — contents discovery will be skipped gracefully. + +7. Discover and classify site settings (if `DEV_TOKEN` is available and `websiteRecordId` is known): + + Use Node.js `https` module to query. **Paginate via `@odata.nextLink`** — sites with > 500 settings would otherwise silently truncate, dropping tier classifications and underreporting `plannedEnvVarCount`. Send `Prefer: odata.maxpagesize=5000` so Dataverse emits the continuation link, then loop until exhausted: + ``` + GET {DEV_ENV_URL}/api/data/v9.2/mspp_sitesettings?$filter=_mspp_websiteid_value eq '{websiteRecordId}'&$select=mspp_name,mspp_value&$top=5000 + Authorization: Bearer {DEV_TOKEN} + Prefer: odata.maxpagesize=5000 + OData-MaxVersion: 4.0 + OData-Version: 4.0 + Accept: application/json + ``` + On each response, append `value[]` to the running array. If `@odata.nextLink` is present, GET that URL with the same headers (no need to re-add the filter — the nextLink already encodes the query). Stop when the response has no `@odata.nextLink`. Cap at 100 iterations for safety. + + Classify the returned settings using `${CLAUDE_PLUGIN_ROOT}/scripts/lib/classify-site-settings.js` — the single source of truth for the credential regex and tier mapping shared with `setup-solution` Phase 5. Either pipe the JSON array of `{name, value}` rows into the script's stdin (CLI mode) or `require()` it inline: + + ```bash + echo '' \ + | node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/classify-site-settings.js" + ``` + + Output (the four-bucket shape that downstream phases + `setup-solution` consume directly): + + ```js + SITE_SETTINGS_DATA = { + keepAsIs: [{name}], // regular settings (Tier 3 — Search/Bootstrap/WebApi/feature flags) + authNoValue: [{name}], // Authentication/* or AzureAD/* with empty value (Tier 2b — added as-is, set in target env) + promoteToEnvVar: [{name, value}], // Authentication/* or AzureAD/* with value (Tier 2a — setup-solution offers env-var promotion) + credentialNeedsDecision: [{name, value}] // ConsumerKey/ConsumerSecret/ClientId/ClientSecret/AppSecret/AppKey/ApiKey/Password (Tier 1 — bulk-with-override prompt in setup-solution Phase 5.4.C) + } + ``` + + Tier semantics in plain English (so reviewers reading the plan know what each bucket implies): + - **Tier 1 (`credentialNeedsDecision`)** — credential-style names. Setup-solution Phase 5.4.C runs a single bulk prompt: auto-classify by name (Secret-typed env var for `*Secret`/`*Password`/`*ApiKey`/`*AppKey`; String-typed for `*Id`/`*ConsumerKey`), all-as-Secret, all-as-String, skip-all, or pick-per-credential. + - **Tier 2a (`promoteToEnvVar`)** — auth config with a dev value. Setup-solution Phase 5.4.A asks which to back with env vars so each stage can use different values. + - **Tier 2b (`authNoValue`)** — auth config with no dev value yet. Added to the solution as-is; user sets the value in each target env after deployment. + - **Tier 3 (`keepAsIs`)** — everything else. Added unchanged. + + If the OData query fails or the helper errors out, set `SITE_SETTINGS_DATA = null` and continue — the plan still renders, it just can't break down site settings by tier. + +8. Build `SOLUTION_CONTENTS_DATA`: + ```js + { + tables: solutionManifest?.components?.tables || [], // from .solution-manifest.json if SOLUTION_DONE + botComponents: solutionManifest?.botComponents || [], // from manifest if available + siteSettings: SITE_SETTINGS_DATA // from step 7, or null + } + ``` + If `SOLUTION_DONE = false` and manifest is absent, `tables` and `botComponents` will be empty arrays — the plan will show a note that they will be discovered during setup-solution. + +9. Report to user: + ``` + Found: **{siteName}** on `{devEnvUrl}`. + Solution: {✓ already set up ({solutionUniqueName}) / ✗ not yet}. + Pipeline: {✓ already set up ({pipelineName}) / ✗ not yet}. + Site settings: {N total — K regular (keep as-is), P auth settings to review for env var, A auth settings (no dev value), C credential-style settings (setup-solution will prompt per credential) / unable to query}. + ``` + +10. **Estimate solution size and evaluate the split decision tree.** First ensure the ALM artifacts directory exists (all `.alm-*` and `last-*` artifacts live under `docs/alm/` to keep the project root uncluttered): + ```bash + node -e "require('fs').mkdirSync('docs/alm',{recursive:true})" + ``` + Run the estimate helper to classify the site across size, component count, schema heaviness, web file aggregate, and env var count. Use the tmp-file write pattern — if the estimator fails, a prior good `docs/alm/alm-size-estimate.json` is preserved instead of being overwritten with an empty/partial file. When `SOLUTION_DONE = true` (a `.solution-manifest.json` exists), pass `--solutionId {solutionId}` so the env var count is scoped to the target solution — without it, the estimator falls back to a publisher-prefix tenant-wide query and overcounts whenever the prefix is shared across projects (the common `new_` / `cr5fe_` regression): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/estimate-solution-size.js" \ + --envUrl "{DEV_ENV_URL}" --websiteRecordId "{websiteRecordId}" \ + --publisherPrefix "{publisherPrefix}" --siteName "{siteName}" \ + {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} \ + --projectRoot "." \ + --datamodelManifest "./.datamodel-manifest.json" > ./docs/alm/alm-size-estimate.json.tmp \ + && mv ./docs/alm/alm-size-estimate.json.tmp ./docs/alm/alm-size-estimate.json + ``` + When `SOLUTION_DONE = false`, omit `--solutionId`; the estimator's output will include `envVarCountScope: "publisher-prefix"` to signal the wider scope, and the renderer surfaces this caveat in the Env Variables tab so reviewers know the number reflects the tenant view, not a specific solution. `--projectRoot "."` enables the disk cross-check — the estimator walks the local build output (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces `webFilesDiskMeasuredMB`. When that number is much larger than the Dataverse-measured `webFilesAggregateMB`, the estimator flips `truncationSuspected: true` with a warning — file-typed columns whose bytes aren't returned by `$select=content` are the usual cause and the plan should trust the disk number. + Then run the decision tree (same tmp-file pattern): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/compute-split-plan.js" \ + --estimate ./docs/alm/alm-size-estimate.json \ + --projectRoot "." \ + --siteName "{siteName}" \ + --publisherPrefix "{publisherPrefix}" > ./docs/alm/alm-split-plan.json.tmp \ + && mv ./docs/alm/alm-split-plan.json.tmp ./docs/alm/alm-split-plan.json + ``` + If either command exits non-zero, stop and report the stderr message to the user. Do not proceed to Q1b in Phase 2 without a valid split plan. + Store the output as `SPLIT_PLAN`. Fields to read: `splitStrategy`, `proposedSolutions[]`, `appliedStrategies[]`, `assetAdvisory`, `sizeAnalysis`, `recommendations[]`. + + If `SPLIT_PLAN.proposedSolutions.length > 1`, set `RECOMMEND_SPLIT = true`. Otherwise `false`. + + Report to the user: + ``` + Estimated size: {totalSizeMB} MB — components: {count} — tier: {overall tier}. + Decision tree result: {splitStrategy} → {N} solutions recommended. + Asset advisory: {K} files flagged for Azure Blob externalization. + ``` + +10b. **Enumerate environment variable definitions** (runs whenever `DEV_TOKEN` is available — the size estimator gives a count but not per-variable metadata). + + The renderer's Env Variables tab needs schema name, type, default value, and bound site setting per definition. Without this step, the tab can only show a count-summary note while the size signal and the warning quote a number — three views that don't fully agree. Running this query produces the row-level data so the table renders properly. + + Pass `--solutionId` when `SOLUTION_DONE = true` so the returned envVars[] is scoped to the target solution. The helper paginates correctly (Prefer: odata.maxpagesize + @odata.nextLink) regardless of scope — the difference is just which env vars are returned. + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-env-var-definitions.js" \ + --envUrl "{DEV_ENV_URL}" --token "{DEV_TOKEN}" \ + --publisherPrefix "{publisherPrefix}" \ + --websiteRecordId "{websiteRecordId}" \ + {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} > ./docs/alm/alm-env-vars.json.tmp \ + && mv ./docs/alm/alm-env-vars.json.tmp ./docs/alm/alm-env-vars.json + ``` + + Read the JSON: `{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count, scope }`. The `scope` field is `'solution'` when `--solutionId` was passed and the solution had env var defs, `'publisher-prefix'` otherwise, `'none'` when the helper short-circuited (no prefix or auth lapse). Store `envVars` as `ENV_VARS_DETAILS` and `scope` as `ENV_VARS_SCOPE` for use when building `planData.envVars` in Phase 3 (pass through unchanged). + + The helper degrades gracefully (returns `{ envVars: [], count: 0, scope: 'none' }`) when the publisher prefix is unknown, the token has expired, or the query errors. In those cases the renderer falls back to the size estimator's count via `sizeAnalysis.envVarCount.value` (commit `8cbc39a`) — `ENV_VARS_DETAILS = []` is acceptable. + + > **Why scoping matters here**: without `--solutionId`, the helper filters env var defs by publisher prefix tenant-wide. For tenants with a generic prefix (`new_`, `cr5fe_`), this returns env vars from unrelated projects and inflates the count + the `envVars[]` table the renderer draws. The plan looks correct ("12 env vars detected") but is actually showing rows from someone else's project. With `--solutionId`, the helper intersects against `solutioncomponents.componenttype=380` for the target solution — only env vars actually owned by the plan's solution. + + **Skip rule**: if `DEV_TOKEN` is null (auth was unavailable in step 6), skip this step and set `ENV_VARS_DETAILS = []`. The renderer's count-summary fallback covers this case. + +11. **Pre-plan completeness check** (only runs when `SOLUTION_DONE = true`). + + Before the user approves a plan, verify the existing solution already covers everything on the live site. Components created after the last `/power-pages:setup-solution` run (server logic from `add-server-logic`, flows from `add-cloud-flow`, env vars from `configure-env-variables` or `setup-auth`) are silently excluded from any plan built on top of a stale solution. + + Run the shared discovery helper against the source environment: + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-site-components.js" \ + --envUrl "{envUrl}" --token "{token}" \ + --siteId "{websiteRecordId from powerpages.config.json}" \ + --publisherPrefix "{solutionManifest.publisher.prefix}" \ + --solutionId "{solutionManifest.solution.solutionId}" + ``` + + Parse stdout and evaluate `missing.*`: + + - **All `missing.*` arrays empty** → report "Solution contents match the site — proceeding with fresh plan inputs." Continue to Phase 2. + - **Any non-empty `missing.*` array** → report a compact summary: + > "Your solution is **missing {N} component(s)** that exist on the site: + > + > - **{X}** site components (e.g. {first 3 names}) + > - **{L}** site languages (powerpagesitelanguage — required; without these the target site silently fails to render post-auth) + > - **{Y}** cloud flows + > - **{Z}** environment variable definitions + > - **{W}** custom tables + > + > A plan built now will ignore these components. How would you like to proceed?" + + Always render the **site languages** line when `missing.siteLanguages.length > 0`, even when other categories are zero — this gap was a recurring silent-failure mode before discover-site-components started enumerating `powerpagesitelanguages`. See `references/solution-api-patterns.md` for the 3-entity model. + + + > 🚦 **Gate (progress · plan-alm:1.completeness):** Completeness check found gaps vs live site. Sync first, plan with gaps recorded, or cancel. + + Ask via `AskUserQuestion`: + + | Question | Header | Options | + |---|---|---| + | Run `/power-pages:setup-solution` in sync mode to adopt the missing components before planning? | Completeness Check | Yes — sync first (Recommended), No — plan with current solution contents, Cancel | + + - **Yes, sync first (Recommended)**: invoke `/power-pages:setup-solution` (auto-detects the existing manifest and enters sync mode). After it completes, re-run the discovery helper; if `missing.*` is now empty proceed to Phase 2, otherwise repeat the prompt. + - **No, plan with current contents**: store the gap summary as `KNOWN_GAPS` so Phase 3 can surface it in the plan HTML's Risks section, then continue. + - **Cancel**: stop the skill so the user can investigate. + + > **Why this exists**: the same check runs at export (`export-solution` Phase 2.5) and deploy (`deploy-pipeline` Phase 3.5). Adding it here catches gaps at the earliest possible gate — before the user invests time reviewing a plan built on stale inputs. See AGENTS.md → ALM-aware by default. + + > **Skip when `SOLUTION_DONE = false`**: if there is no manifest yet, there is nothing to be stale against — Phase 2 Q1 will handle first-time solution setup. + +12. **Run host resolution** (PP Pipelines path only — runs after the completeness check). + + **Skip rule:** if `PIPELINE_DONE = true`, skip this step entirely — the host info comes from `docs/alm/last-pipeline.json`. Only fresh-pipeline projects need resolution. + + Acquire a BAP-audience access token (the BAP API uses a different audience than Dataverse): + ```bash + az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv + ``` + Capture the output as `BAP_TOKEN`. If acquisition fails, set `HOST_RESOLUTION = { status: 'DetectionFailed', error: '' }` and skip the detect call. + + Run the detect-only wrapper. Use the same tmp-file-then-mv pattern as Phase 1 step 10 so a prior good `docs/alm/alm-host-resolution.json` is preserved if the script fails mid-write. Pass `--skus Production,Sandbox,Trial` so trial-license and developer tenants see their eligible envs in the env-first menu (the helper's default is `Production,Sandbox`; we widen to include Trial here because plan-alm's NoHost branch always offers an existing-env install path that Trial envs can take, even though Trial envs cannot use the create-new fast-path): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/ensure-pipelines-host-detect.js" \ + --envUrl "{DEV_ENV_URL}" --token "{DEV_TOKEN}" --userId "{userId}" \ + --bapToken "{BAP_TOKEN}" \ + --projectRoot "." \ + --cacheMaxAgeHours 24 \ + --skus Production,Sandbox,Trial > ./docs/alm/alm-host-resolution.json.tmp \ + && mv ./docs/alm/alm-host-resolution.json.tmp ./docs/alm/alm-host-resolution.json + ``` + + > **Note**: `ensure-pipelines-host-detect.js` is a **detection-only wrapper** the `ensure-pipelines-host` skill exposes for orchestrators. It runs Phases 1.0 (cache fast-path) + 2 (resolution order including tenant-wide enumeration) + 5 (verify if a host is found) of that workflow, but never enters Phase 3 (decision tree) or Phase 4 (provisioning). Output matches the `docs/alm/last-host-check.json` schemaVersion 2 with `actionTaken: "none"` always. + + **Failure handling:** if the detection script exits non-zero, set `HOST_RESOLUTION = { status: 'DetectionFailed', error: '' }` and continue. Phase 2 Q4 falls back to today's "enter URL manually" branch. + + On success, parse `docs/alm/alm-host-resolution.json` and store as `HOST_RESOLUTION` (mapping the wrapper's field names into the plan-alm shape): + ```js + HOST_RESOLUTION = { + status: parsed.resolutionStatus, // one of: AvailableUsingCustomHost | AvailableUsingCustomHostByAdminDefault | AvailableUsingPlatformHost | AvailableUnboundCustomHost | MultipleUnboundCustomHosts | PlatformHostExistsUnbound | CannotRedirect | NoHost | OrgSettingStale | PermissionDenied + finalHostEnvUrl: parsed.finalHostEnvUrl, // string | null + finalHostEnvId: parsed.finalHostEnvId, // string | null + hostType: parsed.isPlatformHost ? 'platform' : (parsed.finalHostEnvUrl ? 'custom' : null), + pipelinesSolutionVersion: parsed.pipelinesSolutionVersion, // string | null + candidates: parsed.candidates // { existingCustomHosts[], existingPlatformHost, eligibleForAppInstall[], inaccessibleEnvs[] } + } + ``` + + Report a single line: + ``` + Pipeline host: {finalHostEnvUrl} ({status}) + ``` + or, when no URL is set yet: + ``` + Pipeline host: will be ensured during setup-pipeline ({status}) + ``` + +--- + +## Phase 2 — Gather ALM Strategy + +Ask questions in sequence. **Solution is always Q1** — it is the prerequisite for all other steps. Branch after Q2 based on promotion strategy selection. + +### Q1 — Solution Setup (always asked first) + +**If `SOLUTION_DONE = true`** (manifest found in Phase 1): + + +> 🚦 **Gate (plan · plan-alm:2.q1-existing):** Existing solution found — reuse it (skip setup-solution) or create new (run setup-solution). + +Ask via `AskUserQuestion`: +> "A Dataverse solution is already configured for this site: **{SOLUTION_UNIQUE_NAME}**. Use this existing solution?" + +Options: +1. **Yes, use the existing solution** — `setup-solution` will be skipped in the plan +2. **No, create a new solution** — set `SOLUTION_DONE = false`; `setup-solution` will run + +**If `SOLUTION_DONE = false`** (no manifest found): + +Tell the user (not via `AskUserQuestion` — informational only): +> "No Dataverse solution is set up for this site yet. **`setup-solution` will be the first step in your plan.** The publisher prefix you choose during setup is irreversible — choose carefully." + + +> 🚦 **Gate (plan · plan-alm:2.q1-fresh):** No existing solution — include setup-solution in plan, or accept a user-supplied unique name. + +Ask via `AskUserQuestion`: +> "Ready to include solution setup in the plan?" + +Options: +1. **Yes, include solution setup** — continue +2. **I already have a solution (enter name)** — accept free-text solution unique name, set `SOLUTION_DONE = true`, `SOLUTION_UNIQUE_NAME = user input` + +--- + +### Q1b — Split Recommendation (only if `RECOMMEND_SPLIT = true`) + + +> 🚦 **Gate (plan · plan-alm:2.q1b-split):** Follow recommended split strategy, override to single, accept Asset Advisory first, or show migration guidance. + +The decision tree from Phase 1 Step 10 recommended splitting into multiple solutions. Ask via `AskUserQuestion`: + +> "Based on the site size and component analysis, the recommended approach is **{splitStrategy}** — {N} solutions instead of one. Do you want to follow this recommendation?" + +Options: +1. **Use the recommended split** — proceed with `proposedSolutions[]` from the decision tree. `setup-solution` will create all N solutions. +2. **Keep as a single solution anyway** — override to single. Record override reason; `setup-solution` creates one solution with all components. +3. **Accept Asset Advisory first** (only offered if `assetAdvisory.candidates.length > 0`) — user commits to externalizing the flagged assets. Recompute size excluding those files, re-run the decision tree, present the new recommendation. +4. **Show me migration guidance** (only offered if an existing `.solution-manifest.json` is found and does not match the recommendation) — produce `docs/alm-migration-plan.md` and exit. Do not execute. + +**If option 1:** continue with `proposedSolutions`. + +**If option 2 — Keep as a single solution anyway:** this overrides a data-driven recommendation that's frequently right. Before honoring the override, **re-surface the tier signals so the user is making an informed choice, not a one-click dismissal.** Read from `SPLIT_PLAN.sizeAnalysis`: + + ``` + You're about to override a {splitStrategy} recommendation. Before doing that, here's what the estimator measured: + + • Total size: {totalSizeMB.value} MB (tier: {totalSizeMB.tier} — threshold {thresholds.maxSolutionSizeMB} MB) + • Component count: {componentCount.value} (tier: {componentCount.tier} — threshold {thresholds.maxComponentCount}) + • Schema attributes: {schemaAttrCount.value} (tier: {schemaAttrCount.tier} — threshold {thresholds.maxSchemaAttrs}) + • Web files aggregate: {webFilesAggregateMB.value} MB (tier: {webFilesAggregateMB.tier} — threshold {thresholds.maxAggregateWebFilesMB} MB) + • Env var definitions: {envVarCount.value} (tier: {envVarCount.tier}) + + {if SPLIT_PLAN.truncationSuspected === true: + ⚠ The estimator flagged its inputs as possibly truncated: + {SPLIT_PLAN.truncationWarnings.join('\n ')} + The numbers above could be UNDER-counted. Investigate before overriding. + } + + Solutions exceeding the platform thresholds frequently fail to import (timeouts, OOM, partial state). A single-solution plan that lands in the red tier is the most common cause of "the deploy hung overnight" reports. Recovering means splitting after the fact, which is harder than splitting upfront. + ``` + + + > 🚦 **Gate (consent · plan-alm:2.q1b-override):** Override the data-driven split recommendation to keep as single solution. Free-text `overrideReason` follows on Yes. + + Then ask via `AskUserQuestion`: + + | Question | Header | Options | + |---|---|---| + | Still want to keep as a single solution? | Override confirmation | No — use the recommended {splitStrategy} split (Recommended), Yes — override anyway and note the reason, Cancel — re-think the strategy | + + - **No** → re-route to Option 1 (use the recommended split). + - **Yes** → require a free-text `overrideReason` via a follow-up `AskUserQuestion` ("Briefly: why is single-solution the right call for this site?"). Record `overrideReason` and `overrideConfirmedSignals` (the tier-classified signals shown above) in the plan. Only then override `SPLIT_PLAN.proposedSolutions` to the single-solution structure for rendering. + - **Cancel** → return to Q1b top. + + > **Why the friction:** in field-reported sessions, "keep as single anyway" was a one-click override and turned out to be the single most common path to a wrong recommendation. The re-confirmation isn't there to talk the user out of it — it's there to make sure the override is informed and the reason gets recorded for audit. Override-with-recorded-reason is fully respected; the gate only blocks the silent click-through. + +**If option 3:** subtract advisory candidate sizes from the estimate, re-run `compute-split-plan.js`, re-present. +**If option 4:** write `docs/alm-migration-plan.md` (see the spec doc `solution-splitting-logic.md` §7), commit it, mark plan as Deferred, exit. + +--- + +### Q2 — Strategy Selection (always asked) + + +> 🚦 **Gate (plan · plan-alm:2.q2-strategy):** Pick promotion strategy — PP Pipelines, manual export/import, existing pipeline, or help-me-decide. Branches the rest of the plan. + +Ask via `AskUserQuestion`: + +> "How do you want to promote your solution between environments?" + +Options: +1. **Power Platform Pipelines** — Microsoft's native CI/CD, managed deployments, approval gates +2. **Manual export/import** — export a zip from dev and import directly to each target environment +3. **I already have a pipeline set up** — run a deployment now +4. **Help me decide** — show a quick comparison + +**If option 4 selected:** Explain: +> "Power Platform Pipelines is recommended for teams and multiple environments — it provides automated promotion, approval gates, and deployment history in one place. Manual export/import is simpler for one-off migrations or when you only need to deploy once. For ongoing CI/CD, choose Power Platform Pipelines." + +Then re-ask Q2 with only options 1–3. + +**If option 3 selected:** Read `docs/alm/last-pipeline.json`, confirm pipeline name and stages, then skip to Phase 3 (generate plan) with `strategy = pp-pipelines`, `PIPELINE_DONE = true`. + +--- + +### PP Pipelines Path — Q3 through Q6 + + +> 🚦 **Gate (plan · plan-alm:2.q3-stages):** Pick how many deployment stages — Staging only / +Production / Production directly / Custom. + +**Q3:** Ask via `AskUserQuestion`: +> "How many deployment stages do you want in this pipeline?" + +Options: +1. **Staging only** — Dev → Staging (I'll add Production later) +2. **Staging + Production** — Dev → Staging → Production (full promotion chain) +3. **Production directly** — Dev → Production only (bypass staging) +4. **Custom** — I'll describe my own stage layout + +If option 4: accept free-text description (via "Other") and build a stage list from the response. + +Store stages as `PP_STAGES` (array of `{ label, envUrl, envName, type }`). Dev is always the source. + +For each stage, populate `envName` from `ENV_LIST` (gathered in Phase 1 Step 5 via `pac env list --output json`). Match by URL origin (lowercase, trailing slash stripped, path/query ignored) and copy the entry's `DisplayName` (or `displayName`) into `envName`. When no match is found — usually because the user pasted a custom URL via "Other" — leave `envName` unset; the renderer falls back to showing the URL alone in the stage card. The renderer puts `envName` between the stage label and the URL (e.g. *Staging / **Supplier Portal Staging** / https://orgd6a9894f.crm5.dynamics.com/*) so reviewers recognize the env at a glance and the URL stays available as a one-click jump-to-env. Set `type: "source"` for the dev/source stage and `type: "target"` for every downstream stage so the renderer applies the active-stage styling correctly. + +**Q4 (host environment — branches on `HOST_RESOLUTION.status` from Phase 1 step 12):** + +This question consumes `HOST_RESOLUTION` populated by the new detect-only wrapper run in Phase 1 step 12. Each branch sets `HOST_ENV_URL` (which feeds the rest of plan-alm) and may also set the auxiliary flags `CHOSEN_ENV_URL`, `WILL_PROVISION_PLATFORM`, `WILL_PROVISION_CUSTOM`, `WILL_USE_PPAC`, `WILL_ENSURE_HOST`, and `USER_CHOSE_DEFER_TO_SETUP_PIPELINE`. Defaults: `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`, all flags `false` / null. + +**Why the NoHost branch presents the env-first menu here instead of deferring to ensure-pipelines-host Phase 3.C:** the original design asked a yes/no "we'll provision new — continue?" question in plan-alm and let 3.C surface the env-first choice at execution time. In practice the agent treated the plan-alm yes-confirmation as authorization to skip 3.C entirely (or to skip 4.A's pre-call gate), and users hit 4.A → 409 trial-license errors when an existing env install (4.B) would have been a clean path. Surfacing the env-first menu **here** — once, at planning time, when the user has full context — eliminates the ambiguity. ensure-pipelines-host then trusts `CHOSEN_ENV_URL` and skips its own 3.C menu (see ensure-pipelines-host Phase 3 skip rule). + +| `status` | Q4 prompt | Result | +|---|---|---| +| `AvailableUsingCustomHost`, `AvailableUsingCustomHostByAdminDefault`, `AvailableUsingPlatformHost` | "Detected host `{finalHostEnvUrl}` (Pipelines v`{pipelinesSolutionVersion}`). Use this host?" Options: 1. Yes, use this / 2. Use a different host environment (Other) | Y → `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`. N → fall back to today's "enter different URL" branch (free-text via Other). | +| `AvailableUnboundCustomHost` | "Existing Custom Host `{displayName}` (`{finalHostEnvUrl}`) found in tenant — not yet bound to dev env. setup-pipeline will reuse it (recommended; avoids duplicates). Use this host?" Options: 1. Yes, use this / 2. Use a different host environment (Other) | Y → `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`, `WILL_ENSURE_HOST = true`. N → fall back to "enter different URL". | +| `MultipleUnboundCustomHosts` | "{N} Custom Hosts found in tenant. Which one should setup-pipeline use?" Options: enumerate `HOST_RESOLUTION.candidates.existingCustomHosts[]` (up to 3) by display name + URL, plus "Other" for a custom URL, plus "Decide later — setup-pipeline will ask". | Picked candidate → `HOST_ENV_URL = candidate.instanceApiUrl`, `WILL_ENSURE_HOST = true`. Decide-later → `HOST_ENV_URL = null`, `WILL_ENSURE_HOST = true`, `USER_CHOSE_DEFER_TO_SETUP_PIPELINE = true`. | +| `PlatformHostExistsUnbound` | "Tenant Platform Host `{finalHostEnvUrl}` exists. Use it (no admin role required) or create a new Custom Host?" Options: 1. Use Platform Host / 2. Create new Custom Host / 3. Cancel | 1 → `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`, `WILL_ENSURE_HOST = true`. 2 → `HOST_ENV_URL = null`, `WILL_PROVISION_CUSTOM = true`, `WILL_ENSURE_HOST = true`. 3 → exit. | +| `NoHost` | **Host-type prompt** — same shape as `ensure-pipelines-host` Phase 3.C so the user makes the host choice once, here, instead of being asked again at execution time. Present: *"No Pipelines host bound to `{devEnvUrl}`. Which environment should host Pipelines? Pipelines lives in one env per tenant; pipelines, stages, and run history are stored there. Source envs deploy through it."* Top-level options: **1.** "Provision a Platform Host (recommended) — Microsoft-managed Dataverse env auto-provisioned in your tenant home geo. Pipelines app pre-installed. Idempotent. ~3–5 min." **2.** "Set up a Custom Host — Pipelines lives in a Dataverse env you control. We'll ask whether to use an existing env or create a brand-new dedicated one." **3.** "Open PPAC and create one manually (admin fallback)." **4.** "Switch to Manual export/import strategy." **5.** "Cancel." When the user picks Option 2, surface the **Custom-Host sub-prompt**: build the eligible-env list from `HOST_RESOLUTION.candidates.eligibleForAppInstall[]` with role labels (`dev env`, `source env`, `staging env`, `production env`) per origin match; cap the visible list at 5 envs with role-aware ranking (see "Eligible-env presentation cap" below). Sub-options: **a.** Each visible env (display name + URL + role labels) labeled "*Install Pipelines app on this env*" — sandbox-sku envs add a "(Sandbox — confirmation gate)" suffix; append "Other (paste URL)" as the last per-env entry. **b.** "Create a brand-new dedicated env (D365_ProjectHost template, ~5–10 min, requires Power Platform admin)." **c.** "Back — return to host-type menu." When the eligible list is empty, drop sub-option `a` and present only `b` / `c`. | Option 1 (Platform Host) → `HOST_ENV_URL = null`, `WILL_PROVISION_PLATFORM = true`, `WILL_ENSURE_HOST = true`. Option 2 → sub-prompt; sub-option `a` picked env → `HOST_ENV_URL = picked.instanceApiUrl`, `CHOSEN_ENV_URL = picked.instanceApiUrl`, `WILL_ENSURE_HOST = true` (Sandbox confirmation gate, if applicable, must be passed before this resolution stands; "Other (paste URL)" → ask for the env URL via free-text, then proceed as a picked eligible env); sub-option `b` → `HOST_ENV_URL = null`, `WILL_PROVISION_CUSTOM = true`, `WILL_ENSURE_HOST = true`; sub-option `c` → re-show top-level menu. Option 3 (PPAC) → `HOST_ENV_URL = null`, `WILL_USE_PPAC = true`, `WILL_ENSURE_HOST = true`. Option 4 (Manual strategy) → restart Phase 2 with `STRATEGY = manual`. Option 5 → exit. | +| `CannotRedirect` | **Block.** Show the org-setting vs tenant-default mismatch error from `HOST_RESOLUTION.candidates`/`warnings` and stop the skill — only a Power Platform admin can resolve. | Exit with the specific error. | +| `OrgSettingStale`, `PermissionDenied`, `DetectionFailed` | Surface the error; ask the user to enter the host URL manually with `pac env list` pre-fill (today's fallback). Pre-fill options from `ENV_LIST` (up to 3 known environment URLs) plus "Other" for a custom URL; pre-fill first option from `docs/alm/last-pipeline.json` if present. | `HOST_ENV_URL = user-supplied`. | + +Store the resulting `HOST_ENV_URL` for use by the rest of plan-alm. The auxiliary flags `CHOSEN_ENV_URL`, `WILL_PROVISION_PLATFORM`, `WILL_PROVISION_CUSTOM`, `WILL_USE_PPAC`, `WILL_ENSURE_HOST`, and `USER_CHOSE_DEFER_TO_SETUP_PIPELINE` feed the planData `hostResolution` block in Phase 3 and the inline summary in Phase 4. ensure-pipelines-host reads `chosenEnvUrl`, `willProvisionPlatform`, `willProvisionCustom`, and `willUsePpac` from that block to bypass its own Phase 3.C menu when the user has already made the choice here. + +**Eligible-env presentation cap** (used by the `NoHost` row above and by `MultipleUnboundCustomHosts`). When the eligible list runs long, build the visible options as follows so the prompt stays scannable: + +1. **Always-visible role-labeled envs first.** Include any eligible env carrying a `dev env`, `source env`, `staging env`, or `production env` label (matched by URL origin against `devEnvUrl` and against `PP_STAGES[].envUrl`). These are the project's own envs and are nearly always the right pick. Dedupe by origin. +2. **Fill remaining slots up to 5** from the rest of the eligible list, in the order returned by `list-tenant-envs.js` (name-hint pattern `pipeline|deploy|host|alm|cicd|govern` → admin-perms → recency). +3. **Append "Other (paste URL)"** as the last per-env entry inside option 1's nested list — escape hatch for envs that didn't make the cap. +4. When `eligible.length > 5`, suffix option 1's headline with: ` Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.` When `eligible.length <= 5`, no suffix (all envs visible inline). +5. When the user picks "Other (paste URL)", pre-fill the URL input with `ENV_LIST` (the `pac env list --output json` output gathered in Phase 1) so they can paste-or-pick from the full inventory rather than typing a URL by hand. + +The same cap policy applies to `ensure-pipelines-host` Phase 3.C — see that skill's Step 3a for the same rules. Keep the two implementations consistent so users see the same prompt shape regardless of whether they enter via plan-alm or directly via setup-pipeline → ensure-pipelines-host. + + +> 🚦 **Gate (plan · plan-alm:2.q5-approval):** Pick approval mode — required per stage / staging auto + prod required / no gates. + +**Q5:** Ask via `AskUserQuestion`: +> "Should deployments require approval before each stage?" + +Options: +1. Required before each stage (Recommended for production) +2. Staging auto-approve, production requires approval +3. No approval gates — deploy automatically + +Store as `PP_APPROVAL_MODE`. + +**Note:** PP Pipelines always exports as a **managed** solution to target environments. Set `EXPORT_TYPE = "managed"` automatically — no question needed. + +**Q6 (auto-detect, no question):** Resolve `HAS_ENV_VARS` in this order: + +1. If `ENV_VARS_DETAILS.length > 0` (Phase 1 Step 10b returned per-variable rows): `HAS_ENV_VARS = true`. This is the most accurate signal — we've enumerated the definitions directly. +2. Else if `SPLIT_PLAN.sizeAnalysis.envVarCount.value > 0` (the size estimator counted definitions but the enumerator didn't return rows — usually because the publisher prefix or token was missing): `HAS_ENV_VARS = true`. The plan's count-summary fallback will explain to the user that per-variable details will be enumerated later. +3. Else if `SOLUTION_DONE = true` and `.solution-manifest.json` lists components with `componenttype 380`: `HAS_ENV_VARS = true`. (Stale-manifest fallback — should rarely fire now that 10b is in place.) +4. Otherwise: `HAS_ENV_VARS = false`. Variables will be discovered during setup-solution. + +When `HAS_ENV_VARS = true`, the plan notes that `deploy-pipeline` will prompt for per-stage env var values (see Phase 3 risks population). + +--- + +### Manual Path — Q3 through Q6 + + +> 🚦 **Gate (plan · plan-alm:2.q3-manual):** Pick how many target environments for manual export/import path. + +**Q3:** Ask via `AskUserQuestion`: +> "How many target environments do you need to deploy to?" + +Options: +1. One target (e.g. Production) +2. Two targets (e.g. Staging then Production) +3. Dev only — not deploying yet + +Store as `MANUAL_TARGET_COUNT`. + +If option 3: set `MANUAL_TARGET_COUNT = 0`. Proceed to Q5. + + +> 🚦 **Gate (plan · plan-alm:2.q4-manual-target):** Pick the URL for each manual target env. **Fires PER TARGET in the `MANUAL_TARGET_COUNT` loop.** Two targets (Staging + Production) = two separate prompts. Each prompt pre-fills from `pac env list` and accepts a different URL. Do NOT collect all target URLs in a single multi-input prompt — each target is a distinct decision (different audiences, different env characteristics, possibly different SKUs). + +**Q4 (one per stage):** For each target environment needed, ask via `AskUserQuestion`: + +> "What is the URL for target environment {N}?" + +Pre-fill from `ENV_LIST`: show up to 3 known environment URLs from `pac env list` as options, plus "Enter a different URL" as the last option. + +Store target URLs as `MANUAL_TARGETS` (array). + + +> 🚦 **Gate (consent · plan-alm:2.q5-manual-type):** Managed vs Unmanaged export — irreversible choice for the produced zip. + +**Q5:** Ask via `AskUserQuestion`: +> "How should the solution be exported?" + +Options: +1. Managed — for staging/production (cannot edit in target) +2. Unmanaged — for dev-to-dev (editable in target) + +Store as `EXPORT_TYPE`. + + +> 🚦 **Gate (plan · plan-alm:2.q6-manual-checkpoint):** Pause between export and import for review, or proceed automatically. + +**Q6:** Ask via `AskUserQuestion`: +> "Do you want a checkpoint pause between export and import for review?" + +Options: +1. Yes — pause after export so I can review the zip before importing +2. No — proceed automatically + +Store as `MANUAL_CHECKPOINT` (`true` or `false`). + +**Q6 (auto-detect, no question):** Same as PP Pipelines Q6 — check for env var definitions. + +--- + +## Phase 3 — Generate HTML Plan + +**Now create all tasks** — strategy is known. + +### Task creation + +**For PP Pipelines path**, create these tasks (in order): + +| # | Subject | activeForm | Description | +|---|---------|-----------|-------------| +| 1 | Generate ALM plan | Generating ALM plan | Build planData, render docs/alm-plan.html | +| 2 | Approve ALM plan | Awaiting plan approval | Present inline summary, get user confirmation | +| 3 | Setup solution | Setting up solution | Invoke setup-solution skill (conditional) | +| 4 | Setup pipeline | Setting up pipeline | Invoke setup-pipeline skill (conditional) | +| 5..N | Deploy to {stageName} | Deploying to {stageName} | Invoke deploy-pipeline skill for this stage — one task per target stage | +| 5..N+1 | Activate site in {stageName} | Activating site in {stageName} | Check activation status; if Pending/null: `pac env select --environment "{stage.targetEnvironmentUrl}"` then invoke activate-site — one task per target stage | +| 5..N+2 | Test site in {stageName} | Testing site in {stageName} | Invoke /power-pages:test-site against the activated URL; capture pass/fail counts; non-blocking — one task per target stage | +| N+3 | Finalize | Finalizing | Update HTML status, commit, run skill tracking | + +Create one **Deploy to {stageName}** + **Activate site in {stageName}** + **Test site in {stageName}** task triplet for each target stage in `PP_STAGES` (e.g. Staging, Production). + +**For Manual path**, create: + +| # | Subject | activeForm | Description | +|---|---------|-----------|-------------| +| 1 | Generate ALM plan | Generating ALM plan | Build planData, render docs/alm-plan.html | +| 2 | Approve ALM plan | Awaiting plan approval | Present inline summary, get user confirmation | +| 3 | Setup solution | Setting up solution | Invoke setup-solution skill (conditional) | +| 4 | Export solution | Exporting solution | Invoke export-solution skill | +| 5..N | Import to {targetLabel} | Importing solution | Switch PAC CLI context, invoke import-solution (one task per target) | +| N+1 | Activate site in {targetLabel} | Activating site | Check activation status, invoke activate-site if not yet provisioned (one task per target, optional) | +| N+2 | Finalize | Finalizing | Update HTML status, commit, run skill tracking | + +If `SOLUTION_DONE = true`, add `(will skip — already set up)` to the setup-solution task description. +If `PIPELINE_DONE = true` (PP path), add `(will skip — already set up)` to the setup-pipeline task description. + +**Activation steps (PP path):** Create a separate **"Activate site in {stageName}"** task for every target stage. After each `deploy-pipeline` invocation succeeds, the activation task for that stage runs immediately — do not wait until all stages are deployed. The planData `steps` array must include one `"Deploy to {stageName}"` + one `"Activate site in {stageName}"` + one `"Test site in {stageName}"` triplet per target stage. Activation and testing happen after every stage deployment — not just Production. + +**Test steps (PP path):** Create a separate **"Test site in {stageName}"** task for every target stage. After each activation completes (or is skipped), the test task runs immediately and is **non-blocking** — `test-site` writes `docs/alm/last-test-site.json`, plan-alm ingests it into `validationRuns[stageName]`, and the rendered HTML's **Validation** tab gets a per-stage sub-tab with categorized findings. Failures do not abort the plan. + +**Activation steps (Manual path):** For the Manual path, create one "Activate site in {targetLabel}" task per target environment. These run after the corresponding import completes. The Manual path does not include automatic test-site invocations — site testing is left to the user after manual deployment. + +Mark task 1 ("Generate ALM plan") as `in_progress`. + +### Build planData + +Build a `planData` object with all gathered strategy inputs: + +```json +{ + "SITE_NAME": "{siteName}", + "GENERATED_AT": "{ISO timestamp}", + "STRATEGY": "pp-pipelines | manual", + "EXPORT_TYPE": "managed | unmanaged", // PP Pipelines path: always "managed" + "APPROVAL_MODE": "{approvalMode description}", + "HAS_ENV_VARS": true | false, + "SOLUTION_DONE": true | false, + "PIPELINE_DONE": true | false, + "PLAN_STATUS": "Draft", + "LAST_INVOCATION_AT": null, + "APPROVED_BY": "", + "APPROVAL_DATE": "", + "stages": [ + { "label": "Dev", "envUrl": "{devEnvUrl}", "type": "source" }, + { "label": "Staging", "envUrl": "{stagingUrl}", "type": "target" }, + { "label": "Production", "envUrl": "{prodUrl}", "type": "target" } + ], + "steps": [ + { "name": "Setup solution", "status": "pending", "skip": false }, + { "name": "Setup pipeline", "status": "pending", "skip": false }, + { "name": "Deploy via pipeline to Staging", "status": "pending", "skip": false }, + { "name": "Activate site in Staging", "status": "pending", "skip": false }, + { "name": "Test site in Staging", "status": "pending", "skip": false }, + { "name": "Deploy via pipeline to Production", "status": "pending", "skip": false }, + { "name": "Activate site in Production", "status": "pending", "skip": false }, + { "name": "Test site in Production", "status": "pending", "skip": false } + ], + "validationRuns": { + "Staging": null, + "Production": null + }, + "pipelineMeta": null, // populated when docs/alm/last-pipeline.json exists — see "pipelineMeta block" below + "risks": [ + { "type": "info", "message": "..." } + ], + "solutionContents": { + "tables": ["{table1}", "{table2}"], + "botComponents": [{ "name": "..." }], + "siteSettings": { + "keepAsIs": [{ "name": "..." }], + "promoteToEnvVar": [{ "name": "...", "value": "..." }], + "credentialNeedsDecision": [{ "name": "..." }] + } + }, + + // --- v2 fields from the split decision tree (Phase 1 Step 10) --- + "sizeAnalysis": { /* tier-classified signals from SPLIT_PLAN.sizeAnalysis */ }, + "assetAdvisory": { /* candidates + recommendation from SPLIT_PLAN.assetAdvisory */ }, + "splitStrategy": "single | strategy-1-layer | strategy-2-change-frequency | strategy-3-schema-segmentation | strategy-4-config-isolation", + "appliedStrategies": ["strategy-1-layer"], // may include "composite-sub-partition" when a Layer-split child still exceeded the size or count cap and was sub-divided + "compositeSubPartitioned": false, // mirrors SPLIT_PLAN.compositeSubPartitioned — true when the renderer's strategy rationale should mention sub-partitioning + "proposedSolutions": [ /* from SPLIT_PLAN.proposedSolutions — ALWAYS at least 1 entry */ ], + "recommendations": [ /* from SPLIT_PLAN.recommendations */ ], + "envVars": [ /* from ENV_VARS_DETAILS (Phase 1 Step 10b) — { schemaName, type, defaultValue, siteSetting } per definition; empty array when DEV_TOKEN unavailable */ ], + "plannedEnvVarCount": 0, // sum of SITE_SETTINGS_DATA.promoteToEnvVar.length + credentialNeedsDecision.length — env vars setup-solution will offer to create. Renderer shows this as "N planned" alongside the existing-count stat so a fresh project (envVars: []) doesn't look like nothing is happening when the risks list says auth settings will be promoted. Reset to 0 by refresh-alm-plan-data setup-solution phase. + "breakdown": { /* bytes-per-category from the estimate */ }, + "estimationMethod": "metadata-based", + "estimationAccuracyPct": 15, + "truncationSuspected": false, // true when the estimator's truncation canary fired — surfaced as a red banner on the rendered plan and gates the "keep as single solution anyway" override in Phase 2 Q1b + "truncationWarnings": [], // specific category-level reasons the canary fired (e.g. "Dataverse reports 6050 powerpagecomponent rows but discovery returned 500") + "webFilesDiskMeasuredMB": null, // disk-measured total when `--projectRoot` was passed to estimate-solution-size.js and a build-output dir (dist/public-output/build/.output) was found; null otherwise. Renderer surfaces this under the Web Files signal when it disagrees materially with `webFilesAggregateMB` — useful when file-typed columns hold bytes that $select=content can't return. + "webFilesDiskMeasuredPath": null, // the build-output directory that produced webFilesDiskMeasuredMB; null when not measured. + "webFileSampleSize": 0, // how many web-file rows the stratified sampler actually read for size measurement. Compared with webFileCount this tells reviewers how aggressively the aggregate was extrapolated. + + // --- Raw discovery snapshot — embedded verbatim for diagnosability --- + // The mapped fields above (sizeAnalysis, splitStrategy, hostResolution, …) + // are derived from these. When a rendered plan looks wrong, this block is + // what a reviewer (or another agent) needs to diagnose without re-running + // discovery. Never reference these fields from the renderer's display + // paths — keep them as a sealed diagnostic envelope. + "rawDiscovery": { + "estimate": { /* full output of estimate-solution-size.js, verbatim */ }, + "splitPlan": { /* full output of compute-split-plan.js, verbatim */ }, + "hostResolution": { /* full output of ensure-pipelines-host-detect.js (PP path only) or null */ } + }, + + // --- v3 fields from the host resolution (Phase 1 Step 12) — PP Pipelines path only --- + "hostResolution": { + "status": "AvailableUsingCustomHost | AvailableUsingCustomHostByAdminDefault | AvailableUsingPlatformHost | AvailableUnboundCustomHost | MultipleUnboundCustomHosts | PlatformHostExistsUnbound | CannotRedirect | NoHost | OrgSettingStale | PermissionDenied | DetectionFailed", + "hostEnvUrl": "https://pascalepipelineshost.crm.dynamics.com" | null, + "hostEnvId": "0817fd3d-a664-e99a-a758-dd9dc03ceb01" | null, + "hostType": "custom | platform | null", + "pipelinesSolutionVersion": "9.x.y.z" | null, + "candidatesCount": 0, + "willEnsureDuringExecution": true | false, + "willProvisionPlatform": true | false, + "willProvisionCustom": true | false, + "willUsePpac": true | false, + "chosenEnvUrl": "https://orgc4f78248.crm5.dynamics.com/" | null, + "userChoseDeferToSetupPipeline": false + } +} +``` + +`solutionContents` is populated from `SOLUTION_CONTENTS_DATA` built in Phase 1. If discovery was unavailable, pass `null` — the renderer will show a fallback note. + +`plannedEnvVarCount` is computed from `SITE_SETTINGS_DATA` (Phase 1 Step 7): `(SITE_SETTINGS_DATA.promoteToEnvVar?.length || 0) + (SITE_SETTINGS_DATA.credentialNeedsDecision?.length || 0)`. When `SITE_SETTINGS_DATA` is null (Step 7 query failed), set `plannedEnvVarCount = 0`. The renderer reads this alongside `envVars.length` (existing) and `sizeAnalysis.envVarCount.value` (size-estimator's count) to produce a "N today / +M planned" display in the Overview stat card and Size Analysis signal. + +**`validationRuns` block** (PP Pipelines path only — initialize one entry per target stage with value `null`; populated during Phase 7 Step C by ingesting `docs/alm/last-test-site.json` after each stage's test run). The full categorized test report drives the new **Validation** tab in the rendered HTML. Shape per stage: + +```json +{ + "validationRuns": { + "Staging": { + "url": "https://example.powerappsportals.com", + "runAt": "2026-04-27T15:00:00.000Z", + "durationSec": 120, + "runOutcome": "passed | passed-with-warnings | failed", + "summary": { + "critical": 0, "high": 1, "medium": 0, "low": 2, + "total": 3, "automated": 2, "manual": 1, + "passed": 2, "failed": 1, "skipped": 0 + }, + "categories": [ + { + "id": "site-load", + "name": "Site Load", + "icon": "📦", + "tests": [ + { + "id": "t01", + "name": "Homepage returns 200 OK", + "severity": "critical", + "type": "automated", + "status": "passed", + "description": "...", + "steps": ["GET /", "Expect 200"], + "expected": "200 OK", + "actual": "200 OK", + "validates": "Site activation" + } + ] + } + ] + }, + "Production": null + } +} +``` + +The shape is identical to `docs/alm/last-test-site.json` written by `test-site` Phase 6.7a — `plan-alm` reads that file verbatim and assigns it to `validationRuns[stageName]`. The renderer maps `runOutcome` to green / yellow / red Outcome badges and produces a sub-tab per stage on the Validation tab. For the Manual path, omit `validationRuns` from planData. + +**`pipelineMeta` block** (PP Pipelines path only — read from `docs/alm/last-pipeline.json` and `docs/alm/last-deploy.json` at planData-build time. `null` on fresh plans where no pipeline is configured yet; populated after `setup-pipeline` and refreshed after each `deploy-pipeline` run via the post-deploy re-render in Phase 7). Highlights the pipeline that is actually moving configurations for this project. Shape: + +```json +{ + "pipelineMeta": { + "isActive": true, + "pipelineId": "2b8b5de8-8f43-f111-bec7-6045bd569497", + "pipelineName": "BYOC Supplier Portal Pipeline", + "reusedByWiring": null, + "lastDeploy": { + "status": "Succeeded", + "stageName": "Deploy to Staging", + "deployedAt": "2026-04-29T08:42:00.000Z", + "artifactVersion": "1.0.0.2", + "componentCount": 118 + } + } +} +``` + +- `isActive`: `true` whenever the project has a configured pipeline (`docs/alm/last-pipeline.json` exists). Drives the **ACTIVE** chip on the Pipelines tab. +- `pipelineName`: from `docs/alm/last-pipeline.json`. The renderer falls back to `${SITE_NAME}-Pipeline` when `pipelineMeta` is absent. +- `reusedByWiring`: `null` when the pipeline was created fresh; an object `{ originalName, requestedName }` when `create-deployment-pipeline.js` matched an existing pipeline by source+target wiring and reused it under its existing name. The renderer surfaces this with an explanatory note so reviewers understand why the plan and the live pipeline names may differ. +- `lastDeploy`: derived from `docs/alm/last-deploy.json`. Omit (set to `null`) before the first deploy. + +**How to populate.** During Phase 3 planData build, read both files (Node.js inline) and inject: +```bash +node -e " +const fs = require('fs'); +const meta = { isActive: false, pipelineId: null, pipelineName: null, reusedByWiring: null, lastDeploy: null }; +try { + const lp = JSON.parse(fs.readFileSync('docs/alm/last-pipeline.json','utf8')); + meta.isActive = true; + meta.pipelineId = lp.pipelineId || null; + meta.pipelineName = lp.pipelineName || null; + meta.reusedByWiring = lp.reusedByWiring || null; +} catch {} +try { + const ld = JSON.parse(fs.readFileSync('docs/alm/last-deploy.json','utf8')); + meta.lastDeploy = { + status: ld.status, stageName: ld.stageName, deployedAt: ld.deployedAt, + artifactVersion: ld.artifactVersion, componentCount: ld.componentCount, + }; +} catch {} +process.stdout.write(JSON.stringify(meta)); +" +``` +Embed the result as `planData.pipelineMeta`. + +**v2 fields** (`sizeAnalysis`, `assetAdvisory`, `splitStrategy`, `appliedStrategies`, `compositeSubPartitioned`, `proposedSolutions`, `recommendations`, `breakdown`) come straight from `SPLIT_PLAN` computed in Phase 1 Step 10, mutated by Q1b user choices. Pass them through unchanged to the renderer. **`envVars`** comes from `ENV_VARS_DETAILS` populated in Phase 1 Step 10b — pass it through unchanged. When the array is empty, the renderer's count-aware fallback uses `sizeAnalysis.envVarCount.value` so the Env Variables tab still shows a count-summary note. + +**Disk-measurement cross-check** (`webFilesDiskMeasuredMB`, `webFilesDiskMeasuredPath`, `webFileSampleSize`) — hoist these from the estimator output (`rawDiscovery.estimate.webFilesDiskMeasuredMB`, etc.) onto the top level of `planData`. They're only populated when `--projectRoot "."` was passed to `estimate-solution-size.js` AND a build-output directory was detected. The renderer surfaces `webFilesDiskMeasuredMB` next to the Dataverse-measured Web Files signal when the two numbers disagree materially (the same condition that flips `truncationSuspected`), so reviewers can see at a glance which number to trust. Pass `null` for all three when the estimator didn't measure disk. + +**Truncation canary** (`truncationSuspected`, `truncationWarnings`) — pass through verbatim from `SPLIT_PLAN.truncationSuspected` / `SPLIT_PLAN.truncationWarnings` (which `compute-split-plan.js` itself reads from the estimate). When `truncationSuspected === true`, the rendered plan shows a red banner above the size analysis, and the Phase 2 Q1b "keep as single anyway" override is gated by an extra confirmation step (see Phase 2 Q1b). Common causes: Dataverse pagination regression in `estimate-solution-size.js` OR a web-file undercount (file-typed columns whose bytes aren't returned via `$select=content`) — the disk cross-check (when enabled) flags the latter explicitly. + +**`rawDiscovery` block** — embed the full estimator + split-plan + host-resolution outputs verbatim, before any field mapping. This is a diagnostic envelope: in general, the renderer must not reference its contents from display paths. **Narrow exception**: the renderer's `webFilesDiskMeasured*` / `webFileSampleSize` read paths fall back to `rawDiscovery.estimate.*` if the top-level hoist is missing, so older plan files (written before the hoist was specified) still render the disk-compare note. The right fix when you see that fallback fire is to re-build planData with the hoist populated, not to keep the fallback path active. Otherwise the principle stands: `rawDiscovery` exists so a reviewer (or a future agent debugging a wrong plan) can read `docs/.alm-plan-data.json` and see what the discovery scripts actually produced, side-by-side with the mapped fields the renderer used. Read each source file once and assign: + +```js +planData.rawDiscovery = { + estimate: readJson('./docs/alm/alm-size-estimate.json'), + splitPlan: readJson('./docs/alm/alm-split-plan.json'), + hostResolution: PIPELINE_DONE + ? null // host info comes from .last-pipeline.json in that path + : readJson('./docs/alm/alm-host-resolution.json'), // PP path with detection; null for Manual path +}; +``` + +Use `null` for any source that was skipped (Manual path doesn't run host resolution; pre-pipeline projects don't have a manifest). Do not redact or summarize — the value of the snapshot is that it's the raw input. + +> **`proposedSolutions[]` is never empty.** Even when `splitStrategy === "single"` and the decision tree recommends one solution, `compute-split-plan.js` returns one entry describing the base solution (uniqueName, displayName, sizeMB, componentCount, componentTypes). Pass that through. The renderer drives the Solutions tab off this array — leaving it empty produces an unhelpful "structure will be determined" placeholder. If you find yourself with `proposedSolutions = []` because compute-split-plan wasn't run, synthesize a single base entry from `solutionContents.solution` / `data.SITE_NAME` / `componentCount` / `totalSizeMB` rather than passing through an empty array. The renderer has a safety-net synthesizer for this case but the right fix is upstream — populate it explicitly. + +**`hostResolution` block** (PP Pipelines path only — omit for Manual path). Built from `HOST_RESOLUTION` (Phase 1 step 12) plus the auxiliary flags set by Phase 2 Q4: + +- `status` ← `HOST_RESOLUTION.status` +- `hostEnvUrl` ← `HOST_ENV_URL` (from Q4) — may be `null` when the user deferred or chose to provision new +- `hostEnvId` ← `HOST_RESOLUTION.finalHostEnvId` +- `hostType` ← `HOST_RESOLUTION.hostType` +- `pipelinesSolutionVersion` ← `HOST_RESOLUTION.pipelinesSolutionVersion` +- `candidatesCount` ← `HOST_RESOLUTION.candidates.existingCustomHosts.length` +- `willEnsureDuringExecution` ← `WILL_ENSURE_HOST` flag from Q4 (true whenever setup-pipeline will need to consult ensure-pipelines-host at execution time — i.e. status is `NoHost`, any `*Unbound*`, or the user deferred) +- `willProvisionPlatform` ← `WILL_PROVISION_PLATFORM` flag from Q4 (set when the user picks Option 1 "Provision a Platform Host" in the NoHost host-type menu; ensure-pipelines-host treats this as a directive to go straight to Phase 4.0) +- `willProvisionCustom` ← `WILL_PROVISION_CUSTOM` flag from Q4 +- `willUsePpac` ← `WILL_USE_PPAC` flag from Q4 (set when the user picks "Open PPAC and create one manually" in the NoHost host-type menu; ensure-pipelines-host treats this as a directive to go straight to Phase 4.C) +- `chosenEnvUrl` ← `CHOSEN_ENV_URL` flag from Q4 (set when the user picks Option 2 → sub-option `a` in the NoHost host-type menu; ensure-pipelines-host treats this as a directive to skip its Phase 3.C menu and go straight to Phase 4.B with this env) +- `userChoseDeferToSetupPipeline` ← `USER_CHOSE_DEFER_TO_SETUP_PIPELINE` flag from Q4 (only set in the `MultipleUnboundCustomHosts` "Decide later" branch) + +Populate `risks` based on gathered data: +- If `HAS_ENV_VARS = true`: `{ type: "warning", message: "This solution has environment variables ({N} detected) — you will be prompted for per-stage values during deployment." }`. Substitute `{N}` from `ENV_VARS_DETAILS.length` if it's > 0, otherwise from `SPLIT_PLAN.sizeAnalysis.envVarCount.value` (the count the size estimator reported). When neither source has a positive count, drop the parenthetical (`"This solution has environment variables — you will be prompted..."`). +- If `SITE_SETTINGS_DATA.promoteToEnvVar.length > 0`: `{ type: "info", message: "{N} auth-related site settings (Authentication/* and AzureAD/*) detected with values. setup-solution will ask which to back with environment variables so each stage can use different values (e.g., different OAuth callback URLs). Skip any you don't need to vary per stage." }`. Substitute `{N}` from `SITE_SETTINGS_DATA.promoteToEnvVar.length`. (Replaces older "will be promoted" wording — that implied automatic action; in reality the user picks per setting.) +- If `SITE_SETTINGS_DATA.credentialNeedsDecision.length > 0`: `{ type: "info", message: "{N} credential-style site settings (ConsumerKey / ClientId / ClientSecret / etc.) detected. setup-solution will run a single bulk prompt to handle all of them — auto-classify by name (recommended; *Secret/Password/ApiKey/AppKey become Secret env vars, *Id/ConsumerKey become String env vars), all-as-Secret, all-as-String, skip-all, or fall through to a per-credential picker for granular control." }`. Substitute `{N}` from `SITE_SETTINGS_DATA.credentialNeedsDecision.length`. Do NOT emit any "excluded from solution / configure manually" wording — that was the pre-IronItOut behavior and it's gone. +- If `EXPORT_TYPE = "unmanaged"` and strategy includes a production target: `{ type: "warning", message: "Unmanaged solutions can be edited in the target environment — consider using Managed for production." }` +- If `SOLUTION_DONE = false`: `{ type: "info", message: "A Dataverse solution will be created first — publisher prefix is irreversible once chosen." }` +- **Always** (when planning a PP Pipelines path with `SOLUTION_DONE` becoming true after Phase 4): `{ type: "info", message: "When you add new components later (server logic, cloud flows, env vars, custom tables), re-run /power-pages:setup-solution in sync mode to bring them into this solution. The completeness check in this skill (Phase 1 Step 11) will flag any drift between the live site and the solution before the next plan-alm run." }`. Skip when `SOLUTION_DONE` is already true at plan-alm start (sync mode is already self-evident at that point). +- If `KNOWN_GAPS` is set (the pre-plan completeness check in Phase 1 Step 11 found gaps and the user chose to continue): `{ type: "warning", message: "{X} site components, {Y} cloud flows, {Z} env vars, and {W} custom tables exist on the site but are not in the current solution. This plan will not promote them — run /power-pages:setup-solution sync mode before deploying, or re-run plan-alm after syncing." }`. Substitute the counts from `KNOWN_GAPS.missing.*.length`. +- If `HOST_RESOLUTION.status === "NoHost"` AND `WILL_PROVISION_PLATFORM === true`: `{ type: "info", message: "No Pipelines host detected. setup-pipeline will provision a new Platform Host (idempotent, ~3–5 min). Plan execution will pause for a tenant-identity confirmation gate before the call." }`. Do NOT include any wording about admin-role requirements or API names — those are implementation details. +- If `HOST_RESOLUTION.status === "NoHost"` AND `WILL_PROVISION_CUSTOM === true`: `{ type: "info", message: "No Pipelines host detected. setup-pipeline will create a new Custom Host. Plan execution will pause for admin-role attestation and a pre-call confirmation." }` +- If `HOST_RESOLUTION.status === "NoHost"` AND none of the provisioning flags are set (user picked an existing env via Option 2 → sub-option `a`, or chose PPAC manual, or deferred): emit a status-appropriate info entry derived from `CHOSEN_ENV_URL` / `WILL_USE_PPAC`. +- If `HOST_RESOLUTION.status === "AvailableUnboundCustomHost"`: `{ type: "info", message: "An existing Custom Host (" + HOST_RESOLUTION.finalHostEnvUrl + ") will be reused. Source env will be bound to it automatically." }` +- If `HOST_RESOLUTION.status === "MultipleUnboundCustomHosts"`: `{ type: "info", message: HOST_RESOLUTION.candidates.existingCustomHosts.length + " existing Custom Hosts found in tenant. setup-pipeline will prompt for selection." }` +- If `HOST_RESOLUTION.status === "PlatformHostExistsUnbound"`: `{ type: "info", message: "Tenant has a Platform Host. Reusing it is the lowest-friction option; creating a Custom Host instead provides better governance for separate-tenant or governed scenarios." }` +- If `HOST_RESOLUTION.status === "CannotRedirect"`: `{ type: "warning", message: "CannotRedirect: source env ProjectHostEnvironmentId points at PE but tenant default custom host is set elsewhere. Resolution requires Power Platform admin." }` (Note: Phase 2 Q4 normally blocks plan generation in this state; this is a defensive entry in case the plan is somehow generated.) + +Write `planData` to `docs/.alm-plan-data.json` (create `docs/` if it doesn't exist). + +### Render the HTML plan + +```bash +node "${CLAUDE_PLUGIN_ROOT}/skills/plan-alm/scripts/render-alm-plan.js" \ + --output "/docs/alm-plan.html" \ + --data "/docs/.alm-plan-data.json" +``` + +**Keep `docs/.alm-plan-data.json` on disk.** Phases 5 / 6 / 7 / 8 read this file to refresh `hostResolution`, `pipelineMeta`, `validationRuns`, `risks`, and the plan footer after each run step, then re-render `docs/alm-plan.html` so the rendered tabs reflect actual run state (not the pre-run plan). The file is also what `check-alm-plan.js` reads for the Phase 0 ALM-plan gate in `setup-pipeline` / `deploy-pipeline` / `setup-solution` / `export-solution` / `import-solution` / `configure-env-variables` — deleting it makes every downstream skill think no plan exists. Earlier guidance to delete this file after the initial render was incorrect and caused the Pipelines tab + risks list to stay frozen at pre-run state for the lifetime of the plan. + +Write `docs/alm/alm-plan-context.json` (persists so `setup-solution` can read it): +```json +{ + "generatedAt": "{ISO timestamp}", + "siteName": "{siteName}", + "siteSettings": { + "keepAsIs": [{name}], + "authNoValue": [{name}], + "promoteToEnvVar": [{name, value}], + "excluded": [{name}] + } +} +``` +This file is intentionally NOT deleted — `setup-solution` and other skills read it to skip re-discovery. + +### Open the HTML plan in the user's default browser + +The inline Markdown summary presented in Phase 4 is intentionally compact — reviewers need to see the full rendered plan (size gauge, signal cards, per-solution breakdown, asset advisory, pipeline stages) before giving informed approval. Launch `docs/alm-plan.html` in the default browser **before** the approval prompt so the user can scan the full plan while reading the CLI summary. + +> **Why no Node wrapper.** The earlier `node -e "spawn('powershell.exe', [...])"` chain hits the agent's sandbox classifier (Node spawning a process that spawns another process is the textbook pattern the classifier blocks). The agent should use the **OS-native shell tool directly** — no Node detour, no `child_process`, no detached subprocess. + +**Step 1. Print the absolute `file://` URL prominently *first*.** This is the user's reliable fallback if the launcher gets blocked: + +```bash +node -e "process.stdout.write('Plan URL: file:///' + require('path').resolve('docs/alm-plan.html').replace(/\\\\/g, '/') + '\n')" +``` + +(Single Node call, no spawn, never blocked. Output: `Plan URL: file:///C:/Projects/.../docs/alm-plan.html`.) + +**Step 2. Launch the browser via the OS-native shell.** Pick the shell tool the agent has available: + +- **Windows / PowerShell tool** — call `Start-Process` directly: + ```powershell + Start-Process "docs/alm-plan.html" + ``` + +- **Windows / Bash tool (Git Bash, WSL passthrough)** — call PowerShell from Bash, but as a single direct invocation (no Node wrapper): + ```bash + powershell.exe -NoProfile -Command "Start-Process 'docs/alm-plan.html'" + ``` + +- **macOS** — call `open` directly: + ```bash + open docs/alm-plan.html + ``` + +- **Linux** — call `xdg-open` directly: + ```bash + xdg-open docs/alm-plan.html + ``` + +**Step 3. Report the URL to the user.** After the launch attempt, surface the file:// URL the agent printed in Step 1, so the user always has a clickable backup: + +> "Opened `docs/alm-plan.html` in your browser. If it didn't open automatically, use this link: `file:///C:/Projects/.../docs/alm-plan.html`. Review it, then answer the approval prompt below." + +If the launch silently fails (sandboxed terminal, SSH session, headless environment), do not retry, do not block, do not loop. Step 1's printed URL is the contract — the user can click or paste it themselves. Continue to Phase 4 and rely on the CLI summary as backup. + +Mark task 1 as `completed`. + +--- + +## Phase 4 — Present Plan and Get Approval + +Mark task 2 ("Approve ALM plan") as `in_progress`. + +Present a concise inline Markdown summary: + +``` +## ALM Plan: {siteName} + +**Strategy:** {PP Pipelines / Manual export/import} +**Stages:** {Dev} → {Staging} → {Production (if applicable)} +**Approval gates:** {description from PP_APPROVAL_MODE, or "N/A — manual path"} +**Solution export:** {Managed / Unmanaged} +**Pipeline host:** {hostEnvUrl} ({status}) — *(PP Pipelines path only; when `WILL_ENSURE_HOST = true`, render as `Will be ensured during setup-pipeline ({status})` instead)* + +**Steps that will run:** +- [ ] Setup solution {(SKIP — already set up) if SOLUTION_DONE} +- [ ] Setup pipeline {(SKIP — already set up) if PIPELINE_DONE} {(PP path only)} +- [ ] Export solution {(manual path only)} +- [ ] Import to {targetLabel} × {N} {(manual path only)} +- [ ] Deploy via pipeline {(PP path only)} + +Full plan written to: docs/alm-plan.html +``` + + +> 🚦 **Gate (plan · plan-alm:4.approve):** The capstone — user approves the rendered HTML plan before plan-alm dispatches any downstream skill. Save-for-later writes the plan but exits without execution. + +Ask via `AskUserQuestion`: +> "Does this ALM plan look correct?" + +Options: +1. **Approve and execute the plan** +2. **Save plan but execute manually later** +3. **I want to change something** — go back to questions + +- **If option 3:** Re-run Phase 2 (ask which section to change, then re-gather those answers). Regenerate the plan (repeat Phase 3). Re-present for approval. +- **If option 2:** Capture the approver (see below), stamp `` and `` in the HTML, then update HTML plan footer `plan-status` span to "Approved — Deferred" via `Edit` tool. Commit `docs/alm-plan.html` with message `"Add ALM plan for {siteName} (deferred)"`. Show next steps for manual execution. Mark task 2 as `completed`. Exit the skill. +- **If option 1:** Capture the approver (see below), stamp the HTML, then update `` to "In Execution" via `Edit` tool. **Also update `docs/.alm-plan-data.json`** — set `PLAN_STATUS: "In Execution"` and write `LAST_INVOCATION_AT: ""` (now). This is the signal `check-alm-plan.js` reads to set `inExecution.status === "active"` so the downstream skills' Phase 0 gates skip silently. `check-alm-plan.js` will refresh `LAST_INVOCATION_AT` on every subsequent invocation that finds the plan in execution, so the chain stays alive even across multi-hour deploys. Mark task 2 as `completed`. + +**Capturing the approver (both options 1 and 2):** + +Capture the name silently using git, falling back to the OS user: + +```bash +node -e "const {execSync}=require('child_process');let n='';try{n=execSync('git config user.name',{encoding:'utf8'}).trim();}catch{};if(!n){n=process.env.USER||process.env.USERNAME||'';}process.stdout.write(n);" +``` + + + +Store the output as `APPROVER`. If `APPROVER` is empty (no git config, no USER env var), ask via `AskUserQuestion`: + +> "Who is approving this plan? (needed for the audit trail in docs/alm-plan.html)" +> +> Options: 1. *{current system user from `whoami`}* · 2. Other (enter name) + +Once `APPROVER` is known, use `Edit` to replace the empty/placeholder value in `docs/alm-plan.html`: + +- Find `` (or `` / `__APPROVED_BY__`) and replace its inner text with `APPROVER`. +- Find `` and replace its inner text with the current ISO timestamp. + +Both spans are guaranteed to exist in the template — there is exactly one of each in the "Execution Checklist" tab footer. + +--- + +## Phase 5 — Execute: setup-solution (conditional) + +**If `SOLUTION_DONE = true`:** +Mark the "Setup solution" task as `completed` with description "Skipped — solution already configured". Update the HTML checklist step for "Setup solution" to `status-skipped` via `Edit` tool. Skip to Phase 6. + +**If `SOLUTION_DONE = false`:** +Mark the "Setup solution" task as `in_progress`. Update the HTML checklist step to `status-in-progress` via `Edit` tool. + +Invoke the skill: +``` +/power-pages:setup-solution +``` + +After completion: mark the task as `completed`. Update the HTML checklist step to `status-completed` via `Edit` tool. + +--- + +## Phase 6 — Execute: setup-pipeline OR export-solution + +### PP Pipelines path + +**If `PIPELINE_DONE = true`:** +Mark the "Setup pipeline" task as `completed` with description "Skipped — pipeline already configured". Update HTML checklist step to `status-skipped`. Skip to Phase 7. + +**If `PIPELINE_DONE = false`:** +Mark the "Setup pipeline" task as `in_progress`. Update HTML checklist step to `status-in-progress`. + +Invoke the skill: +``` +/power-pages:setup-pipeline +``` + +After completion: mark task as `completed`. Update HTML checklist step to `status-completed`. Then run the post-run plan refresh — this is **not optional**; without it the Pipelines tab stays at "Will be ensured during setup-pipeline" and the risks list keeps surfacing the pre-run NoHost warning even though the host now exists: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase setup-pipeline \ + --render +``` + +This (a) reads `docs/alm/last-host-check.json` and rewrites `planData.hostResolution` to the post-run state (status flips from `NoHost` to `AvailableUsingCustomHost`, all the `willEnsure*` / `willProvision*` / `chosenEnvUrl` flags clear), (b) reads `docs/alm/last-pipeline.json` and populates `planData.pipelineMeta` with the actual pipeline name + ID + host URL + stages (no `lastDeploy` yet — that fills in after Phase 7 Step A), (c) drops resolved entries from `planData.risks` (NoHost / *Unbound* / Platform-Host warnings), then (d) re-renders `docs/alm-plan.html`. If the helper exits with `ok:false`, surface the reason — the most likely cause is that `docs/.alm-plan-data.json` is missing (Phase 3 must have written it; the file should never be deleted between phases). + +### Manual path + +Mark the "Export solution" task as `in_progress`. Update HTML checklist step to `status-in-progress`. + +Invoke the skill: +``` +/power-pages:export-solution +``` + +After completion: mark task as `completed`. Update HTML checklist step to `status-completed`. + +**Refresh the plan after export.** Run the helper (export-solution self-refreshes too — this is belt-and-suspenders): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase export-solution \ + --render +``` + +The helper re-renders `docs/alm-plan.html` so the Export-step status update is visible to reviewers immediately. + + +> 🚦 **Gate (progress · plan-alm:7.manual-checkpoint):** Manual path pause between export and import — user reviews zip before import proceeds. Defer-now exits cleanly leaving the zip. + +**If `MANUAL_CHECKPOINT = true`:** Ask via `AskUserQuestion`: +> "Export complete. Review the solution zip at `{zipPath}` before importing. Ready to proceed with import?" + +Options: +1. Yes, proceed with import +2. Stop here — I'll import manually later + +If option 2: update HTML plan footer to "Approved — Deferred (paused after export)". Commit `docs/alm-plan.html`. Exit. + +--- + +## Phase 7 — Execute: Deploy + +### PP Pipelines path + +**For each target stage in `PP_STAGES` (e.g. Staging, then Production), run this loop:** + +**Step A — Deploy:** +Mark the "Deploy to {stageName}" task as `in_progress`. Update HTML checklist step to `status-in-progress`. + +Invoke the skill: +``` +/power-pages:deploy-pipeline +``` + +**Halt-on-failure check (Step A.1).** After `deploy-pipeline` returns, read `docs/alm/last-deploy.json` to see what actually happened. **Do NOT unconditionally mark the deploy task `completed`** — `deploy-pipeline` can halt at many points (validation failure, Phase 3.6 batch-validation-failed, blocked-attachments cancel, user-cancel at Phase 6.0 consent gate, mid-deploy `AttachmentBlocked`, etc.) and each writes a marker with a status field. Proceeding to Step B / the next stage on a failed deploy wastes time and produces confusing output (activate-site against a target that didn't receive the solution). + +```bash +node -e "try { const d = require('./docs/alm/last-deploy.json'); const gaps = Array.isArray(d.knownGaps) ? d.knownGaps : []; process.stdout.write(JSON.stringify({status: d.status, stageName: d.stageName, knownGaps: gaps, knownGapsCount: gaps.length})) } catch (e) { process.stdout.write(JSON.stringify({status: 'NoMarker', knownGaps: [], knownGapsCount: 0, error: e.message})) }" +``` + +The extractor returns `knownGaps: []` when the field is absent or malformed — the agent branches on `knownGapsCount > 0` rather than null/undefined checks, which avoids the ambiguity between "field missing" and "field present but empty array". + +Branch on the parsed status. **Check `knownGaps` presence first** because Phase 3.6.5's "deploy succeeded subset" path writes `status: "Succeeded"` alongside a populated `knownGaps[]` — a naive `status === "Succeeded"` branch would miss the gap signal: + +- **`knownGaps` is a non-empty array** (regardless of `status`): A subset of solutions deployed (Phase 3.6.5 "deploy succeeded subset" path) but at least one was intentionally skipped. Mark the deploy task `completed-with-gaps` (or `completed` with a note in the rendered plan). Show the user the recorded `knownGaps` and confirm they want to proceed to Step B for the partial deploy. Run the refresh-plan step regardless. +- **`status === "Succeeded"`** (and no `knownGaps`): Mark deploy task `completed`. Update HTML checklist step to `status-completed`. Proceed to the refresh-plan step below, then Step B. +- **Any other status** (`"Failed"`, `"ValidationFailed"`, `"Canceled"`, `"PendingApproval"` — user cancelled the approval pause, `"Unknown"` — poll timed out, `"NoMarker"` — the file didn't exist or was unparseable, or any unrecognised value): Mark deploy task `failed`. Update HTML checklist step to `status-failed`. Run the refresh-plan step so the rendered plan shows the failure surface. Then fire the deploy-failure gate below. The catch-all clause covers future deploy-pipeline status values without requiring this skill to be updated in lockstep. + + +> 🚦 **Gate (plan · plan-alm:7.deploy-failure):** deploy-pipeline halted before completing the deploy to `{stageName}`. Caller decides: retry this stage (re-invoke deploy-pipeline — Dataverse import idempotency makes already-succeeded solutions in a multi-solution loop a no-op), skip this stage and continue to the next (advanced — leaves a stage gap; the rendered plan will show it), or exit the orchestration. Cancel exits the loop without touching subsequent stages. + +Use `AskUserQuestion`: + + | Question | Header | Options | + |---|---|---| + | Deploy to `{stageName}` did not complete (`{status}`). What now? | Deploy failure | Retry this stage, Skip to next stage (advanced — leaves a gap), Exit orchestration | + + - **Retry**: re-invoke `/power-pages:deploy-pipeline` for this stage. Re-run the halt-on-failure check on the retry's marker. Loop up to the user's tolerance. + - **Skip to next stage**: leave the deploy task `failed`, do NOT run Step B / Step C for this stage, continue the outer `PP_STAGES` loop. The rendered plan shows a stage gap; the user owns the consequence. + - **Exit orchestration**: stop the skill. The rendered plan reflects current state (succeeded stages + the failed one). Re-invoking `/power-pages:plan-alm` later resumes from Phase 7 against the existing plan. + +**Refresh the plan after each stage's deploy.** Run the helper (regardless of success/failure — the refresh ingests both outcomes): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase deploy-pipeline \ + --render +``` + +The helper reads `docs/alm/last-deploy.json`, populates `planData.pipelineMeta.lastDeploy` (status, stageName, deployedAt, artifactVersion, componentCount, activationStatus, siteUrl), and re-renders `docs/alm-plan.html`. The Pipelines tab now shows the actual pipeline name + ACTIVE chip + last-run footer (Succeeded / Failed status + version + component count + stage label). Cheap; runs once per stage in the deploy loop. The `setStepStatus` cross-cutting behavior already maps failure-status markers to `failed` step status so the checklist surfaces what actually happened — Step A.1's halt-on-failure gate just adds the user-facing prompt that was previously missing. + +**Step B — Activate (immediately after deploy for this stage, only when Step A succeeded or completed-with-gaps):** +Mark the "Activate site in {stageName}" task as `in_progress`. Update HTML checklist step to `status-in-progress`. + +Read `docs/alm/last-deploy.json` to check whether activation already happened inside `deploy-pipeline`: +```bash +node -e "const d=require('./docs/alm/last-deploy.json'); process.stdout.write(JSON.stringify({activationStatus: d.activationStatus, siteUrl: d.siteUrl}))" +``` + +- `activationStatus === "Activated"`: site is live. Mark task `completed`. Update checklist step to `status-completed`. Show site URL. +- `activationStatus === "Pending"` or `null`: activation was deferred or didn't run inside `deploy-pipeline` Phase 7.7. Re-prompt the user. + + **Switch PAC CLI to the target environment first** — the activation flow reads the current env from `pac auth who` and looks up the site's record via `pac pages list` in that env. If PAC is still pointing at dev (or at the previous stage's env), activation would target the wrong environment. This mirrors the explicit switch pattern used by `deploy-pipeline` Phase 7.7 (the helper-and-switch-back pair around `check-activation-status.js`); plan-alm's Step C also switches PAC back at the end of the stage loop, but the forward switch BEFORE activate-site must happen here. + + ```bash + pac env select --environment "{stage.targetEnvironmentUrl}" + ``` + + Where `{stage.targetEnvironmentUrl}` is the current stage's target environment URL — pulled from `planData.stages[]` (matched by `stage.label === stageName`, then `stage.envUrl`) or equivalently from `docs/alm/last-pipeline.json` `stages[].targetEnvironmentUrl`. Do NOT skip this switch even when only one target stage exists — by the time Step B runs, PAC may already have been switched back to dev by the end of `deploy-pipeline`. + + + > 🚦 **Gate (plan · plan-alm:7.activate-step-b):** Per-stage post-deploy activation prompt — Step B second-chance when deploy-pipeline Phase 7.7 was skipped or errored. **Fires PER STAGE in the multi-stage execution loop.** Two stages (Staging + Production) where both need activation = two prompts. The "Yes, activate now" answer for Staging does NOT cover Production — each stage's activation is a distinct decision (different URLs, different audiences, different go-live timing). Do NOT batch. + + Then ask via `AskUserQuestion`: + + > "**{siteName}** was deployed to **{stageName}** successfully. The site is not yet activated (not publicly accessible). Activate it now?" + + Options: + 1. **Yes, activate now** — invoke `/power-pages:activate-site`. After it completes, mark task `completed`, update checklist step to `status-completed`. + 2. **No, skip for now** — mark task `skipped`, update checklist step to `status-skipped`. + + > **Why this gap mattered.** In the happy path, `deploy-pipeline` Phase 7.7 already activated the site (or got an explicit "No") and patched `activationStatus` into `docs/alm/last-deploy.json`. Step B sees `"Activated"` and skips. The gap shows up only when 7.7 was answered "No, I'll activate later" OR when 7.7 was skipped (e.g. activation-status check returned `error`) — in those cases Step B is the second-chance prompt, and it MUST do the PAC switch itself because 7.7's switch-back at the end of Phase 7.7 already returned PAC to dev. For a multi-stage plan this is especially important: on the Production iteration, PAC is back at dev after Staging's Step C; Step B for Production needs an explicit switch to the Production env URL, not Staging's. + +**Step C — Test site (immediately after activate for this stage):** +Mark the "Test site in {stageName}" task as `in_progress`. Update HTML checklist step to `status-in-progress`. + +Determine the URL to test: +- Prefer `siteUrl` from `docs/alm/last-deploy.json` (written by `deploy-pipeline`). +- If absent or empty, fall back to the URL returned by the most recent `activate-site` invocation for this stage. +- If both are unavailable (activation was skipped, no URL captured), mark the task `skipped` and set `validationRuns[stageName] = null`. Update checklist step to `status-skipped`. + +If a URL is available, invoke the skill (forwarding the URL as the argument): +``` +/power-pages:test-site --siteUrl {activatedUrl} +``` + +When `test-site` completes, ingest its `docs/alm/last-test-site.json` marker into `validationRuns[stageName]` and re-render via the same refresh helper used by other phases: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase test-site \ + --stageName "{stageName}" \ + --render +``` + +The helper reads `docs/alm/last-test-site.json`, populates `planData.validationRuns[{stageName}]` with the test outcome (runOutcome, summary counts, categories), and re-renders `docs/alm-plan.html` so the Validation tab updates immediately. + +`runOutcome` is set by `test-site` itself (see Phase 6.7a in the test-site skill): `"failed"` when any critical/high failure exists, `"passed-with-warnings"` for any non-critical failure or console errors, `"passed"` otherwise. Trust the value as written — do not re-compute. + +**Decision rule (non-blocking):** Regardless of `runOutcome`, mark the task `completed` and continue to the next stage. Failures are diagnostic, not gating — the plan does not abort. + +Update the HTML checklist step: +- `runOutcome === "failed"` → `status-warning` (NEW status — yellow). +- otherwise → `status-completed`. + +**Checklist substep rendering** (the renderer handles this automatically once `validationRuns` is populated and the planData re-rendered): every `Test site in {stageName}` step gets an inline substep showing the test-result badge (`PASSED` / `WARNINGS` / `FAILED`), the tested URL, the `pass / fail / skip` summary line, and a "View details →" link that jumps to the Validation tab. Every `Deploy via pipeline to {stageName}` and `Activate site in {stageName}` step also gets a `Target: ` substep so reviewers see the target env without leaving the Execution tab. The renderer derives env URLs from `data.stages[].envUrl` (matched by trailing stage label) — keep stage labels consistent across `data.stages` and `data.steps`. + +After handling activation and testing, switch PAC CLI back to the dev environment: +```bash +pac env select --environment "{devEnvUrl}" +``` + +**Then repeat Step A + B + C for the next stage** (if any). + +### Manual path (one import per target environment) + +For each entry in `MANUAL_TARGETS`: + +1. Mark the "Import to {targetLabel}" task as `in_progress`. Update the corresponding HTML checklist step to `status-in-progress`. + +2. Switch the PAC CLI context to the target environment: + ```bash + pac env select --environment "{targetEnvUrl}" + ``` + +3. Invoke the skill: + ``` + /power-pages:import-solution + ``` + +4. After completion: mark the task as `completed`. Update the HTML checklist step to `status-completed`. + +5. **Refresh the plan after the import.** Run the helper (import-solution self-refreshes too — this is belt-and-suspenders): + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase import-solution \ + --stageName "{targetLabel}" \ + --render + ``` + + `{targetLabel}` is the current iteration's target (e.g. `Staging`, `Production`). The helper reads `docs/alm/last-import.json`, writes a per-target entry into `planData.manualImports[targetLabel]` (status, version, component count, failures), and re-renders `docs/alm-plan.html`. The matching `Import to {targetLabel}` checklist step picks up an `IMPORTED` (or `FAILED`) badge with import details inline — same idiom as the test-site validation substep on PP-path Test steps. Always pass `--stageName` from the per-target loop so the helper doesn't have to fall back to URL matching. + +6. **Activate site in {targetLabel}** (optional) — mark the "Activate site in {targetLabel}" task as `in_progress`. Update HTML checklist step to `status-in-progress`. + + PAC CLI is already pointing to the target environment from step 2. Run the activation check: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/check-activation-status.js" --projectRoot "." + ``` + + - **`activated: true`**: Site is already live. Mark task as `completed`. Update checklist step to `status-completed`. + - **`activated: false`**: Invoke `/power-pages:activate-site`. After completion, mark task as `completed`. Update checklist step to `status-completed`. + - **`error`**: Mark task as `skipped`. Note error in summary. + +7. **Refresh the plan after activation.** Run the helper (activate-site self-refreshes too — this is belt-and-suspenders): + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase activate-site \ + --render + ``` + +After all imports: switch PAC CLI back to the dev environment: +```bash +pac env select --environment "{devEnvUrl}" +``` + +--- + +## Phase 8 — Finalize + +Mark the "Finalize" task as `in_progress`. + +### 8.1 Update HTML plan status + +Run the post-run plan refresh in `finalize` mode and re-render. This sets `planData.PLAN_STATUS = "Completed"` and produces the final HTML with all post-run state (latest hostResolution, pipelineMeta + lastDeploy, validationRuns, status footer) consistent across every tab: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase finalize \ + --render +``` + +If you also need to surface a timestamp in the footer (e.g. plan completion time), apply that via `Edit` tool **after** the re-render — the renderer doesn't currently emit a completion timestamp. + +### 8.2 Run skill tracking + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/update-skill-tracking.js" \ + --projectRoot "." \ + --skillName "PlanAlm" \ + --authoringTool "ClaudeCode" +``` + +### 8.3 Commit + +```bash +git add docs/alm-plan.html && git commit -m "Add ALM plan for {siteName}" +``` + +### 8.4 Present final summary + +Display a summary: + +``` +## ALM Complete: {siteName} + +**Strategy used:** {PP Pipelines / Manual export/import} +**Skills invoked:** {comma-separated list of skills that ran} + +**Artifacts created:** +- docs/alm-plan.html — ALM plan document +- .solution-manifest.json — Solution configuration {(if newly created)} +- docs/alm/last-pipeline.json — Pipeline configuration {(PP path only, if newly created)} +- docs/alm/last-deploy.json — Last deployment record {(PP path only)} +- {solutionName}_{managed|unmanaged}.zip — Solution package {(manual path only)} + +**Site activation:** { + PP path: "Activation status per stage is in docs/alm/last-deploy.json and each deploy history file." + Manual path: list each target env and its activation status (Activated / Pending) +} +``` + +Mark the "Finalize" task as `completed`. + +--- + +## Progress Tracking Table + +| Task subject | activeForm | Description | +|---|---|---| +| Generate ALM plan | Generating ALM plan | Gather strategy inputs, build planData, render docs/alm-plan.html | +| Approve ALM plan | Awaiting plan approval | Present inline summary + HTML plan path, get user confirmation | +| Setup solution | Setting up solution | Invoke setup-solution skill (skip if .solution-manifest.json exists) | +| Setup pipeline | Setting up pipeline | Invoke setup-pipeline skill — PP Pipelines path only (skip if docs/alm/last-pipeline.json exists). May delegate to ensure-pipelines-host internally to resolve or provision the host environment when `hostResolution.willEnsureDuringExecution` is true; that delegation is transparent to plan-alm and is not a separate top-level task. | +| Export solution | Exporting solution | Invoke export-solution skill — Manual path only | +| Deploy to {stageName} | Deploying to {stageName} | Invoke deploy-pipeline skill — PP Pipelines path, one task per target stage | +| Activate site in {stageName} | Activating site in {stageName} | Check activation status + invoke activate-site immediately after each stage deploys — one task per target stage | +| Test site in {stageName} | Testing site in {stageName} | Invoke /power-pages:test-site against the activated URL; capture pass/fail counts; non-blocking | +| Import to {targetEnv} | Importing solution | Switch PAC CLI context, invoke import-solution — Manual path, one task per target | +| Activate site in {targetEnv} | Activating site | Check activation status + invoke activate-site if needed — Manual path, one task per target | +| Finalize | Finalizing | Update HTML plan status, commit, run skill tracking, present summary | + +--- + +## Key Decision Points (Wait for User) + +1. **Phase 2, Q1**: Solution setup — confirm existing or include `setup-solution` in plan +2. **Phase 2, Q2**: Promotion strategy — PP Pipelines, Manual, or already set up +3. **Phase 2, Q3–Q6** (PP path): Stage count, host env, approval gates (managed auto-set) + **Phase 2, Q3–Q6** (Manual path): Target count, target env URLs, export type, checkpoint pause +4. **Phase 4**: Plan approval — execute, defer, or revise +5. **Phase 6, Manual**: Checkpoint pause after export (if Q6 = Yes) +6. **Phase 7 (delegated)**: Each invoked skill has its own approval gates + +## Error Handling + +- No `powerpages.config.json`: stop, advise `/power-pages:create-site` +- `pac env list` fails: skip ENV_LIST pre-filling; ask for environment URLs manually +- `render-alm-plan.js` fails (non-zero exit): report error, show planData JSON as fallback, ask user whether to proceed +- Invoked skill fails: report the failure, mark the task as blocked, ask user whether to retry or exit +- Plan approval = option 3 (change something): re-run Phase 2 fully, then regenerate plan — do not carry over stale answers diff --git a/plugins/power-pages/skills/plan-alm/assets/alm-plan-template.html b/plugins/power-pages/skills/plan-alm/assets/alm-plan-template.html new file mode 100644 index 000000000..495fa4e13 --- /dev/null +++ b/plugins/power-pages/skills/plan-alm/assets/alm-plan-template.html @@ -0,0 +1,471 @@ + + + + + +ALM Plan — __SITE_NAME__ + + + + +
+
+ +
+
ALM Plan — __SITE_NAME__
+
Generated __GENERATED_AT__
+
+
+ __PLAN_STATUS__ +
+ +
+ + + +
+ +
+

Overview

+

Application lifecycle plan for __SITE_NAME__. Strategy: __STRATEGY_LABEL__.

+ +
__OVERVIEW_SUMMARY__
+ +
+
__STAT_COMPONENTS__
Components
+
__STAT_ENVVARS__
Env Variables
+
__STAT_SIZE__
Est. Size (MB)
+
__STAT_SOLUTIONS__
Solutions
+
+ +

Environments

+
__STAGES_HTML__
+ +

Recommendations & Risks

+ __RISKS_HTML__ + +

ALM Strategy

+
+ __STRATEGY_RATIONALE__ +
+
+ +
+

Size Analysis

+

Estimated solution size and component signals against recommended thresholds. Estimation: __ESTIMATION_METHOD__ (±__ESTIMATION_ACCURACY__%).

+ + __SIZE_ALERT__ + + __SIZE_GAUGE__ + +

Signal classification

+
__SIGNAL_CARDS__
+ +

Breakdown by component category

+
__SIZE_BREAKDOWN__
+
+ +
+

Asset Advisory

+

Large files that should live outside the Dataverse solution. Primary recommendation: Azure Blob Storage (private, SAS-gated). Advisory only — no automated upload.

+ __ADVISORY_HTML__ +
+ +
+

Environment Variables

+

Environment-specific configuration that should differ between stages.

+ __ENVVARS_HTML__ +
+ +
+

__SOLUTIONS_TAB_TITLE__

+

__SOLUTIONS_TAB_DESC__

+ __SOLUTIONS_HTML__ +
+ +
+

__PIPELINES_TAB_TITLE__

+

__PIPELINES_TAB_DESC__

+ __PIPELINES_HOST_CARD__ + __PIPELINES_HTML__ +
+ +
+

Site Validation

+

Migration validation tests run after each target stage's deployment + activation. Each tab below corresponds to one target environment.

+ __VALIDATION_TAB__ +
+ +
+

Execution Checklist

+

Steps invoked by plan-alm after approval.

+ __CHECKLIST_HTML__ + __HOST_CHECKLIST_SUBSTEP__ +
+ Approved by: __APPROVED_BY__  ·  + Approval date: __APPROVAL_DATE__ +
+
+ +
+
+ +
AI-generated content may be incorrect · ALM Plan for __SITE_NAME__
+ + + + + diff --git a/plugins/power-pages/skills/plan-alm/scripts/render-alm-plan.js b/plugins/power-pages/skills/plan-alm/scripts/render-alm-plan.js new file mode 100644 index 000000000..d6a36d1f2 --- /dev/null +++ b/plugins/power-pages/skills/plan-alm/scripts/render-alm-plan.js @@ -0,0 +1,1503 @@ +#!/usr/bin/env node +/** + * render-alm-plan.js — Renders the ALM plan HTML from a JSON data file. + * + * Usage: + * node render-alm-plan.js --output --data + * + * Required top-level keys in the JSON data file: + * SITE_NAME, GENERATED_AT, STRATEGY, PLAN_STATUS, APPROVED_BY, APPROVAL_DATE, + * stages, steps, risks + * + * Optional v2 keys (added for split-solutions support): + * sizeAnalysis, assetAdvisory, proposedSolutions, appliedStrategies, + * recommendations, envVars, breakdown, estimationMethod, estimationAccuracyPct + */ + +const path = require('path'); +const fs = require('fs'); +const { parseArgs } = require('../../../scripts/lib/render-template'); +const { DEFAULTS: ALM_THRESHOLDS } = require('../../../scripts/lib/alm-thresholds'); + +const args = parseArgs(process.argv); + +if (!args.output || !args.data) { + console.error('Usage: node render-alm-plan.js --output --data '); + process.exit(1); +} + +const templatePath = path.join(__dirname, '..', 'assets', 'alm-plan-template.html'); +const outputPath = path.resolve(args.output); +const dataPath = path.resolve(args.data); + +if (!fs.existsSync(templatePath)) { + console.error(`Template not found: ${templatePath}`); + process.exit(1); +} +if (!fs.existsSync(dataPath)) { + console.error(`Data file not found: ${dataPath}`); + process.exit(1); +} + +let template = fs.readFileSync(templatePath, 'utf8'); +let data; +try { + data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); +} catch (e) { + console.error(`Failed to parse data file: ${e.message}`); + process.exit(1); +} + +function escapeHtml(str) { + if (str == null) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} +const tierColor = { green: 'var(--pass)', yellow: 'var(--high)', red: 'var(--critical)', unknown: 'var(--text-dim)' }; + +const strategyLabel = data.STRATEGY === 'pp-pipelines' ? 'Power Platform Pipelines' : 'Manual Export / Import'; +const proposedSolutions = Array.isArray(data.proposedSolutions) ? data.proposedSolutions : []; +const envVars = Array.isArray(data.envVars) ? data.envVars : []; +const plannedEnvVarCount = Number.isFinite(data.plannedEnvVarCount) ? Math.max(0, Math.trunc(data.plannedEnvVarCount)) : 0; +const sizeAnalysis = data.sizeAnalysis || null; +const assetAdvisory = data.assetAdvisory || { enabled: false, candidates: [], recommendation: null }; +const breakdown = data.breakdown || {}; + +// Disk-measurement cross-check. Top-level keys are the contract path (per +// SKILL.md "Disk-measurement cross-check" section). The rawDiscovery fallback +// exists for older plan files written before the hoist was specified — it +// keeps the signal-card display intact without forcing a re-render. All three +// fields are null when --projectRoot wasn't passed or no build-output dir +// was found. +const diskMeasuredMB = + data.webFilesDiskMeasuredMB != null + ? Number(data.webFilesDiskMeasuredMB) + : (data.rawDiscovery?.estimate?.webFilesDiskMeasuredMB != null + ? Number(data.rawDiscovery.estimate.webFilesDiskMeasuredMB) + : null); +const diskMeasuredPath = + data.webFilesDiskMeasuredPath || data.rawDiscovery?.estimate?.webFilesDiskMeasuredPath || null; +// Stratified-sample count surfaced under the Web Files signal so reviewers can +// see how aggressively the aggregate was extrapolated. Same hoist-then-fallback +// pattern as the disk-measurement fields above. +const webFileSampleSize = + Number.isFinite(data.webFileSampleSize) + ? Number(data.webFileSampleSize) + : (Number.isFinite(data.rawDiscovery?.estimate?.webFileSampleSize) + ? Number(data.rawDiscovery.estimate.webFileSampleSize) + : null); +const webFileCount = + Number.isFinite(data.webFileCount) + ? Number(data.webFileCount) + : (Number.isFinite(data.rawDiscovery?.estimate?.webFileCount) + ? Number(data.rawDiscovery.estimate.webFileCount) + : null); + +// Three-number semantics when the estimator ran with --solutionId: +// componentCountSiteTotal — RAW Dataverse rows on the site. What the +// Maker UI would show if the entire site +// were adopted into a solution. +// componentCountInSolution — solutioncomponents rows for the target +// solution. Matches the Maker "Objects" count. +// orphansOnSite — ppcs on the site that aren't in the solution, +// excluding stale bundle chunks. +// For the headline "X components" we prefer inSolution when present (that's +// what the pipeline ships). Fall back to siteTotal, the legacy +// sizeAnalysis.componentCount value, and finally the proposedSolutions +// aggregate when the estimator ran without a solution context — that last +// fallback prevents the Overview tab from showing 0 components when the +// Solutions tab already has accurate per-solution counts. +const fallbackComponentCount = Number(sizeAnalysis?.componentCount?.value ?? 0); +const proposedComponentCount = proposedSolutions.reduce((sum, s) => sum + Number(s?.componentCount || 0), 0); +const componentCountSiteTotal = Number(data.componentCountSiteTotal ?? fallbackComponentCount); +const componentCountInSolution = (data.componentCountInSolution == null) ? null : Number(data.componentCountInSolution); +const orphansOnSite = (data.orphansOnSite == null) ? null : Number(data.orphansOnSite); +const hasSolutionMembershipBreakout = componentCountInSolution !== null; +// Pick the first non-zero source. proposedSolutions is the last-ditch fallback — +// it holds the SPLIT_PLAN's count which compute-split-plan.js calculates from a +// different code path than estimate-solution-size.js, so it can be populated +// even when the estimator's componentCount returned 0. +const componentCount = ( + (hasSolutionMembershipBreakout ? componentCountInSolution : componentCountSiteTotal) + || proposedComponentCount +); +// Same fallback chain for total size — Overview MB stat is wrong if we trust 0 from +// sizeAnalysis when proposedSolutions has a non-zero aggregate. +const proposedTotalSizeMB = proposedSolutions.reduce((sum, s) => sum + Number(s?.sizeMB || 0), 0); +const totalSizeMB = Number(sizeAnalysis?.totalSizeMB?.value ?? 0) || proposedTotalSizeMB; +// Threshold pulled from the central source so a future bump in alm-thresholds.js +// flows through here without an additional code edit. +const SIZE_LIMIT_MB = ALM_THRESHOLDS.maxSolutionSizeMB; +const exceedsSize = totalSizeMB > SIZE_LIMIT_MB; +const sizeTier = sizeAnalysis?.totalSizeMB?.tier || 'unknown'; +const sizeColor = tierColor[sizeTier]; + +const sizeBadge = proposedSolutions.length > 1 ? 'SPLIT' : (exceedsSize ? 'SPLIT' : 'OK'); +const sizeBadgeClass = (proposedSolutions.length > 1 || exceedsSize) ? 'nav-badge-warn' : 'nav-badge-ok'; + +function buildOverviewSummary() { + const solCount = proposedSolutions.length || 1; + const strat = Array.isArray(data.appliedStrategies) && data.appliedStrategies.length > 0 + ? data.appliedStrategies.join(' + ') + : data.splitStrategy || 'single'; + + let msg = `${escapeHtml(data.SITE_NAME)} — `; + msg += `estimated at ${totalSizeMB.toFixed(1)} MB with ${componentCount.toLocaleString()} components. `; + if (solCount > 1) { + msg += `Recommendation: ${solCount} solutions (${strat}). All ship through the same pipeline as per-solution stage runs (see deploymentOrder[] in last-pipeline.json).`; + } else { + msg += 'Recommendation: single solution. Within thresholds across all signals.'; + } + if (assetAdvisory.candidates?.length > 0) { + msg += `

Asset advisory flagged ${assetAdvisory.candidates.length} file(s) for externalization to Azure Blob.`; + } + return msg; +} + +// Render a single stage card. Used by both the Overview pipeline diagram and +// the Pipelines tab body — keeping the card markup in one place ensures the +// two views stay visually consistent. Layout (top → bottom): +// 1. stage label (e.g. "Dev", "Staging", "Production") — large, bold, the +// stage's role in the pipeline +// 2. env display name (e.g. "ni-dev", "Supplier Portal Staging") — medium, +// the friendly identifier humans recognize +// 3. env URL (clickable, opens in new tab) — small, monospace, useful for +// one-click jump-to-env without leaving the plan +function buildStageCardHtml(stage) { + const activeClass = stage && stage.type === 'source' ? 'stage-active' : ''; + const label = escapeHtml(stage?.label || ''); + const envName = stage?.envName ? `
${escapeHtml(stage.envName)}
` : ''; + const envUrl = stage?.envUrl + ? `
` + : ''; + return `
+
${label}
+ ${envName} + ${envUrl} +
`; +} + +function buildStagesHtml() { + return (data.stages || []).map(buildStageCardHtml).join('\n'); +} + +function buildRisksHtml() { + const risks = Array.isArray(data.risks) ? data.risks : []; + const recs = Array.isArray(data.recommendations) ? data.recommendations : []; + const all = [...risks, ...recs]; + if (all.length === 0) { + return '
No risks or recommendations identified for this plan.
'; + } + const iconMap = { warning: '⚠', info: 'ⓘ', error: '⛔' }; + return all.map((r) => { + const t = String(r.type || 'info').toLowerCase(); + return `
${iconMap[t] || 'ⓘ'}${escapeHtml(r.message || '')}
`; + }).join('\n'); +} + +function buildStrategyRationale() { + // All multi-solution strategies ship through ONE pipeline with one stage + // per target environment. setup-pipeline Phase 6b records per-solution + // `deploymentOrder[]` in `docs/alm/last-pipeline.json`; deploy-pipeline + // loops the order, creating a stage run per solution against the same + // stage. Earlier rationale text said "each solution gets its own pipeline" + // — that described the pre-v1.3.x layout that was reverted because it + // cluttered the Pipelines UI. + const strat = data.splitStrategy || 'single'; + // Count NON-buffer solutions for the narrative — the Future Growth buffer + // is a reserved 0/0 slot that doesn't deploy until it has content. Mention + // it separately if present so the rationale and the Solutions tab agree. + const nonBufferSolutions = (proposedSolutions || []).filter((s) => !s.isFutureBuffer); + const hasFutureBuffer = (proposedSolutions || []).some((s) => s.isFutureBuffer === true); + const futureBufferNote = hasFutureBuffer ? ' Plus a reserved Future Growth buffer solution (initially empty, exists as a default target for new components).' : ''; + const changeFreqNames = nonBufferSolutions.map((s) => s.uniqueName || '').filter(Boolean); + const changeFreqList = changeFreqNames.length + ? changeFreqNames.map((n) => `${escapeHtml(n.split('_').slice(1).join('_') || n)}`).join(' → ') + : 'FoundationIntegrationConfigContent'; + const N = nonBufferSolutions.length; + // Number-word for small counts; fall back to digits for >10. + const numberWord = (n) => ({ 1: 'One', 2: 'Two', 3: 'Three', 4: 'Four', 5: 'Five', 6: 'Six', 7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten' }[n] || String(n)); + const map = { + 'single': `All components packaged in a single managed solution. Estimated size is within the ${ALM_THRESHOLDS.maxSolutionSizeMB} MB split-decision threshold (platform hard cap is 95 MB) and component count is within tested bounds. One pipeline, one approval chain.`, + 'strategy-1-layer': `Components split into Core (schema, security, integrations, config) and WebAssets (web files). Both ship through the same pipeline as separate stage runs (per the deploymentOrder[] in last-pipeline.json) — WebAssets can be re-deployed independently when only frontend files change.${futureBufferNote}`, + 'strategy-2-change-frequency': `${numberWord(N || 4)} solutions ordered by change frequency: ${changeFreqList}. All ship through the same pipeline in that order, so low-churn layers don\'t re-import when only content changes.${futureBufferNote}`, + 'strategy-3-schema-segmentation': `Tables split by domain into per-domain solutions. A separate Site solution imports last. All solutions ship through the same pipeline in domain order. Warning: schema-heavy imports can take 10+ hours per stage — test in staging and avoid peak hours.${futureBufferNote}`, + 'strategy-4-config-isolation': `Environment variable definitions isolated into their own solution so value changes don't require re-importing everything else.${futureBufferNote}`, + }; + let rationale = map[strat] || map.single; + if (data.compositeSubPartitioned === true) { + // Composite path: Layer split fired AND Core busted a cap, so Core was + // sub-partitioned into Foundation/Config/Content (+ Integration when + // flows or bots exist). Without this sentence the rationale describes + // Core + WebAssets but the Solutions tab shows 4+ entries, which reads + // like the rationale and the table disagree. + rationale += ' Core was further sub-partitioned because it still exceeded the size or component-count cap after Web Assets were peeled off — it now ships as separate Foundation, Config, and Content solutions (plus Integration when the parent had flows or bots). All sub-solutions deploy through the same pipeline as separate stage runs.'; + } + if (data.appliedStrategies?.includes('strategy-4-config-isolation') && strat !== 'strategy-4-config-isolation') { + rationale += ' Additionally, env var definitions are isolated into a dedicated EnvVars solution (additive Strategy 4).'; + } + return rationale; +} + +function buildSizeAlert() { + if (proposedSolutions.length > 1) { + return `
+ +
${proposedSolutions.length} solutions recommended. See the Solutions tab for the split layout and Pipelines for per-solution deployment order.
+
`; + } + if (exceedsSize) { + return `
+ 🚨 +
Estimated size ${totalSizeMB.toFixed(1)} MB exceeds the recommended ${SIZE_LIMIT_MB} MB cap.
+
`; + } + return `
+ +
Within recommended limits. No split is required.
+
`; +} + +function buildSizeGauge() { + const maxDisplay = Math.max(totalSizeMB, SIZE_LIMIT_MB) * 1.3; + const fillPct = Math.min((totalSizeMB / maxDisplay) * 100, 100); + const threshPct = (SIZE_LIMIT_MB / maxDisplay) * 100; + const fillColor = exceedsSize + ? 'linear-gradient(90deg, #ca5010 0%, #d13438 100%)' + : 'linear-gradient(90deg, #107c10 0%, #0078d4 100%)'; + // When the fill is narrow (small solutions, e.g. 4.9 MB / 95 MB ≈ 4%), the + // inline "4.9 MB" label overflows the pill and renders as a floating chip + // that reads like a different number. Drop the inline label below ~15% — + // the headline size-gauge-value already shows the exact MB value on the + // right, so this isn't a loss of information, just less visual noise. + const showInlineLabel = fillPct >= 15; + const inlineLabel = showInlineLabel + ? `${totalSizeMB.toFixed(1)} MB` + : ''; + return `
+
+
+
Total Estimated Size
+
Recommended limit: ${SIZE_LIMIT_MB} MB
+
+
+
${totalSizeMB.toFixed(1)} MB
+
${exceedsSize ? (totalSizeMB - SIZE_LIMIT_MB).toFixed(1) + ' MB over limit' : (SIZE_LIMIT_MB - totalSizeMB).toFixed(1) + ' MB under limit'}
+
+
+
+
+ ${inlineLabel} +
+
+
${SIZE_LIMIT_MB} MB limit
+
+
+
`; +} + +function buildSignalCards() { + if (!sizeAnalysis) return '
Size analysis unavailable.
'; + // Thresholds align with alm-thresholds.js DEFAULTS — bumped tighter than the + // platform hard caps (95 MB / 6000 components) to reserve growth headroom. + const signals = [ + { key: 'totalSizeMB', label: 'Size (MB)', fmt: (v) => Number(v).toFixed(1), threshold: `< ${ALM_THRESHOLDS.maxSolutionSizeMB} MB` }, + { key: 'componentCount', label: 'Components', fmt: (v) => Number(v).toLocaleString(), threshold: `< ${ALM_THRESHOLDS.maxComponentCount.toLocaleString()}` }, + { key: 'schemaAttrCount', label: 'Schema Attrs', fmt: (v) => Number(v).toLocaleString(), threshold: `< ${ALM_THRESHOLDS.maxSchemaAttrs.toLocaleString()}` }, + { key: 'tableCount', label: 'Tables', fmt: (v) => Number(v).toLocaleString(), threshold: `< ${ALM_THRESHOLDS.maxTableCount}` }, + { key: 'webFilesAggregateMB', label: 'Web Files (MB)', fmt: (v) => Number(v).toFixed(1), threshold: `< ${ALM_THRESHOLDS.maxAggregateWebFilesMB} MB` }, + { key: 'envVarCount', label: 'Env Vars', fmt: (v) => Number(v).toLocaleString(), threshold: `< ${ALM_THRESHOLDS.maxEnvVarCount}` }, + ]; + return signals.map((s) => { + const a = sizeAnalysis[s.key]; + if (!a) return ''; + const tier = a.tier || 'unknown'; + const color = tierColor[tier]; + // Env Vars signal shows existing-vs-planned dual count when applicable — + // a fresh project (envVarCount.value === 0) with K planned should not + // appear empty in the Size Analysis tab. + let valueDisplay; + if (s.key === 'envVarCount') { + valueDisplay = escapeHtml(envVarStatDisplay()); + } else { + valueDisplay = s.fmt(a.value || 0); + } + // Web Files signal annotations: + // (a) disk-compare note — fires when --projectRoot was passed and the + // disk-measured number disagrees materially with Dataverse (same + // condition as the estimator's undercount canary). Surfaces the + // actual MB delta + the disk path so a reviewer can see at a glance + // which to trust. The canary's warning is also in the risks list, + // but inline is much more discoverable. + // (b) sample-extrapolation note — fires when the stratified sampler + // measured fewer files than the total count (>150). Tells the + // reviewer how aggressively the aggregate was scaled up. + let signalExtras = ''; + if (s.key === 'webFilesAggregateMB') { + if (diskMeasuredMB != null && diskMeasuredMB > 5) { + const dv = Number(a.value || 0); + if (dv < 0.5 * diskMeasuredMB) { + const pathAttr = diskMeasuredPath ? ` title="${escapeHtml(diskMeasuredPath)}"` : ''; + signalExtras += `
Disk: ${diskMeasuredMB.toFixed(1)} MB — Dataverse-measured is < 50% of the local build output. File-typed columns may be holding bytes $select=content can't return. Trust the disk number.${diskMeasuredPath ? ` (measured from ${escapeHtml(diskMeasuredPath)})` : ''}
`; + } + } + if ( + webFileSampleSize != null && + webFileCount != null && + webFileSampleSize > 0 && + webFileCount > webFileSampleSize + ) { + signalExtras += `
Aggregate extrapolated from a stratified sample of ${webFileSampleSize} of ${webFileCount.toLocaleString()} web files.
`; + } + } + return `
+
${s.label}
+
${valueDisplay}
+ ${signalExtras} +
`; + }).join('\n'); +} + +function buildSizeBreakdown() { + const entries = [ + { label: 'Tables & Columns', key: 'tables', color: '#0078d4' }, + { label: 'Web Files', key: 'webFiles', color: '#ca5010' }, + { label: 'Cloud Flows', key: 'cloudFlows', color: '#5c2d91' }, + { label: 'Site Settings', key: 'siteSettings', color: '#8764b8' }, + { label: 'Web Roles & Permissions', key: 'webRolesAndPermissions', color: '#107c10' }, + { label: 'Environment Variables', key: 'envVars', color: '#038387' }, + { label: 'Other Metadata', key: 'otherMetadata', color: '#8890a4' }, + ].map((e) => ({ ...e, sizeMB: Number(breakdown[e.key] || 0) })) + .filter((e) => e.sizeMB > 0) + .sort((a, b) => b.sizeMB - a.sizeMB); + + if (entries.length === 0) return '
Breakdown not available.
'; + const max = Math.max(...entries.map((e) => e.sizeMB)); + const total = entries.reduce((s, e) => s + e.sizeMB, 0); + return entries.map((e) => { + const barPct = Math.max((e.sizeMB / max) * 100, 2); + const pctOfTotal = ((e.sizeMB / total) * 100).toFixed(1); + return `
+
${e.label}
+
+
+ ${barPct > 15 ? `${pctOfTotal}%` : ''} +
+
+
${e.sizeMB.toFixed(1)} MB
+
`; + }).join('\n'); +} + +function buildAdvisoryHtml() { + if (!assetAdvisory.enabled) { + return '
Asset advisory is disabled in .alm-config.json.
'; + } + const candidates = assetAdvisory.candidates || []; + if (candidates.length === 0) { + return `
No assets flagged for externalization. All web files are under the individual-file threshold (${ALM_THRESHOLDS.maxSingleFileMB} MB) or excluded by patterns.
`; + } + let html = ''; + if (assetAdvisory.recommendation === 'externalize-media') { + html += `
+
Bulk externalization recommended. Aggregate web file size and media ratio indicate the bundle is dominated by images/fonts. Moving these to Azure Blob (or CDN) will reduce solution size meaningfully and can avoid the need for a split.
`; + } + html += candidates.map((c) => `
+
${Number(c.sizeMB || 0).toFixed(1)} MB
+
+
${escapeHtml(c.name)}
+
${escapeHtml(c.rationale || '')}
+
→ ${escapeHtml(c.suggestedUrlFormat || '')}
+
+ ${c.recommendation === 'cdn' ? 'CDN' : 'Azure Blob'} +
`).join('\n'); + return html; +} + +function envVarSummaryCount() { + // Source of truth when per-variable details haven't been enumerated into + // envVars[] yet. The size estimator counts env var definitions matching the + // publisher prefix during plan-alm Phase 1 and stores the count in + // sizeAnalysis.envVarCount.value — that count is what drives the size + // signal card and the agent-generated "(N detected)" warning. + const v = sizeAnalysis?.envVarCount?.value; + return Number.isFinite(v) ? Math.max(0, Math.trunc(v)) : 0; +} + +// Existing env var definitions found on the live env (envVars[] populated by +// discover-env-var-definitions.js, with size-estimator count as fallback). +function envVarExistingCount() { + return envVars.length || envVarSummaryCount(); +} + +// "N today / +M planned" or "N today" depending on which counts are populated. +// The dual-count display is critical for fresh projects — the renderer was +// previously showing 0 even when the risks list said "K auth settings will be +// promoted to env vars", which read as a bug. Showing both counts keeps the +// stat card and the risks list internally consistent. +function envVarStatDisplay() { + const existing = envVarExistingCount(); + const planned = plannedEnvVarCount; + if (existing === 0 && planned > 0) return `0 / +${planned} planned`; + if (existing > 0 && planned > 0) return `${existing} / +${planned} planned`; + return String(existing); +} + +// Builds one expandable card per existing env var. Mirrors the mock layout: +// display name (friendly heading) on the row, schema name + type/bound setting +// + default value + description inside the body, and a per-environment values +// table when ev.values is populated. The card class is hooked by the template's +// click-to-toggle JS (see alm-plan-template.html). +function buildEnvVarCard(ev) { + const displayName = ev.displayName || ev.schemaName || '(unnamed env var)'; + const type = ev.type || 'String'; + const schemaName = ev.schemaName || ''; + const siteSetting = ev.siteSetting || ''; + const defaultValue = ev.defaultValue == null ? '' : String(ev.defaultValue); + const description = ev.description || ''; + const rationale = ev.rationale || ''; + const values = (ev.values && typeof ev.values === 'object') ? ev.values : {}; + + const fieldBlocks = [ + `
Schema Name
${escapeHtml(schemaName)}
`, + `
Data Type
${escapeHtml(type)}
`, + `
Bound Site Setting
${ + siteSetting + ? `${escapeHtml(siteSetting)}` + : '— (not bound to a site setting)' + }
`, + `
Default Value
${ + defaultValue + ? `${escapeHtml(defaultValue)}` + : '— (no default)' + }
`, + ]; + if (description) { + fieldBlocks.push(`
Description
${escapeHtml(description)}
`); + } + + let rationaleBlock = ''; + if (rationale) { + rationaleBlock = `
+
Reasoning
+
${escapeHtml(rationale)}
+
`; + } + + let perEnvBlock = ''; + const envNames = Object.keys(values); + if (envNames.length > 0) { + const rows = envNames.map((e) => `${escapeHtml(e)}${escapeHtml(values[e] || '')}`).join(''); + perEnvBlock = `
+
Values by Environment
+
${rows}
EnvironmentValue
+
`; + } + + return `
+
+ ENV VAR + ${escapeHtml(displayName)} + ${escapeHtml(type)} + +
+
+
${fieldBlocks.join('')}
+ ${rationaleBlock} + ${perEnvBlock} +
+
`; +} + +// Side-by-side matrix: one row per env var, one column per environment. +// Renders only when at least one env var has a populated values{} map (i.e. +// after deploy-pipeline has back-filled per-stage values). +// +// Stage-key canonicalization: deployment-settings.json + planData.envVars[].values +// can carry stage keys under multiple aliases — the stage `label` ("Staging"), +// the pipeline-stage display name ("Deploy to Staging"), or the environment's +// BAP display name ("CitizenServicesStaging"). All three refer to the same +// target. Without dedup, a 2-env deploy renders THREE columns (Dev, Staging, +// "Deploy to Staging") with the last two identical. We build an alias→label +// map from data.stages[] and last-pipeline.json (when present) and collapse +// every observed alias onto its canonical stage label before assembling the +// header. Aliases that don't match any known stage stay as-is so unknown +// keys aren't silently dropped. +function buildEnvVarValuesMatrix() { + // Build alias → canonical label map from the plan's stage list. + const aliasToLabel = new Map(); + const canonicalLabels = []; + const stagesArr = (Array.isArray(data) ? null : (data && data.stages)) || []; + for (const stage of stagesArr) { + if (!stage || typeof stage !== 'object') continue; + const label = String(stage.label || '').trim(); + if (!label) continue; + if (!canonicalLabels.includes(label)) canonicalLabels.push(label); + aliasToLabel.set(label, label); + // Common aliases observed in deployment-settings.json / pipeline stage names. + if (stage.envName) aliasToLabel.set(String(stage.envName), label); + if (stage.envUrl) aliasToLabel.set(String(stage.envUrl), label); + aliasToLabel.set(`Deploy to ${label}`, label); + aliasToLabel.set(`${label} (Deploy to ${label})`, label); + } + // Also pick up stage labels from pipelineMeta.stages[] when present. + const pipelineStages = (data && data.pipelineMeta && Array.isArray(data.pipelineMeta.stages)) + ? data.pipelineMeta.stages : []; + for (const ps of pipelineStages) { + if (!ps || typeof ps !== 'object') continue; + const psName = String(ps.name || '').trim(); + if (!psName) continue; + // "Deploy to Staging" → canonical "Staging" when "Staging" is a known label; + // otherwise treat the pipeline name itself as canonical. + const stripped = psName.replace(/^Deploy to\s+/i, '').trim(); + const canonical = canonicalLabels.includes(stripped) ? stripped : psName; + if (!aliasToLabel.has(psName)) aliasToLabel.set(psName, canonical); + if (!canonicalLabels.includes(canonical)) canonicalLabels.push(canonical); + } + + // Collect canonical stage names actually observed across env vars (preserving stage order). + const observedLabels = new Set(); + for (const ev of envVars) { + if (ev.values && typeof ev.values === 'object') { + for (const key of Object.keys(ev.values)) { + const canonical = aliasToLabel.get(key) || key; // unknown aliases stay as-is + observedLabels.add(canonical); + } + } + } + if (observedLabels.size === 0) return ''; + // Order: stages from planData first (in plan order), then any leftovers from unknown aliases. + const envNames = [ + ...canonicalLabels.filter((l) => observedLabels.has(l)), + ...Array.from(observedLabels).filter((l) => !canonicalLabels.includes(l)), + ]; + + const headerCells = envNames.map((e) => `${escapeHtml(e)}`).join(''); + const rows = envVars.map((ev) => { + const label = escapeHtml(ev.displayName || ev.schemaName || '(unnamed)'); + // Resolve each canonical column to its alias on the env var's values{} map. + // Picks the first matching alias so duplicates (e.g. "Staging" AND "Deploy to Staging" + // both pointing to the same target) collapse to one column. + const cells = envNames.map((e) => { + const valuesMap = (ev.values || {}); + let v = valuesMap[e]; // exact canonical hit + if (v == null || v === '') { + for (const [alias, canonical] of aliasToLabel.entries()) { + if (canonical === e && valuesMap[alias] != null && valuesMap[alias] !== '') { + v = valuesMap[alias]; + break; + } + } + } + return v == null || v === '' + ? '(not set)' + : `${escapeHtml(v)}`; + }).join(''); + return `${label}${cells}`; + }).join(''); + + return `

Values by Environment

+

Per-stage values for each environment variable. Set the correct value in each target before importing the solution.

+
+ + ${headerCells} + ${rows} +
Environment Variable
+
`; +} + +function buildEnvVarsHtml() { + const existing = envVars.length; + const planned = plannedEnvVarCount; + + // Empty state: neither existing nor planned env vars. + if (existing === 0 && planned === 0) { + const summaryCount = envVarSummaryCount(); + if (summaryCount > 0) { + // The size estimator found definitions but envVars[] wasn't enumerated + // (publisher prefix or token unavailable during plan-alm Step 10b). + const noun = summaryCount === 1 ? 'definition' : 'definitions'; + return `
${summaryCount} environment variable ${noun} detected. Per-variable details (schema name, type, bound site setting) will be reviewed during setup-solution / configure-env-variables, and per-stage values will be collected before deploy-pipeline.
`; + } + return '
No environment variable definitions detected. If environment-specific values are needed (URLs, client IDs, endpoints), they can be added during Setup Solution.
'; + } + + // Build optional sections: existing cards + planned summary + values matrix. + // All can render together when the project has some env vars already and + // more are planned. + const sections = []; + + if (existing > 0) { + const cards = envVars.map(buildEnvVarCard).join('\n'); + sections.push(`

Existing environment variables (${existing})

+${cards}`); + + // Comparison matrix renders inline below the cards once per-stage values + // are known. Empty until deploy-pipeline back-fills via refresh-alm-plan-data. + const matrix = buildEnvVarValuesMatrix(); + if (matrix) sections.push(matrix); + } + + if (planned > 0) { + const noun = planned === 1 ? 'environment variable' : 'environment variables'; + sections.push(`

Planned ${noun} (${planned})

+
setup-solution will walk through these ${planned} candidate site setting${planned === 1 ? '' : 's'} (auth-related and credential-style) one at a time. For each, you'll pick whether to back it with a Secret-typed env var (Key Vault per stage), a String-typed env var (plain text per stage), or skip — so the realized env-var count is typically lower than the candidate count. Per-variable details (schema name, type, bound site setting, default value) populate this tab automatically once setup-solution finishes and the plan is refreshed via refresh-alm-plan-data.js (or by re-running /power-pages:plan-alm).
`); + } + + // Always close with a one-line refresh hint when either count is non-zero, + // so users know to re-render after each ALM phase completes. + sections.push('
This tab back-fills automatically after setup-solution (creates definitions) and deploy-pipeline (records per-stage values). If counts look stale, run the relevant ALM skill again or re-render the plan.
'); + + return sections.join('\n'); +} + +function buildSolutionsTabTitle() { return proposedSolutions.length > 1 ? `Solutions (${proposedSolutions.length})` : 'Solution'; } +function buildSolutionsTabDesc() { + return proposedSolutions.length > 1 + ? `Split into ${proposedSolutions.length} managed solutions per the decision tree. Deploy in order shown below.` + : 'All components packaged in a single managed solution.'; +} + +function buildAssetAdvisoryCallout() { + // Surface the advisory on the Solutions tab when the primary recommendation + // is to move assets out of the solution. Without this pointer, users only + // see "N proposed solutions" and miss the fact that a CDN/Blob move would + // likely avoid the split altogether. + if (!assetAdvisory.enabled) return ''; + if (assetAdvisory.recommendation !== 'externalize-media') return ''; + const candidateCount = Array.isArray(assetAdvisory.candidates) ? assetAdvisory.candidates.length : 0; + const candidateMB = Array.isArray(assetAdvisory.candidates) + ? assetAdvisory.candidates.reduce((s, c) => s + Number(c.sizeMB || 0), 0).toFixed(1) + : '0.0'; + return `
`; +} + +function buildSolutionMembershipBanner() { + // When the estimator had a --solutionId context, show the site-vs-solution + // split so reviewers can reconcile what they see in the Maker UI with what + // ships. Numbers are all raw row counts — bundle chunks included — so they + // match the "Objects" page in Power Platform's solution explorer. + if (!hasSolutionMembershipBreakout) return ''; + const orphansClass = (orphansOnSite && orphansOnSite > 0) ? 'warn' : 'pass'; + const orphansNote = (orphansOnSite && orphansOnSite > 0) + ? ` · ${orphansOnSite.toLocaleString()} actionable orphan(s) on the site are NOT in this solution — run /power-pages:setup-solution in sync mode to adopt them. (Stale bundle-chunk orphans are excluded from this count.)` + : ` · solution is fully in sync with the site (no actionable orphans).`; + return `
+ Solution membership vs. site inventory. + The site holds ${componentCountSiteTotal.toLocaleString()} raw rows in Dataverse; the target solution owns ${componentCountInSolution.toLocaleString()} components.${orphansNote} +
`; +} + +function buildSynthesizedSingleSolution() { + // Compose a single proposedSolution entry from other planData fields when + // the caller passed proposedSolutions = []. Returns null if there isn't + // enough information to synthesize anything useful. + // + // Sources, in priority order: + // - data.solutionContents.solution (if the orchestrator wrote it) + // - data.solution / data.SOLUTION_INFO (legacy field) + // - .solution-manifest.json data already merged into planData + // - Fall back to SITE_NAME for both unique and display names + + const fromContents = (data.solutionContents && data.solutionContents.solution) || null; + const fromTopLevel = data.solution || data.SOLUTION_INFO || null; + const src = fromContents || fromTopLevel || {}; + + const uniqueName = src.uniqueName || src.unique_name || data.solutionUniqueName || + (data.SITE_NAME ? String(data.SITE_NAME).replace(/\s+/g, '') : null); + const displayName = src.friendlyName || src.displayName || data.SITE_NAME || uniqueName; + + if (!uniqueName && !displayName) return null; + + return { + uniqueName: uniqueName || 'Solution', + displayName: displayName || uniqueName || 'Solution', + order: 1, + sizeMB: totalSizeMB || 0, + componentCount: componentCount || 0, + componentTypes: ['All site components'], + tableLogicalNames: Array.isArray(data.solutionContents && data.solutionContents.tables) ? data.solutionContents.tables : [], + description: 'Single managed solution containing all Power Pages site components. No split was recommended by the size estimator.', + isFutureBuffer: false, + }; +} + +function buildSolutionsHtml() { + // Safety net: when proposedSolutions is empty (caller forgot to populate + // the single-solution entry, or planData was hand-built), synthesize one + // base-solution entry from other planData fields rather than showing the + // useless "structure will be determined" placeholder. Reviewers always + // see SOMETHING about the solution that's about to ship. + if (proposedSolutions.length === 0) { + const synthesized = buildSynthesizedSingleSolution(); + if (synthesized) { + proposedSolutions.push(synthesized); + } else { + return '
Solution structure will be determined during Setup Solution. (Fallback shown because planData.proposedSolutions was empty — populate it from the size estimator output for a richer view.)
'; + } + } + const membershipHtml = buildSolutionMembershipBanner(); + const calloutHtml = buildAssetAdvisoryCallout(); + const colors = ['#0078d4', '#ca5010', '#107c10', '#8764b8', '#038387', '#5c2d91']; + const cards = proposedSolutions.map((sol, i) => { + const color = colors[i % colors.length]; + const overLimit = sol.sizeMB > SIZE_LIMIT_MB; + const sColor = overLimit ? 'var(--high)' : 'var(--pass)'; + const componentTypes = Array.isArray(sol.componentTypes) ? sol.componentTypes.join(', ') : ''; + const tables = Array.isArray(sol.tableLogicalNames) && sol.tableLogicalNames.length > 0 + ? `

Tables in this solution

${sol.tableLogicalNames.map((t) => `${escapeHtml(t)}`).join('')}
` + : ''; + return `
+
+
${sol.order || i + 1}
+
+
${escapeHtml(sol.displayName || sol.uniqueName)}
+
${escapeHtml(sol.uniqueName)}
+
+
+ ${Number(sol.sizeMB || 0).toFixed(1)} + MB +
+ +
+
+
${escapeHtml(sol.description || '')}
+
+

Component types

${escapeHtml(componentTypes)}
+

Component count (est.)

${(sol.componentCount || 0).toLocaleString()}
+
+ ${tables} +
+
`; + }).join('\n'); + return `${membershipHtml}${calloutHtml}${cards}`; +} + +function buildPipelinesTabTitle() { + // We always provision a single pipeline now, even in multi-solution plans — + // multi-solution is expressed via deploymentOrder against the same pipeline. + return 'Deployment Pipeline'; +} +function buildPipelinesTabDesc() { + const nDeployable = proposedSolutions.filter((s) => !s.isFutureBuffer).length; + return proposedSolutions.length > 1 + ? `One Power Platform Pipeline runs ${nDeployable} solution${nDeployable === 1 ? '' : 's'} in dependency order against each target environment. The empty Future solution is created but skipped during deployment until it has content.` + : `Power Platform Pipelines configuration for promoting ${escapeHtml(data.SITE_NAME)} across environments.`; +} + +function buildPipelineActiveAnnotations(meta, color) { + // Renders the chips/notes that mark a pipeline as the one currently being + // used to move configurations — only emitted when planData has a + // pipelineMeta block (i.e. docs/alm/last-pipeline.json exists for this project). + if (!meta || !meta.isActive) return { chip: '', wiringNote: '', lastRunFooter: '' }; + + const chip = `ACTIVE`; + + let wiringNote = ''; + if (meta.reusedByWiring && typeof meta.reusedByWiring === 'object') { + const orig = escapeHtml(meta.reusedByWiring.originalName || ''); + const req = escapeHtml(meta.reusedByWiring.requestedName || ''); + wiringNote = `
+ Reused — matched on source+target wiring. Original pipeline name: ${orig}${req ? ` (requested name was ${req})` : ''}. +
`; + } + + let lastRunFooter = ''; + const ld = meta.lastDeploy; + if (ld && typeof ld === 'object') { + const status = String(ld.status || ''); + const sLow = status.toLowerCase(); + const statusColor = sLow === 'succeeded' ? 'var(--pass)' : (sLow === 'failed' ? 'var(--critical)' : 'var(--high)'); + const parts = []; + if (ld.artifactVersion) parts.push(`v${escapeHtml(ld.artifactVersion)}`); + parts.push(`${escapeHtml(status || 'unknown')}`); + if (ld.stageName) parts.push(escapeHtml(ld.stageName)); + if (ld.deployedAt) parts.push(`${escapeHtml(ld.deployedAt)}`); + if (ld.componentCount != null) parts.push(`${Number(ld.componentCount)} components`); + lastRunFooter = `
+ Last run: ${parts.join(' · ')} +
`; + } + return { chip, wiringNote, lastRunFooter }; +} + +function buildPipelinesHtml() { + const colors = ['#0078d4', '#ca5010', '#107c10', '#8764b8', '#038387']; + const stages = Array.isArray(data.stages) ? data.stages : []; + // Reuse the shared card builder so the Pipelines tab body matches the + // Overview pipeline diagram exactly (env name + clickable URL). Without + // this the two views drifted — Overview included envName, Pipelines tab + // didn't. + const stagesHtml = stages.map(buildStageCardHtml).join(''); + + const meta = data.pipelineMeta && typeof data.pipelineMeta === 'object' ? data.pipelineMeta : null; + const activeColor = colors[0]; + const ann = buildPipelineActiveAnnotations(meta, activeColor); + + // Pipeline name: prefer the actual provisioned name when known. + const synthesizedName = `${escapeHtml(data.SITE_NAME || 'Site')}-Pipeline`; + const pipelineName = meta && meta.pipelineName + ? escapeHtml(meta.pipelineName) + : synthesizedName; + + if (proposedSolutions.length > 1) { + const nDeployable = proposedSolutions.filter((s) => !s.isFutureBuffer).length; + const header = `
+ + ${pipelineName}${ann.chip} + 1 pipeline · ${nDeployable} run${nDeployable === 1 ? '' : 's'} +
${ann.wiringNote}${ann.lastRunFooter} +
${stagesHtml}
`; + + // Deployment order list — each solution is a stage run. Future buffer shown + // distinctly so reviewers understand it's created but not deployed yet. + const orderRows = proposedSolutions.map((sol, i) => { + const color = colors[i % colors.length]; + const isFuture = !!sol.isFutureBuffer; + const label = isFuture ? 'Skipped (empty)' : `Run ${sol.order || i + 1}`; + const labelColor = isFuture ? 'var(--text-dim)' : color; + return `
+ + ${escapeHtml(sol.uniqueName)} + ${label} +
`; + }).join(''); + + return `${header} +
+

Deployment order

+ ${orderRows} +
`; + } + + // Single-solution path. Show a header with the pipeline name + ACTIVE chip + // when meta is present; otherwise just the stage flow (preserves prior look + // for fresh/unconfigured plans). + if (meta) { + return `
+ + ${pipelineName}${ann.chip} +
${ann.wiringNote}${ann.lastRunFooter} +
${stagesHtml}
`; + } + return `
${stagesHtml}
`; +} + +function buildValidationTab(d) { + // Renders the full "Site Validation" tab body. One sub-tab per target stage. + // Each sub-tab shows a summary grid + per-category test cards. + // + // Data shape (from plan-alm Phase 7 Step C, ingesting test-site's docs/alm/last-test-site.json): + // data.validationRuns = { + // "": null | { + // url, runAt, durationSec, runOutcome, + // summary: { critical, high, medium, low, total, automated, manual, + // passed, failed, skipped }, + // categories: [ + // { id, name, icon, tests: [ + // { id, name, severity, type, status, description, steps[], + // expected, actual, validates } + // ]} + // ] + // } + // } + // + // For stages in data.stages with type === 'target' that have no entry (or null), + // an empty-state pane is rendered so reviewers see the stage but understand + // testing hasn't run yet. + + const targetStages = (Array.isArray(d.stages) ? d.stages : []) + .filter((s) => s && s.type === 'target' && s.label) + .map((s) => s.label); + const runs = (d.validationRuns && typeof d.validationRuns === 'object') ? d.validationRuns : {}; + + // Stage list = union of target stages + any stage names already captured. + // Preserve target-stage order; append unknown stages last (rare, but possible + // if stages were renamed mid-run). + const allStages = [...targetStages]; + for (const stageName of Object.keys(runs)) { + if (!allStages.includes(stageName)) allStages.push(stageName); + } + + if (allStages.length === 0) { + return `
+
No target stages defined — nothing to validate. Validation runs after each stage's deployment + activation.
+
`; + } + + const safeId = (s) => String(s).replace(/[^A-Za-z0-9_-]+/g, '_'); + + function statusBadgeForStage(run) { + if (!run) { + return `Not run`; + } + const o = String(run.runOutcome || '').toLowerCase(); + if (o === 'failed') return `Failed`; + if (o === 'passed-with-warnings') return `Warnings`; + return `Passed`; + } + + // Sub-tab bar + const subtabBar = allStages.map((stageName, i) => { + const run = runs[stageName] || null; + const id = safeId(stageName); + return ``; + }).join(''); + + // Per-stage panes + const panes = allStages.map((stageName, i) => { + const run = runs[stageName] || null; + const id = safeId(stageName); + const paneClass = `vstage-pane${i === 0 ? ' active' : ''}`; + return `
${buildValidationStagePane(stageName, run)}
`; + }).join(''); + + return `
+
+ Migration validation tests run after each target stage's deployment and activation. Each tab below corresponds to one target environment. Tests are categorized and grouped by severity — Critical failures should be addressed before promoting to the next stage; lower-severity findings are diagnostic only. +
+
${subtabBar}
+
${panes}
+
`; +} + +function buildValidationStagePane(stageName, run) { + if (!run) { + return `
Not yet tested. /power-pages:test-site runs automatically after this stage's deployment and activation.
`; + } + + const url = run.url ? `${escapeHtml(run.url)}` : ''; + const dur = (run.durationSec != null) ? `${Number(run.durationSec).toFixed(0)}s` : '—'; + const runAt = run.runAt ? `${escapeHtml(run.runAt)}` : '—'; + + // Severity buckets — render four cards (Critical / High / Medium / Low) separately + // plus a Total. Previously Medium and Low were combined ("Medium / Low") which + // hid a real severity signal from reviewers and validators reading the plan; + // a stage that ships 4 medium-severity issues looked identical to one that + // shipped 4 low-severity issues. Use the summary object as authored by + // test-site Phase 6.7a, which counts each severity bucket directly from + // categories[].tests[].severity (so the planner sees what the test author saw). + const summary = run.summary || {}; + const cardClass = (n) => Number(n || 0) > 0 ? 'has-value' : 'zero-value'; + const summaryGrid = `
+
+
${Number(summary.critical || 0)}
+
Critical
+
+
+
${Number(summary.high || 0)}
+
High
+
+
+
${Number(summary.medium || 0)}
+
Medium
+
+
+
${Number(summary.low || 0)}
+
Low
+
+
+
${Number(summary.total || 0)}
+
Total Tests
+
+
`; + + // Run header (URL, runAt, duration, outcome) + const outcomeKlass = (() => { + const o = String(run.runOutcome || '').toLowerCase(); + if (o === 'failed') return 'critical'; + if (o === 'passed-with-warnings') return 'high'; + return 'pass'; + })(); + const outcomeLabel = (() => { + const o = String(run.runOutcome || '').toLowerCase(); + if (o === 'failed') return 'FAILED'; + if (o === 'passed-with-warnings') return 'WARNINGS'; + return 'PASSED'; + })(); + const runHeader = `
+
+ URL + ${url} +
+
+ Run at + ${runAt} +
+
+ Duration + ${dur} +
+
+ Outcome + ${outcomeLabel} +
+
`; + + // Categories + const categories = Array.isArray(run.categories) ? run.categories : []; + const categoryHtml = categories.length === 0 + ? `
Run completed but produced no categorized findings.
` + : categories.map((cat) => buildValidationCategory(cat)).join(''); + + return `${runHeader} +${summaryGrid} +${categoryHtml}`; +} + +function buildValidationCategory(cat) { + if (!cat || !Array.isArray(cat.tests) || cat.tests.length === 0) return ''; + const tests = cat.tests; + + const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + tests.forEach((t) => { + const s = String(t.severity || '').toLowerCase(); + if (sevCounts.hasOwnProperty(s)) sevCounts[s]++; + }); + + const sevPills = ['critical', 'high', 'medium', 'low'] + .filter((s) => sevCounts[s] > 0) + .map((s) => `${sevCounts[s]} ${s}`) + .join(' '); + + const cards = tests.map((t) => buildValidationTestCard(t)).join(''); + + return `
+
+ ${cat.icon || ''} + ${escapeHtml(cat.name || cat.id || '')} + ${tests.length} test${tests.length === 1 ? '' : 's'} + ${sevPills} +
+ ${cards} +
`; +} + +function buildValidationTestCard(t) { + const sev = String(t.severity || 'low').toLowerCase(); + const type = String(t.type || 'automated').toLowerCase(); + const status = String(t.status || '').toLowerCase(); + const statusBadge = (() => { + if (status === 'passed') return `PASS`; + if (status === 'failed') return `FAIL`; + if (status === 'skipped') return `SKIP`; + return ''; + })(); + const steps = Array.isArray(t.steps) ? t.steps : []; + const stepsHtml = steps.length > 0 + ? `
Steps
+
    ${steps.map((s) => `
  1. ${escapeHtml(s)}
  2. `).join('')}
` + : ''; + const expectedHtml = t.expected + ? `
Expected Result: ${escapeHtml(t.expected)}
` + : ''; + const actualHtml = t.actual + ? `
Actual: ${escapeHtml(t.actual)}
` + : ''; + const validatesHtml = t.validates + ? `
Validates
${escapeHtml(t.validates)}
` + : ''; + const descHtml = t.description + ? `
${escapeHtml(t.description)}
` + : ''; + + return `
+
+ ${sev} + ${type} + ${statusBadge} + ${escapeHtml(t.name || t.id || '')} + +
+
+ ${descHtml} +
+
+
Severity
+ ${sev.toUpperCase()} +
+
+
Type
+ ${type === 'automated' ? 'Automated (scriptable)' : 'Manual (browser)'} +
+ ${validatesHtml} +
+ ${stepsHtml} + ${expectedHtml} + ${actualHtml} +
+
`; +} + +// Sidebar nav button for the Validation tab — badge shows total failure count +// (critical + high + failed status), or "OK" when everything passed. +function buildValidationNavBadge() { + const runs = (data.validationRuns && typeof data.validationRuns === 'object') ? data.validationRuns : null; + if (!runs) return { text: '', cls: '' }; + let totalFailures = 0; + let totalRuns = 0; + for (const v of Object.values(runs)) { + if (!v || typeof v !== 'object') continue; + totalRuns++; + const s = v.summary || {}; + totalFailures += Number(s.critical || 0) + Number(s.high || 0); + } + if (totalRuns === 0) return { text: '', cls: '' }; + if (totalFailures > 0) return { text: String(totalFailures), cls: 'nav-badge-warn' }; + return { text: 'OK', cls: 'nav-badge-ok' }; +} + +function buildHostCardHtml(d) { + // Renders the "Pipelines Host" card on the Pipeline tab. Three modes: + // - host-card-ok → AvailableUsing* statuses (host already established) + // - host-card-pending → AvailableUnboundCustomHost / MultipleUnboundCustomHosts / + // PlatformHostExistsUnbound / NoHost (will be ensured by setup-pipeline) + // - host-card-blocked → CannotRedirect (defensive — Phase 2 Q4 normally blocks plan generation) + // Returns '' when no hostResolution block is present (Manual path or pre-update plans). + const hr = d && d.hostResolution; + if (!hr || !hr.status) return ''; + const status = String(hr.status); + if (status.startsWith('AvailableUsing')) { + const url = hr.hostEnvUrl || ''; + const name = hr.hostEnvName || ''; + const meta = []; + if (hr.hostType) meta.push(escapeHtml(hr.hostType)); + if (hr.pipelinesSolutionVersion) meta.push('Pipelines v' + escapeHtml(hr.pipelinesSolutionVersion)); + meta.push('✓ Reachable'); + // When we have the env display name, lead with it (humans recognize names, + // not GUIDs in URLs) and demote the URL to a clickable navigation aid. + // Falls back to URL-as-headline when name is missing (older planData / + // detection paths that didn't capture the BAP displayName). + const headline = name + ? `
${escapeHtml(name)}
+ ` + : `
${escapeHtml(url)}
`; + return `
+
Pipelines Host
+ ${headline} +
${meta.join(' · ')}
+
`; + } + if (hr.willEnsureDuringExecution === true) { + let note = ''; + if (status === 'AvailableUnboundCustomHost') { + note = 'Will reuse existing Custom Host' + (hr.hostEnvUrl ? ' ' + escapeHtml(hr.hostEnvUrl) + '' : '') + ' (in tenant, not yet bound to dev env).'; + } else if (status === 'MultipleUnboundCustomHosts') { + note = 'Will pick from ' + Number(hr.candidatesCount || 0) + ' existing Custom Hosts at execution time.'; + } else if (status === 'PlatformHostExistsUnbound') { + note = 'Will use existing Platform Host (idempotent — already provisioned in this tenant).'; + } else if (status === 'NoHost') { + // The NoHost env-first menu in plan-alm Phase 2 Q4 asks the user to pick a + // host strategy: install on existing env / provision new / PPAC manual / + // switch to manual strategy. Reflect the choice rather than always saying + // "will provision new", so the rendered plan agrees with what + // ensure-pipelines-host will actually do at execution time. + if (hr.willProvisionPlatform === true) { + // Keep the user-facing description free of API names and admin-role disclaimers — + // those are implementation details. The pre-call confirmation gate in + // ensure-pipelines-host Phase 4.0 echoes the tenant identity before firing. + note = 'Will provision a new Platform Host (idempotent, ~3–5 min). Plan execution will pause for a tenant-identity confirmation gate before the call.'; + } else if (hr.chosenEnvUrl) { + note = 'Will install Pipelines app on existing env ' + escapeHtml(hr.chosenEnvUrl) + '.'; + } else if (hr.willUsePpac === true) { + note = 'Will create new Custom Host via PPAC manual flow (admin opens https://admin.powerplatform.microsoft.com/deploymentsNew custom host).'; + } else if (hr.willProvisionCustom === true) { + note = 'Will provision a new Custom Host (~5–10 min, requires Power Platform admin role). Plan execution will pause for admin-role attestation and a pre-call confirmation gate.'; + } else { + // Fallback for older planData that did not capture the choice. + note = 'Will provision a new Custom Host (~5–10 min, requires Power Platform admin role). Plan execution will pause for admin-role attestation and a pre-call confirmation gate.'; + } + } else { + note = 'Will be resolved during setup-pipeline (' + escapeHtml(status) + ').'; + } + return `
+
Pipelines Host
+
Will be ensured during setup-pipeline
+
${note}
+
`; + } + if (status === 'CannotRedirect') { + // Defensive: plan-alm Phase 2 Q4 normally blocks plan generation in this state. + // If we get here, surface the error visibly so reviewers understand the plan is unsafe. + return `
+
Pipelines Host
+
Blocked — CannotRedirect
+
Source env ProjectHostEnvironmentId points at Platform Host but tenant default custom host is set elsewhere. Resolution requires Power Platform admin.
+
`; + } + // Other terminal states (OrgSettingStale / PermissionDenied / DetectionFailed) fall through with no card. + return ''; +} + +function buildHostChecklistSubBullet(d) { + // Renders a sub-bullet under the "Setup pipeline" checklist item when setup-pipeline + // will delegate to ensure-pipelines-host at execution time. Display-only — no separate + // status tracking; the parent "Setup pipeline" status covers it. The
  • is wrapped + // in a
      so it is valid HTML when slotted directly into the template. + if (!d || !d.hostResolution || d.hostResolution.willEnsureDuringExecution !== true) return ''; + return `
      • ↳ Ensure Pipelines host (delegated by setup-pipeline)
      `; +} + +// Maps a checklist step name to the tab the user most likely wants to inspect +// when they click the step. Used by buildChecklistHtml() to wrap the step name +// in an anchor that re-uses the existing data-tab click handler installed by +// the template's footer script. Returns null when no tab is a clear match +// (e.g. "Finalize" or skipped steps); the renderer then falls back to plain +// text without a link. +// +// Order matters — more specific patterns first ("Test site" before "Setup"). +function tabForChecklistStep(name) { + const n = String(name || '').toLowerCase(); + if (!n) return null; + if (/\btest\s+site\b/.test(n)) return 'validation'; + if (/\b(deploy|deploy\s+via\s+pipeline|deploy\s+to)\b/.test(n)) return 'pipelines'; + if (/\b(import|import\s+to)\b/.test(n)) return 'solutions'; + if (/\bactivate\b/.test(n)) return 'pipelines'; + if (/\bsetup\s+pipeline\b/.test(n)) return 'pipelines'; + if (/\bsetup\s+solution\b/.test(n)) return 'solutions'; + if (/\bexport\s+solution\b/.test(n)) return 'solutions'; + if (/\bensure\s+pipelines\s+host\b/.test(n)) return 'pipelines'; + if (/\bfinalize\b/.test(n)) return 'overview'; + return null; +} + +function buildChecklistHtml() { + const statusIcon = { pending: '○', 'in-progress': '●', completed: '✓', skipped: '—', warning: '⚠' }; + const steps = Array.isArray(data.steps) ? data.steps : []; + if (steps.length === 0) return '
      Execution steps will be populated after approval.
      '; + const runs = (data.validationRuns && typeof data.validationRuns === 'object') ? data.validationRuns : {}; + // Manual-path per-target import outcomes — keyed by target stage label, + // populated by refresh-alm-plan-data's import-solution phase. Parallel to + // validationRuns; surfaced as a substep on the matching "Import to {stage}" + // checklist step. + const imports = (data.manualImports && typeof data.manualImports === 'object') ? data.manualImports : {}; + // Manual-path activation outcomes — keyed by target stage label, populated + // by refresh-alm-plan-data's activate-site phase. Surfaced as an ACTIVATED + // substep on the matching "Activate site in {stage}" checklist step. + const activations = (data.activations && typeof data.activations === 'object') ? data.activations : {}; + + // Match "Test site in {stageName}" entries to their captured validationRun. + // Also enrich every " in {stageName}" step with a stage-env subline so + // reviewers can see the target env URL inline. + const targetStageByLabel = {}; + for (const st of (Array.isArray(data.stages) ? data.stages : [])) { + if (st && st.label) targetStageByLabel[st.label] = st; + } + + return steps.map((step) => { + let s = String(step.status || 'pending').toLowerCase().replace(/_/g, '-'); + const skip = step.skip ? ' (will skip)' : ''; + const name = String(step.name || ''); + + // Detect " in " pattern. The same parser handles + // "Deploy to Staging", "Activate site in Staging", and "Test site in Staging". + const stageMatch = name.match(/(?:to|in)\s+(.+)$/i); + const stageName = stageMatch ? stageMatch[1].trim() : null; + const stageInfo = stageName && targetStageByLabel[stageName] ? targetStageByLabel[stageName] : null; + + // Test-site step: surface the validation run summary if we have one. + const isTestStep = /^test\s+site\s+in\s+/i.test(name); + let validationLine = ''; + if (isTestStep && stageName) { + const run = runs[stageName] || null; + if (run && typeof run === 'object') { + const o = String(run.runOutcome || '').toLowerCase(); + const badgeKlass = + o === 'failed' ? 'test-result-fail' : + o === 'passed-with-warnings' ? 'test-result-warning' : + 'test-result-pass'; + const badgeLabel = + o === 'failed' ? 'FAILED' : + o === 'passed-with-warnings' ? 'WARNINGS' : + 'PASSED'; + const sm = run.summary || {}; + const counts = []; + if (Number(sm.passed || 0) > 0) counts.push(`${Number(sm.passed)} pass`); + if (Number(sm.failed || 0) > 0) counts.push(`${Number(sm.failed)} fail`); + if (Number(sm.skipped || 0) > 0) counts.push(`${Number(sm.skipped)} skip`); + const countsStr = counts.length ? ` · ${counts.join(' / ')}` : ''; + validationLine = `
        +
      • + ${badgeLabel} + ${run.url ? `${escapeHtml(run.url)}` : '—'}${countsStr} + View details → +
      • +
      `; + // Promote step status to "warning" yellow when the test failed/warned — + // makes the failure visible at a glance from the Execution tab. + if (s === 'completed' && o === 'failed') s = 'warning'; + } else if (s === 'completed' || s === 'pending' || s === 'in-progress') { + // Step exists but no run captured — show a small note. + validationLine = `
        +
      • No test-site run captured for ${escapeHtml(stageName)} yet.
      • +
      `; + } + } + + // Activate-step substep: surface per-target activation outcome when + // planData.activations[stageName] is populated. Same visual idiom as the + // test-site validation substep + import substep below. Detection: step + // name starts with "Activate site in " (plan-alm Phase 3 schema). Works + // for both PP and Manual paths — PP path's activation flows through + // docs/alm/last-deploy.json and refreshDeployPipeline, but the Manual-path + // standalone activate-site invocation is what surfaces here. + const isActivateStep = /^activate\s+site\s+in\s+/i.test(name); + let activateLine = ''; + if (isActivateStep && stageName) { + const act = activations[stageName] || null; + if (act && typeof act === 'object') { + const status = String(act.status || '').toLowerCase(); + const failed = /fail/i.test(status); + const alreadyActivated = status === 'alreadyactivated' || status === 'already-activated'; + const badgeKlass = failed ? 'test-result-fail' : 'test-result-pass'; + const badgeLabel = failed ? 'FAILED' : (alreadyActivated ? 'ALREADY LIVE' : 'ACTIVATED'); + const urlMarkup = act.siteUrl + ? `${escapeHtml(act.siteUrl)}` + : '—'; + activateLine = `
        +
      • + ${badgeLabel} + ${urlMarkup} +
      • +
      `; + if (s === 'completed' && failed) s = 'warning'; + } + } + + // Manual-path Import-step substep: surface per-target import outcome + // when planData.manualImports[stageName] is populated. Same visual idiom + // as the test-site validation substep above. Detection mirrors the + // plan-alm Phase 3 step name "Import to {stageName}". + const isImportStep = /^import\s+to\s+/i.test(name); + let importLine = ''; + if (isImportStep && stageName) { + const imp = imports[stageName] || null; + if (imp && typeof imp === 'object') { + const status = String(imp.status || '').toLowerCase(); + const failed = (imp.componentFailureCount && imp.componentFailureCount > 0) || /fail/i.test(status); + const badgeKlass = failed ? 'test-result-fail' : 'test-result-pass'; + const badgeLabel = failed ? 'FAILED' : 'IMPORTED'; + const versionStr = imp.artifactVersion ? `v${escapeHtml(imp.artifactVersion)}` : ''; + const componentsStr = imp.componentCount != null + ? `${Number(imp.componentCount).toLocaleString()} components` + : ''; + const failedStr = (imp.componentFailureCount && imp.componentFailureCount > 0) + ? ` · ${Number(imp.componentFailureCount)} failed` + : ''; + const detailParts = [versionStr, componentsStr].filter(Boolean).join(' · ') + failedStr; + importLine = `
        +
      • + ${badgeLabel} + ${detailParts || '—'} +
      • +
      `; + // Promote completed → warning when import had component failures. + if (s === 'completed' && failed) s = 'warning'; + } + } + + // Env-name subline: for any stage-bound step (Deploy / Import / Activate / Test), + // show the target env URL beneath the step name. Plays well with the + // existing checklist-substep-list styling. + let envLine = ''; + if (stageInfo && stageInfo.envUrl && !isTestStep) { + envLine = `
        +
      • Target: ${escapeHtml(stageInfo.envUrl)}
      • +
      `; + } + + const targetTab = tabForChecklistStep(name); + const escapedName = escapeHtml(name); + // Wrap the step name in an anchor when there's a clear tab to navigate to. + // The onclick re-uses the .nav-btn click handler installed by the template's + // footer script (querySelector matches the sidebar button by data-tab). + // The href falls back to the section's id, so middle-click / right-click / + // copy-link still works in browsers that block JS. + const nameMarkup = targetTab + ? `${escapedName}${skip}` + : `${escapedName}${skip}`; + + return `
      + ${statusIcon[s] || '○'} + ${nameMarkup} + ${s.replace('-', ' ')} +
      ${envLine}${activateLine}${importLine}${validationLine}`; + }).join('\n'); +} + +const planStatusClass = String(data.PLAN_STATUS || 'Draft').toLowerCase().replace(/[^a-z]+/g, '-').replace(/-+$/, ''); + +const replacements = { + SITE_NAME: escapeHtml(data.SITE_NAME), + GENERATED_AT: escapeHtml(data.GENERATED_AT), + STRATEGY_LABEL: strategyLabel, + PLAN_STATUS: escapeHtml(data.PLAN_STATUS || 'Draft'), + APPROVED_BY: escapeHtml(data.APPROVED_BY || ''), + APPROVAL_DATE: escapeHtml(data.APPROVAL_DATE || ''), + OVERVIEW_SUMMARY: buildOverviewSummary(), + STAT_COMPONENTS: (componentCount || 0).toLocaleString(), + STAT_ENVVARS: envVarStatDisplay(), + STAT_SIZE: totalSizeMB.toFixed(1), + STAT_SIZE_COLOR: sizeColor, + STAT_SOLUTIONS: String(proposedSolutions.length || 1), + STAGES_HTML: buildStagesHtml(), + RISKS_HTML: buildRisksHtml(), + STRATEGY_RATIONALE: buildStrategyRationale(), + SIZE_ALERT: buildSizeAlert(), + SIZE_GAUGE: buildSizeGauge(), + SIGNAL_CARDS: buildSignalCards(), + SIZE_BREAKDOWN: buildSizeBreakdown(), + SIZE_BADGE: sizeBadge, + SIZE_BADGE_CLASS: sizeBadgeClass, + ADVISORY_HTML: buildAdvisoryHtml(), + ENVVARS_HTML: buildEnvVarsHtml(), + SOLUTIONS_TAB_TITLE: buildSolutionsTabTitle(), + SOLUTIONS_TAB_DESC: buildSolutionsTabDesc(), + SOLUTIONS_HTML: buildSolutionsHtml(), + PIPELINES_TAB_TITLE: buildPipelinesTabTitle(), + PIPELINES_TAB_DESC: buildPipelinesTabDesc(), + PIPELINES_HOST_CARD: buildHostCardHtml(data), + PIPELINES_HTML: buildPipelinesHtml(), + VALIDATION_TAB: buildValidationTab(data), + VALIDATION_NAV_BADGE: buildValidationNavBadge().text, + VALIDATION_NAV_BADGE_CLASS: buildValidationNavBadge().cls, + CHECKLIST_HTML: buildChecklistHtml(), + HOST_CHECKLIST_SUBSTEP: buildHostChecklistSubBullet(data), + ESTIMATION_METHOD: escapeHtml(data.estimationMethod || 'metadata-based'), + ESTIMATION_ACCURACY: String(data.estimationAccuracyPct || 15), +}; + +let result = template; +for (const [key, value] of Object.entries(replacements)) { + result = result.split(`__${key}__`).join(value); +} + +// The template contains exactly one `` in the topbar — +// we inject the status-specific modifier class onto it. If a future template revision +// adds a second occurrence, switch to a `replace_all`-style loop. +result = result.replace(/( { + if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. + const projectRoot = findProjectRoot(cwd); + + // If we can't find the project root, approve gracefully — not a plan-alm session + if (!projectRoot) { + approve(); + return; + } + + const htmlPath = path.join(projectRoot, 'docs', 'alm-plan.html'); + + // If the file does not exist at all, approve gracefully (skill may not have run) + if (!fs.existsSync(htmlPath)) { + approve(); + return; + } + + // File exists — check that it is non-empty and large enough to be valid HTML + let stat; + try { + stat = fs.statSync(htmlPath); + } catch (e) { + block(`validate-plan-alm: Could not stat ${htmlPath}: ${e.message}`); + return; + } + + if (stat.size < 500) { + block( + `validate-plan-alm: docs/alm-plan.html exists but is too small (${stat.size} bytes). ` + + `Expected at least 500 bytes. The file may be empty or truncated. ` + + `Re-run the render-alm-plan.js script to regenerate the plan.` + ); + return; + } + + // Quick content check — must contain the plan-status marker + let content; + try { + content = fs.readFileSync(htmlPath, 'utf8'); + } catch (e) { + block(`validate-plan-alm: Could not read ${htmlPath}: ${e.message}`); + return; + } + + if (!content.includes('plan-status')) { + block( + `validate-plan-alm: docs/alm-plan.html does not contain the expected "plan-status" marker. ` + + `The file may be corrupt or was not generated by render-alm-plan.js.` + ); + return; + } + + approve(); +}); diff --git a/plugins/power-pages/skills/setup-pipeline/SKILL.md b/plugins/power-pages/skills/setup-pipeline/SKILL.md new file mode 100644 index 000000000..f2b2d0291 --- /dev/null +++ b/plugins/power-pages/skills/setup-pipeline/SKILL.md @@ -0,0 +1,585 @@ +--- +name: setup-pipeline +description: >- + Sets up a Power Platform Pipeline for automated Power Pages deployments. + Power Platform Pipelines is Microsoft's native CI/CD tool built into the + Power Platform — no external infrastructure required. + Use when asked to: "set up ci/cd", "create pipeline", "setup pipeline", + "set up power platform pipelines", "create power pipelines", + "automate deployments", "set up automated deployment", + "create deployment pipeline", "use power pipelines". + Also handles: "set up github actions" or "set up azure devops pipeline" + (shows coming-soon guidance for those platforms). +user-invocable: true +argument-hint: "Optional: 'power-platform', 'github', or 'ado' to skip platform selection" +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# setup-pipeline + +Sets up a **Power Platform Pipeline** for automated Power Pages solution deployments. Creates the pipeline configuration directly in Dataverse using the PP Pipelines OData API — no YAML files, no external CI/CD infrastructure needed. + +GitHub Actions and Azure DevOps Pipeline options are shown in the platform menu as **coming soon**. + +> Refer to `${CLAUDE_PLUGIN_ROOT}/references/cicd-pipeline-patterns.md` for all HAR-confirmed API patterns used in this skill. + +## Prerequisites + +- `powerpages.config.json` exists in the project root +- `.solution-manifest.json` exists (solution must be created first via `setup-solution`) +- Azure CLI logged in (`az account show` succeeds) +- PAC CLI logged in (`pac env who` succeeds) +- A Power Platform environment with Pipelines package installed (the "host" environment) + +## Phases + +### Phase 0 — ALM plan gate + +> **`plan-alm` is the front door.** When the user expresses an ALM intent (*promote / ship / deploy / set up CI-CD / move to staging / push to prod*), the orchestrator (`/power-pages:plan-alm`) should run first. This Phase 0 enforces that and is meant to fail closed when there's no plan, not to be a one-time check the user can dismiss forever. + +**Skip rule.** If this skill was invoked *as part of an active `plan-alm` orchestration*, skip Phase 0 entirely and proceed to Phase 1. The gate helper exposes this via its `inExecution` block — pass through silently to Phase 1 when: + +``` +inExecution.status === "active" +``` + +The helper computes this from `docs/.alm-plan-data.json` — `PLAN_STATUS === "In Execution"` AND `LAST_INVOCATION_AT` within the last 60 minutes. `check-alm-plan.js` refreshes `LAST_INVOCATION_AT` automatically on every invocation that finds the plan in execution, so each in-chain skill keeps the chain alive for the next one — even multi-hour deploys (deploy-pipeline alone can take 60 min per stage) survive the window without the chain incorrectly de-classifying. Stalled chains (no heartbeat for > 60 min) reclassify as `stale-heartbeat` and Phase 0 gates fire normally so an abandoned plan doesn't silently bypass user confirmation. + +When `inExecution.status` is anything other than `"active"` (`"not-running"`, `"stale-heartbeat"`, `"no-plan"`), run the Phase 0 gate flow below. Branch on the remaining helper fields: + +**Step 1 — Run the gate helper.** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" \ + --projectRoot "." \ + --envUrl "{devEnvUrl}" \ + --token "{token}" \ + --solutionId "{solutionId from .solution-manifest.json, if available}" +``` + +The helper returns JSON with `{ exists, stale, staleness: { reason, detail }, generatedAt, planStatus, ... }`. The freshness check requires env credentials + solutionId; without those the helper does an existence-only check. + +**Step 2 — Branch on the result.** + +| Result | Behavior | +|---|---| +| `deferred: true` | The user has explicitly deferred ALM for this project (`.alm-deferred` marker present). Pass through silently to Phase 1 — do not nag. | +| `exists: false` | The user hasn't run `plan-alm` yet. See Step 3. | +| `exists: true, stale: false` | Plan is current. Pass through silently to Phase 1. | +| `exists: true, stale: true` (reason: `solution-modified`) | The solution changed after the plan was generated. See Step 4. | + +**Step 3 — No plan.** Tell the user: + +> "No ALM plan exists for this project. `/power-pages:plan-alm` builds one — it detects the project state, asks about your promotion strategy (PP Pipelines vs Manual export/import), and orchestrates the right skills (including this one) in the right order. Want me to run plan-alm now?" + + +> 🚦 **Gate (intent · setup-pipeline:0.no-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `exists:false`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Run `/power-pages:plan-alm` first? | ALM plan gate | Yes — run /power-pages:plan-alm now (Recommended), Continue without a plan (advanced — I know what I'm doing), Cancel | + +- **Yes (Recommended)** → invoke `/power-pages:plan-alm`. plan-alm's Phase 7 dispatches back into this skill at the appropriate stage. +- **Continue without a plan** → set `BYPASSED_PLAN_GATE = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Step 4 — Stale plan.** Tell the user: + +> "ALM plan exists from `{generatedAt}` but the source solution has been modified since (at `{solution.modifiedon}`). Components may have changed. Re-running `plan-alm` will refresh the analysis and the rendered HTML." + + +> 🚦 **Gate (intent · setup-pipeline:0.stale-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `stale:true`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Refresh the plan first? | ALM plan freshness | Refresh — re-run /power-pages:plan-alm (Recommended), Continue with the existing plan, Cancel | + +- **Refresh (Recommended)** → invoke `/power-pages:plan-alm`. After completion, re-run the Phase 0 helper once to confirm freshness; if still stale, surface the detail and proceed to Phase 1 anyway (don't infinite-loop). +- **Continue** → set `STALE_PLAN_ACK = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Why this gate exists.** Direct invocation of this skill bypasses the orchestrator's pre-deploy completeness check, host-resolution decision, deployment-strategy selection, and rendered HTML plan. Users who run `setup-pipeline` directly often miss components that should have been added to the solution, miss the asset advisory for large web files, or build a pipeline against the wrong host environment. The gate ensures `plan-alm` either ran (so all of those decisions are surfaced and recorded) or the user explicitly chose to bypass it. + +### Phase 1 — Detect Project Context + +**Create all tasks upfront at the start of this phase.** + +Tasks to create: +1. "Detect project context" +2. "Select CI/CD platform" +3. "Confirm pipeline configuration" +4. "Run preflight checks" +5. "Create deployment environments" +6. "Create pipeline and stages" +7. "Verify and write artifacts" + +Steps: + +1. Read project context using `detect-project-context.js`: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/detect-project-context.js" + ``` + Capture output as JSON; extract `.siteName` (store as `siteName`), `.websiteRecordId`, `.environmentUrl` (store as `devEnvUrl`), and `.solutionManifest` (store as `solutionManifest`). If `siteName` is absent (no `powerpages.config.json`), stop and advise running `/power-pages:create-site` first. If `solutionManifest` is null (no `.solution-manifest.json`), stop and advise running `/power-pages:setup-solution` first. + + **Manifest version check:** + - If `solutionManifest.schemaVersion === 2` (multi-solution layout), set `MULTI_SOLUTION_MODE = true` and store `solutionManifest.solutions[]` as `SOLUTIONS_LIST`. See Phase 6b — a SINGLE pipeline ships all solutions through per-solution stage runs (the pre-v1.3.x "one pipeline per solution" layout was reverted because it cluttered the Pipelines UI). + - If `schemaVersion` is absent or `1` (single solution), read `solutionManifest.solution.uniqueName` and `solutionManifest.solution.solutionId`. One pipeline will be created (existing flow). + +2. Run `verify-alm-prerequisites.js` to confirm PAC CLI auth, acquire a token, and verify API access: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{devEnvUrl}" + ``` + Capture output as JSON; extract `.envUrl` (use to confirm `devEnvUrl`) and `.token` (store as `DEV_TOKEN`). + +3. Run silently: + ```bash + pac env list --output json 2>/dev/null + ``` + Store output as `ENV_LIST`. + +4. **Resolve the Pipelines host via `ensure-pipelines-host-detect.js`** (the same flow `/power-pages:ensure-pipelines-host` runs internally — it reads any cached `docs/alm/last-host-check.json`, then walks the resolution order: org-setting binding → BAP env GET → tenant default custom host → tenant-wide enumeration. Read-only; never prompts the user): + + ```bash + BAP_TOKEN=$(az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv) + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/ensure-pipelines-host-detect.js" \ + --envUrl "{devEnvUrl}" \ + --token "{DEV_TOKEN}" \ + --userId "{userId}" \ + --bapToken "{BAP_TOKEN}" \ + --projectRoot "." + ``` + + Capture stdout as JSON: `const hostResult = JSON.parse(output)`. Read `hostResult.resolutionStatus`, `hostResult.finalHostEnvUrl`, `hostResult.ready`. + + Branch on `resolutionStatus`: + + - **`AvailableUsingPlatformHost` / `AvailableUsingCustomHost` / `AvailableUsingCustomHostByAdminDefault`** — host is already established and `ready: true`. Store `HOST_ENV_URL = hostResult.finalHostEnvUrl` and continue. Phase 3 confirms with the user. + - **`AvailableUnboundCustomHost` / `MultipleUnboundCustomHosts` / `PlatformHostExistsUnbound` / `NoHost`** — no host bound to the dev env. **Delegate to `/power-pages:ensure-pipelines-host`** so the user can reuse an existing host or provision a new Custom Host (`D365_ProjectHost` template). Tell the user: *"No Pipelines host bound to `{devEnvUrl}`. Invoking `/power-pages:ensure-pipelines-host` to set one up — it will run a tenant-wide search for existing hosts and offer to provision a new Custom Host if none are found."* After the sub-skill completes, re-read `docs/alm/last-host-check.json`; capture `HOST_ENV_URL = finalHostEnvUrl` only if the new marker has `ready: true`. If the user cancelled the sub-skill, stop this skill — no pipeline can be created without a host. + - **`CannotRedirect`** — stop with the specific tenant-misconfiguration error from `hostResult.warnings[0]`. Tell the user: *"This tenant's `DefaultCustomPipelinesHostEnvForTenant` setting and the source env's `ProjectHostEnvironmentId` org setting disagree — only a Power Platform admin can resolve."* + - **`OrgSettingStale`** — stop and surface the warning: *"`ProjectHostEnvironmentId` on `{devEnvUrl}` points at a host env that is no longer visible (deleted, disabled, or you lack access). Clear the org setting via PPAC or contact the env owner."* + - **`PermissionDenied`** — stop and surface the warning: *"Caller lacks BAP read access on the env `{devEnvUrl}` is bound to. Contact the host env owner for at least `Deployment Pipeline User` access."* + + > **Why this replaces the old `discover-pipelines-host.js` call:** that helper only checked the tenant-level `DefaultCustomPipelinesHostEnvForTenant` setting (one of four resolution signals). `ensure-pipelines-host-detect.js` walks the full resolution order the Power Apps UI uses (mirrors `ProjectHostProvider.tsx`), so we agree with the UI in every case — including the previously-undetected `AvailableUnboundCustomHost` case where a Custom Host exists in the tenant but the source env hasn't been bound yet. See `references/cicd-pipeline-patterns.md` for the full state matrix. + +5. Check for existing `docs/alm/last-pipeline.json`. If found, read its contents. + +6. Report findings: "Project: `{siteName}`. Solution: `{uniqueName}`. Dev env: `{devEnvUrl}`. Host env: `{HOST_ENV_URL ?? 'pending — will be ensured next'}` ({hostResult.resolutionStatus}). Existing pipeline: found/not found." + + +> 🚦 **Gate (plan · setup-pipeline:1.existing-pipeline):** Existing `docs/alm/last-pipeline.json` found — overwrite, review first, or cancel. No Dataverse write yet. + +**If an existing `docs/alm/last-pipeline.json` is found**, ask via `AskUserQuestion`: + +> "A pipeline configuration already exists for `{pipelineName}` (created {createdAt}). How would you like to proceed? +> 1. Overwrite — create a new pipeline, replacing the marker +> 2. Review existing setup first, then decide +> 3. Cancel" + +- If **Review**: display the existing `docs/alm/last-pipeline.json` contents, then ask again with the same 3 options. +- If **Cancel**: stop the skill and inform the user no changes were made. +- If **Overwrite**: proceed. + +### Phase 1.5 — Ground in current Pipelines documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: `Power Platform Pipelines setup OData API host environment deploymentenvironments`. +2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/pipelines` (and at most one sister page on host setup or pipeline creation) in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what Microsoft Learn currently says about Pipelines host resolution, `deploymentenvironments` / `deploymentpipelines` / `deploymentstages` schema, and pipeline lifecycle. Compare against `${CLAUDE_PLUGIN_ROOT}/references/cicd-pipeline-patterns.md` and flag any divergence (new fields, deprecated APIs, changed validation status codes). +4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 5 (Register Environments with the Pipelines Host). + +### Phase 2 — Select CI/CD Platform + + +> 🚦 **Gate (plan · setup-pipeline:2.platform):** Pick CI/CD platform — PP Pipelines (full) vs GitHub Actions / ADO (coming soon stubs). + +Ask user via `AskUserQuestion`: + +> "Which CI/CD platform do you want to use? +> 1. **Power Platform Pipelines** — Microsoft's native deployment pipeline. No external infrastructure needed. (Recommended) +> 2. **GitHub Actions** — Coming soon +> 3. **Azure DevOps Pipeline** — Coming soon" + +If the user passed `power-platform`, `github`, or `ado` as an argument, skip this question and use the provided value. + +Store the selection as `PLATFORM`. + +**If `github` or `ado` selected** → display the [Coming Soon path](#coming-soon-path) and stop. + +--- + +## Power Platform Pipelines Path + +### Phase 3 — Confirm Pipeline Configuration + +Before asking any questions, assemble what was auto-detected: + +| Setting | Auto-detected value | +|---|---| +| Site name | `{siteName}` from `powerpages.config.json` | +| Solution unique name | `{uniqueName}` from `.solution-manifest.json` | +| Dev environment URL | `{devEnvUrl}` from `pac env who` | +| Host environment URL | `{HOST_ENV_URL}` from `ensure-pipelines-host-detect.js` (resolved in Phase 1 step 4) | +| BAP environment ID (dev) | From `pac env list` | + + +> 🚦 **Gate (plan · setup-pipeline:3.config):** Confirm auto-detected pipeline configuration — pipeline name, host env, target envs. Cancel exits before any Dataverse write to the host. + +Ask user via `AskUserQuestion` with pre-filled values: + +> "I've gathered the following pipeline configuration. Please confirm or correct: +> +> - **Pipeline name**: `{siteName} Pipeline` (can change) +> - **Source (Dev) environment**: `{devEnvUrl}` +> - **Host environment** (where Pipelines is installed): `{HOST_ENV_URL}` *(resolved in Phase 1 — should always be present at this point; `ensure-pipelines-host` would have stopped the skill otherwise)* +> - **Solution to deploy**: `{uniqueName}` +> - **Target environments**: How many? (Dev → Staging / Dev → Staging → Production)" + +Collect from user: +- `PIPELINE_NAME` (default: `{siteName} Pipeline`) +- `HOST_ENV_URL` (confirm — already resolved in Phase 1; user can override only if they want to point at a different host they administer, in which case re-run `/power-pages:ensure-pipelines-host` first to validate it) +- Target environment count and URLs (`STAGING_ENV_URL`, `PROD_ENV_URL` if applicable) +- BAP environment IDs for each target (from `pac env list` — pre-fill if found, otherwise ask) + +Store `HOST_TOKEN` by running: +```bash +az account get-access-token --resource "{hostEnvOrigin}" --query accessToken -o tsv +``` + +Present a final confirmation summary and ask user to approve before proceeding. + +### Phase 4 — Preflight Checks + +Use Node.js `https` module for all Dataverse calls (curl has encoding issues on Windows). + +**4.1 Verify host environment has Pipelines installed:** +``` +GET {hostEnvUrl}/api/data/v9.1/deploymentpipelines?$top=0 +Authorization: Bearer {HOST_TOKEN} +``` +If response is 404 or returns an "unknown entity" error, stop and inform the user: "The selected host environment does not have Power Platform Pipelines installed. Please select a different environment or install the Pipelines package." + +**4.2 Verify solution exists in dev environment** using `verify-solution-exists.js`: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-solution-exists.js" \ + --envUrl "{devEnvUrl}" \ + --uniqueName "{uniqueName}" \ + --token "{DEV_TOKEN}" +``` +Capture output as JSON; check `.found`. If `false`: warn the user — the solution must be exported from dev before it can be deployed. + +**4.3 Check for existing pipeline with same name:** +``` +GET {hostEnvUrl}/api/data/v9.1/deploymentpipelines?$filter=name eq '{PIPELINE_NAME}'&$select=deploymentpipelineid&$top=1 +Authorization: Bearer {HOST_TOKEN} +``` +If found: ask via `AskUserQuestion` whether to use the existing pipeline ID or create a new one with a different name. + +**4.4 Check `blockedattachments` on source + all target envs:** + +Power Pages code sites include `.js` files in their compiled output. If `.js` is in the env's `blockedattachments` setting, `pac pages upload-code-site` (on the source) and `deploy-pipeline` (on targets) will both fail with `AttachmentBlocked`. Run this on the **source env** and on **every target env**: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/fix-blocked-attachments.js" \ + --envUrl "{envUrl}" \ + --extensions js \ + --dry-run +``` + +If `wasBlocked` is non-empty for any env, inform the user: +> "`.js` files are blocked in `{envUrl}`. This will cause upload/deployment failures for Power Pages code sites. Remove the block? This modifies an environment-level security setting." + + +> 🚦 **Gate (consent · setup-pipeline:4.4.blocked-attachments):** Modify env-level `blockedattachments` security setting (tenant-wide impact). Affects all users of the env, not just this skill. Reversible from PPAC. **Fires PER ENV that has blocks.** Phase 4.4 checks source + every target env; if M envs out of N have `.js` (or other media extensions) on the blocklist, the gate fires M times — once per env. Each env has its own security setting and its own group of affected makers. Yes for source does NOT cover staging; yes for staging does NOT cover production. **Do NOT batch consent across envs.** + +Ask via `AskUserQuestion`: 1. Yes, remove block (recommended) / 2. Skip (I'll fix manually). + +If approved, re-run **without** `--dry-run` to apply the change. If the user declines, record it as a warning — they'll need to fix it manually before deployment succeeds. + +Report preflight results. If any critical check failed, stop with clear instructions. If warnings only, ask user to confirm before proceeding. + +### Phase 5 — Register Environments with the Pipelines Host + +Register each environment (source + targets) with the Pipelines host by creating a `deploymentenvironments` row in the host's Dataverse. This is a **metadata-only registration** — the row is a pointer to an existing BAP environment, not a provisioning call. The environments themselves must already exist in BAP. The host validates that the referenced env is reachable and the caller has the right access (`validationstatus` flips Pending → Succeeded). Process source env first, then targets. + +Use `create-deployment-environment.js` for each environment (dev source + each target): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-deployment-environment.js" \ + --hostEnvUrl "{HOST_ENV_URL}" \ + --token "{HOST_TOKEN}" \ + --name "{siteName} {label}" \ + --bapEnvId "{BAP_ENV_GUID}" \ + --environmentType 200000000 \ + [--environmentUrl "{environmentUrl}"] +``` + +Required args (per `scripts/lib/create-deployment-environment.js`): +- `--bapEnvId` — the **BAP environment GUID** for the env being added. Resolve via `pac env list` (column `Environment ID`) or `pac env who` for the current source env. NOT the org/Dataverse URL. +- `--environmentType` — `200000000` for the dev/source env, `200000001` for each target env. +- `--environmentUrl` is optional and only echoed back into the output marker; it is not posted to Dataverse. + +Capture stdout as JSON: `const envResult = JSON.parse(output)`. +Store `envResult.deploymentEnvironmentId` as `SOURCE_DEPLOYMENT_ENV_ID` (for the dev source env) or append to `TARGET_DEPLOYMENT_ENV_IDs` (for each target). Also retain the `bapEnvId` value used for each call — Phase 5a's force-link auto-fix needs it if creation lands in a Failed state. + +> **Note**: The script POSTs to `deploymentenvironments` with **unprefixed** fields (`name`, `environmentid`, `environmenttype`), extracts the `deploymentenvironmentid` GUID from the `OData-EntityId` header, then polls `validationstatus` every 3 seconds (max 20 attempts) until status `200000001` (Succeeded) or `200000002` (Failed). On failure the script writes the error details to stderr and exits 1 — stop and report the error to the user. (The earlier `msdyn_`-prefixed field shape and `192350001`/`192350002` status codes were from an early-preview HAR; the shipped Pipelines schema rejects `msdyn_`-prefixed properties and uses the `2000000XX` codes.) + +On failure: stop with the error — deployment environment creation is mandatory. + +#### 5a — Detect "already associated with another pipelines host" (Pattern 15) + +If the script's stderr (case-insensitively) contains any of these substrings, the BAP env is currently stamped to a different Pipelines host: + +- `already associated with another pipelines host` +- `associated with another pipelines host` +- `environment is already linked to a different host` +- `environment is already bound to` +- `linked to another host` +- `claimed by another host` + +Match all of these case-insensitively (`String.prototype.toLowerCase()` before `.includes()`) so backend wording drift between Pipelines package versions doesn't silently break detection. If none match but the script exited with the underlying Dataverse error code `0x80048d18` (or a wrapped `errormessage` containing that hex code), treat it as the same pattern — that's the stable signal even when the message wording shifts. + + +> 🚦 **Gate (consent · setup-pipeline:5a.pattern-15):** Target env stamped to a different Pipelines host. Offer force-link as documented auto-fix — DESTRUCTIVE: previous host loses pipeline access for this env. Cancel here exits setup-pipeline cleanly. **Fires PER ENV that triggers Pattern 15.** Phase 5 loops over source + each target env when registering with the host; if two target envs both turn out to be stamped to different hosts, this gate fires twice — once per env. Do NOT batch the consent across envs; the destructive blast radius is per-env (each env carries its own previous-host stamp and its own group of makers losing access). + +This is **Pattern 15** in `${CLAUDE_PLUGIN_ROOT}/references/deployment-error-catalog.md`. Do NOT silently retry. Surface the raw `errormessage` to the user verbatim and offer the documented auto-fix via `AskUserQuestion`: + +``` +question: " is already linked to a different Pipelines host. The /power-pages:force-link-environment skill can take over the association (DESTRUCTIVE to the previous host — makers there lose pipeline access for this env). Run it now?" +header: "Force Link?" +options: + - "Run /power-pages:force-link-environment now (Recommended)" — auto-fix per the deployment error catalog + - "Cancel setup-pipeline" — investigate the previous host first +``` + +Important guardrails: +- **Never invoke** `/power-pages:force-link-environment` without explicit user consent through this prompt — the action is reversible only by performing Force Link again from the previous host. +- If the user picks "Run …", invoke `/power-pages:force-link-environment` with `--host ` and `--dev-env ` (the BAP env GUID captured for this env in Phase 5 — see the "Also retain the `bapEnvId` value" note above) so the sub-skill skips its own host/env prompts. +- When that sub-skill returns success, **re-attempt just the failing environment by re-running `create-deployment-environment.js` with the same args** — do NOT restart Phase 5 wholesale. The create script is idempotent: it short-circuits via `findExistingByBapId` for envs already created (they return `reused: true`), and the previously-failing env will now resolve to Succeeded because the host stamp has moved. +- If the user picks "Cancel", stop the pipeline setup and recommend `/power-pages:ensure-pipelines-host detect-only` to inspect the current host bindings before retrying. + +For any other create-deployment-environment failure, fall through to the generic "stop with the error" path above. + +Report progress for each environment as validation completes. + +### Phase 6 — Create Pipeline, Associate Source, Create Stages + +Use `create-deployment-pipeline.js` to create the pipeline, associate the source environment, and create all stage records in one call: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-deployment-pipeline.js" \ + --hostEnvUrl "{HOST_ENV_URL}" \ + --token "{HOST_TOKEN}" \ + --pipelineName "{PIPELINE_NAME}" \ + --description "Power Pages deployment pipeline for {siteName}" \ + --sourceDeploymentEnvironmentId "{SOURCE_DEPLOYMENT_ENV_ID}" \ + --stagesJson '[{"name":"Deploy to {targetLabel}","targetDeploymentEnvironmentId":"{TARGET_DEPLOYMENT_ENV_ID}","order":1}]' +``` +Capture stdout as JSON: `const pipelineResult = JSON.parse(output)`. +Extract: +- `pipelineResult.pipelineId` → store as `PIPELINE_ID` +- `pipelineResult.stages` → array of `{ stageId, name, targetDeploymentEnvironmentId }` + +> **What the script does internally** (uses the **unprefixed** field schema — the earlier `msdyn_`-prefixed body was rejected by the shipped Pipelines schema; see the comment block at the top of `create-deployment-pipeline.js` for the full migration map): +> 1. POSTs `{ name, description }` to `deploymentpipelines` (v9.1) — extracts `deploymentpipelineid` from `OData-EntityId` header +> 2. POSTs a relative-path `@odata.id` body to `deploymentpipelines({pipelineId})/deploymentpipeline_deploymentenvironment/$ref` to associate the source environment (HAR-confirmed — no leading `/` or full URL) +> 3. For each stage: POSTs `{ name, deploymentpipelineid@odata.bind, targetdeploymentenvironmentid@odata.bind }` to `deploymentstages` — extracts `deploymentstagesid` from `OData-EntityId` header + +On failure: the script writes the error to stderr and exits 1 — stop and report the error to the user. + +### Phase 6b — Multi-solution deploymentOrder (only if `MULTI_SOLUTION_MODE = true`) + +> **Design note (updated v1.3.x):** A single Power Platform Pipeline can deploy +> multiple solutions through separate stage runs — each run just specifies a +> different `artifactname` + `solutionid` on the same `deploymentstages` record. +> Creating one pipeline per solution was wasteful and cluttered the Pipelines +> UI. **We now create ONE pipeline + one stage per target env, and record the +> per-solution deployment order in `docs/alm/last-pipeline.json`**. `deploy-pipeline` +> then loops over the order, creating a stage run per solution against the same +> stage. + +When the manifest is `schemaVersion: 2`, do **not** call `create-deployment-pipeline.js` multiple times. Instead: + +1. Call `create-deployment-pipeline.js` **once** with: + - `pipelineName = "{siteName}-Pipeline"` (e.g. `IdeaSphere-Pipeline`). + - `description` listing the solutions that will deploy through it (e.g. `"Deploys IdeaSphere_Core → IdeaSphere_WebAssets → IdeaSphere_Future in order"`). + - One `deploymentstages` record per target environment (not per solution). +2. Build the `deploymentOrder` array from `SOLUTIONS_LIST` sorted by `order`. Each entry has `{ solutionUniqueName, solutionId, order }`. Skip entries where `isFutureBuffer: true` AND `components.length === 0` — an empty Future solution has nothing to deploy; it's created by `setup-solution` but does not participate in the deployment loop until it has content. Keep it in the order array with `status: "SkippedEmpty"` so the renderer can show the intent. +3. Collect the single `pipelineId` and its `stages[]`. Persist `deploymentOrder` to `docs/alm/last-pipeline.json` (see Phase 7). + +### Phase 7 — Verify, Write Artifacts, Commit + +**7.1 Verify pipeline was created:** +``` +GET {hostEnvUrl}/api/data/v9.1/deploymentpipelines({PIPELINE_ID})?$select=name,statecode +Authorization: Bearer {HOST_TOKEN} +``` + +Confirm `statecode = 0` (Active). If the query fails, report as "verification inconclusive — pipeline may still be valid". + +**7.2 Write `docs/alm/last-pipeline.json`** (create the `docs/alm/` directory first if missing — `node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"`): + +```json +{ + "pipelineId": "{PIPELINE_ID}", + "pipelineName": "{PIPELINE_NAME}", + "hostEnvUrl": "{HOST_ENV_URL}", + "sourceDeploymentEnvironmentId": "{SOURCE_DEPLOYMENT_ENV_ID}", + "sourceEnvironmentUrl": "{devEnvUrl}", + "solutionName": "{uniqueName}", + "createdAt": "{ISO timestamp}", + "stages": [ + { + "stageId": "{deploymentstagesid}", + "name": "Deploy to {targetLabel}", + "rank": 1, + "targetDeploymentEnvironmentId": "{TARGET_DEPLOYMENT_ENV_ID}", + "targetEnvironmentUrl": "{targetEnvUrl}" + } + ] +} +``` + +**Multi-solution marker (manifest v2):** When `MULTI_SOLUTION_MODE = true`, `docs/alm/last-pipeline.json` uses `schemaVersion: 3` with a **single** pipeline and a `deploymentOrder[]` describing which solutions deploy through it, in what order: + +```json +{ + "schemaVersion": 3, + "pipelineId": "...", + "pipelineName": "IdeaSphere-Pipeline", + "hostEnvUrl": "{HOST_ENV_URL}", + "sourceDeploymentEnvironmentId": "{SOURCE_DEPLOYMENT_ENV_ID}", + "sourceEnvironmentUrl": "{devEnvUrl}", + "createdAt": "{ISO timestamp}", + "stages": [ + { + "stageId": "...", + "name": "Deploy to Staging", + "rank": 1, + "targetDeploymentEnvironmentId": "...", + "targetEnvironmentUrl": "https://staging.crm.dynamics.com" + } + ], + "deploymentOrder": [ + { "solutionUniqueName": "IdeaSphere_Core", "solutionId": "...", "order": 1 }, + { "solutionUniqueName": "IdeaSphere_WebAssets", "solutionId": "...", "order": 2 }, + { "solutionUniqueName": "IdeaSphere_Future", "solutionId": "...", "order": 3, "status": "SkippedEmpty", "isFutureBuffer": true } + ] +} +``` + +> **Migration note:** Earlier versions of this skill used `schemaVersion: 2` with a `pipelines[]` array (one Dataverse pipeline record per solution). Projects pinned to v2 continue to work with the old `deploy-pipeline` MULTI_PIPELINE_MODE path; the v3 format should be used for all new setups. When re-running `setup-pipeline` on a v2 project, ask via `AskUserQuestion` whether to migrate (delete the N-1 extra pipelines and collapse to a single one) or keep the legacy layout. + +**7.3 Write (or re-render) `docs/pipeline-setup.md`** (create `docs/` directory if needed). + +Contents: +1. **Pipeline Created** — name, host env URL, pipeline ID +2. **Environments configured** — source + each target with their deployment environment IDs +3. **Solutions in deployment order** (multi-solution mode only) — for each entry in `solutionManifest.solutions[]`, list `{uniqueName, version, componentCount}`. Read `componentCount` from each entry's `components.length` if the manifest tracks it, otherwise from a live Dataverse query (`solutioncomponents?$filter=_solutionid_value eq '{solutionId}' and componenttype ne 380&$count=true`) — DO NOT hard-code or carry forward a stale count from a prior invocation. +4. **How to trigger a deployment** — Run `/power-pages:deploy-pipeline` or open Power Platform make.powerapps.com → Solutions → Pipelines +5. **Approval gates** (if applicable) — How to configure in Power Platform Admin Center +6. **Troubleshooting** — Common validation errors and how to resolve them + +> **Sync-mode re-render**: when `setup-pipeline` is invoked on a project where `docs/alm/last-pipeline.json` ALREADY exists (re-run after `configure-env-variables`, `setup-solution` sync, or a follow-up env-var addition that bumped component counts), regenerate this file in full from current Dataverse state — do not patch in place. Validated failure: a Citizens portal `pipeline-setup.md` showed Foundation = 13 components while Dataverse had 15 after `configure-env-variables` added 2 env var definitions to that solution; the markdown never updated. The simplest safe behavior is "always re-render in Phase 7.3", because the operation reads current state directly and the file has no user-editable sections worth preserving. + +**7.4 Commit:** +```bash +git add docs/alm/last-pipeline.json docs/pipeline-setup.md +git commit -m "Add Power Platform Pipeline configuration for {siteName}" +``` + +**7.5 Record skill usage:** + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "SetupPipeline"`. + +**7.5b Refresh the ALM plan (if one exists):** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase setup-pipeline \ + --render +``` + +The helper reads `docs/alm/last-host-check.json` + `docs/alm/last-pipeline.json`, refreshes `planData.hostResolution` and `planData.pipelineMeta`, drops pre-setup "no host detected" risks, and re-renders `docs/alm-plan.html`. When `docs/.alm-plan-data.json` is absent (standalone invocation, not via plan-alm), the helper returns `ok:false` as a soft no-op — safe to run unconditionally. + +**7.6 Present summary:** + +| Resource | ID / URL | +|---|---| +| Pipeline | `{PIPELINE_NAME}` (`{PIPELINE_ID}`) | +| Host environment | `{HOST_ENV_URL}` | +| Source deployment env | `{SOURCE_DEPLOYMENT_ENV_ID}` | +| Stage: {name} | `{stageId}` → `{targetEnvUrl}` | + +**Files written:** +- `docs/alm/last-pipeline.json` — pipeline configuration marker +- `docs/pipeline-setup.md` — setup documentation + +**Next step:** +> Run `/power-pages:deploy-pipeline` to trigger your first deployment run. + +--- + +## Coming Soon Path + +**If GitHub Actions or Azure DevOps was selected:** + +Inform the user: + +> "GitHub Actions and Azure DevOps Pipeline support are coming soon for this skill. +> +> **For now, you have two options:** +> 1. Use **Power Platform Pipelines** — select option 1 to set up Microsoft's native deployment pipeline (recommended) +> 2. Exit — I'll set up GitHub Actions / Azure DevOps manually using the documentation" + + +> 🚦 **Gate (plan · setup-pipeline:coming-soon.exit):** User selected GitHub/ADO (coming-soon stubs) — offer to switch back to PP Pipelines or exit cleanly. + +Ask via `AskUserQuestion`: +1. Switch to Power Platform Pipelines — go back to Phase 2 +2. Exit — I'll set up manually + +If GitHub/ADO passed as argument: display above message and exit gracefully. + +--- + +## Key Decision Points (Wait for User) + +0. **Phase 1**: Existing pipeline file — overwrite, review, or cancel (only if `docs/alm/last-pipeline.json` found) +1. **Phase 2**: Platform selection (Power Platform Pipelines / GitHub coming soon / ADO coming soon) +2. **Phase 3**: Confirm pipeline configuration — pipeline name, host env URL, target environments +3. **Phase 4**: Preflight warnings — proceed or cancel +4. **Phase 3**: Parameter confirmation before pipeline creation + +## Error Handling + +- No `powerpages.config.json`: stop, advise `/power-pages:create-site` +- No `.solution-manifest.json`: stop, advise `/power-pages:setup-solution` +- `RetrieveSetting` returns empty: ask user for host environment URL manually +- Deployment environment `statecode = 1` with non-null `errormessage` (validation failed): stop with error details +- Pipeline `$ref` call fails: stop — this association is required before stages can be created +- Stage creation fails: record failure, continue with remaining stages — partial success is valid + +## Progress Tracking Table + +| Task subject | activeForm | Description | +|---|---|---| +| Detect project context | Detecting project context | Read powerpages.config.json and .solution-manifest.json; run pac env who and pac env list; call RetrieveSetting to find host env; check for existing docs/alm/last-pipeline.json | +| Select CI/CD platform | Selecting CI/CD platform | Ask user: Power Platform Pipelines (full) or GitHub/ADO (coming soon) | +| Confirm pipeline configuration | Confirming pipeline configuration | Pre-fill pipeline name, source env, host env, solution name from auto-detected values; ask for target environments; get user confirmation | +| Run preflight checks | Running preflight checks | Verify host env has Pipelines installed; verify solution exists in dev env; check for pipeline name conflict | +| Create deployment environments | Creating deployment environments | POST deploymentenvironments for source + each target; poll validationstatus for each until Succeeded | +| Create pipeline and stages | Creating pipeline and stages | POST deploymentpipelines; $ref associate source env; POST deploymentstages for each target (linked via previousdeploymentstageid) | +| Verify and write artifacts | Verifying and writing artifacts | Query pipeline to confirm active; write docs/alm/last-pipeline.json; write docs/pipeline-setup.md; commit; present summary with next steps | diff --git a/plugins/power-pages/skills/setup-pipeline/scripts/validate-pipeline.js b/plugins/power-pages/skills/setup-pipeline/scripts/validate-pipeline.js new file mode 100644 index 000000000..c00e1b39c --- /dev/null +++ b/plugins/power-pages/skills/setup-pipeline/scripts/validate-pipeline.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +// Validates that setup-pipeline completed: checks for docs/alm/last-pipeline.json (Power Platform Pipelines) +// or pipeline YAML files (GitHub Actions / ADO — legacy / future). +// For PP Pipelines: validates pipelineId, hostEnvUrl, sourceDeploymentEnvironmentId, non-empty stages[]. +// Gracefully exits 0 when no pipeline artifacts are found (not a setup-pipeline session). + +const fs = require('fs'); +const path = require('path'); +const { approve, block, runValidation, findProjectRoot, findPath, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); +const { almPath } = require('../../../scripts/lib/alm-paths'); + +runValidation(async (cwd) => { + if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. + const projectRoot = findProjectRoot(cwd) || cwd; + + // Check for Power Platform Pipelines marker (primary path) + const ppMarkerPath = almPath(projectRoot, 'lastPipeline'); + const ppMarkerExists = fs.existsSync(ppMarkerPath); + + // Check for GitHub Actions workflow or ADO pipeline (future/legacy paths) + const ghWorkflowPath = findPath(projectRoot, path.join('.github', 'workflows', 'deploy.yml')); + const adoPipelinePath = findPath(projectRoot, 'azure-pipelines.yml'); + + // No pipeline artifacts found — not a setup-pipeline session + if (!ppMarkerExists && !ghWorkflowPath && !adoPipelinePath) return approve(); + + // --- Power Platform Pipelines path --- + if (ppMarkerExists) { + let marker; + try { + marker = JSON.parse(fs.readFileSync(ppMarkerPath, 'utf8')); + } catch { + return block('docs/alm/last-pipeline.json exists but could not be parsed as JSON.'); + } + + if (!marker.pipelineId) { + return block('docs/alm/last-pipeline.json is missing required field: pipelineId'); + } + if (!marker.hostEnvUrl) { + return block('docs/alm/last-pipeline.json is missing required field: hostEnvUrl'); + } + if (!marker.sourceDeploymentEnvironmentId) { + return block('docs/alm/last-pipeline.json is missing required field: sourceDeploymentEnvironmentId'); + } + if (!Array.isArray(marker.stages) || marker.stages.length === 0) { + return block('docs/alm/last-pipeline.json has empty or missing stages array. At least one deployment stage is required.'); + } + + // Verify each stage has required fields + for (const stage of marker.stages) { + if (!stage.stageId) { + return block(`docs/alm/last-pipeline.json stage "${stage.name || '?'}" is missing stageId.`); + } + if (!stage.targetDeploymentEnvironmentId) { + return block(`docs/alm/last-pipeline.json stage "${stage.name || '?'}" is missing targetDeploymentEnvironmentId.`); + } + } + + // Check docs/pipeline-setup.md was created + const setupDocPath = findPath(projectRoot, path.join('docs', 'pipeline-setup.md')); + if (!setupDocPath) { + return block('docs/pipeline-setup.md was not created. The setup documentation is required.'); + } + + return approve(); + } + + // --- GitHub Actions / ADO path (future implementation) --- + const pipelinePath = ghWorkflowPath || adoPipelinePath; + const isGitHub = !!ghWorkflowPath; + + let pipelineContent; + try { + pipelineContent = fs.readFileSync(pipelinePath, 'utf8'); + } catch { + return block(`Pipeline file '${pipelinePath}' could not be read. The file may be corrupt.`); + } + + if (!pipelineContent.trim()) { + return block(`Pipeline file '${pipelinePath}' is empty.`); + } + + // Check for required YAML keys + if (isGitHub) { + if (!pipelineContent.includes('on:') && !pipelineContent.includes("'on':")) { + return block("GitHub Actions workflow is missing the 'on:' trigger section."); + } + if (!pipelineContent.includes('jobs:')) { + return block("GitHub Actions workflow is missing the 'jobs:' section."); + } + } else { + if (!pipelineContent.includes('trigger:')) { + return block("Azure DevOps pipeline is missing the 'trigger:' section."); + } + if (!pipelineContent.includes('stages:') && !pipelineContent.includes('jobs:')) { + return block("Azure DevOps pipeline is missing both 'stages:' and 'jobs:' sections."); + } + } + + if (!pipelineContent.includes('pac pages upload-code-site')) { + return block("Pipeline file does not contain a 'pac pages upload-code-site' step. The Power Pages deploy step is missing."); + } + + // Check for unreplaced placeholder tokens + const lines = pipelineContent.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) continue; + if (/\{[A-Z][A-Z_]+\}/.test(trimmed) && !trimmed.includes('${{')) { + return block(`Pipeline file contains an unreplaced placeholder token in line: "${trimmed.substring(0, 100)}". Fill in all required values.`); + } + } + + const setupGuidePath = findPath(projectRoot, path.join('docs', 'ci-cd-setup.md')); + if (!setupGuidePath) { + return block('docs/ci-cd-setup.md was not created. The setup guide is required to document manual steps.'); + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/setup-solution/SKILL.md b/plugins/power-pages/skills/setup-solution/SKILL.md new file mode 100644 index 000000000..0feb106e1 --- /dev/null +++ b/plugins/power-pages/skills/setup-solution/SKILL.md @@ -0,0 +1,915 @@ +--- +name: setup-solution +description: >- + Creates a Dataverse publisher and solution, then adds Power Pages site components to + the solution for ALM and deployment management. Use when asked to: "create solution", + "set up solution", "add to solution", "package site into solution", "create publisher", + "solutionize my site", or "set up ALM for my site". +user-invocable: true +argument-hint: "Optional: solution unique name (e.g., 'ContosoSite')" +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, TaskCreate, TaskUpdate, TaskList, AskUserQuestion, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search, mcp__plugin_power-pages_microsoft-learn__microsoft_docs_fetch +model: opus +--- + +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. + +# setup-solution + +Creates a Dataverse publisher and solution, then adds Power Pages site components. Writes `.solution-manifest.json` for use by `export-solution`, `import-solution`, and `setup-pipeline` skills. + +## Prerequisites + +- PAC CLI installed and authenticated (`pac env who` returns an environment URL) +- Azure CLI installed and logged in (`az account show` succeeds) +- `powerpages.config.json` exists in the project root (site must be deployed at least once so `.powerpages-site/` exists with component records) + +## Phases + +### Phase 0 — ALM plan gate + +> **`plan-alm` is the front door.** When the user expresses an ALM intent (*promote / ship / deploy / set up CI-CD / move to staging / push to prod*), the orchestrator (`/power-pages:plan-alm`) should run first. This Phase 0 enforces that and is meant to fail closed when there's no plan, not to be a one-time check the user can dismiss forever. + +**Skip rule.** If this skill was invoked *as part of an active `plan-alm` orchestration*, skip Phase 0 entirely and proceed to Phase 1. The gate helper exposes this via its `inExecution` block — pass through silently to Phase 1 when: + +``` +inExecution.status === "active" +``` + +The helper computes this from `docs/.alm-plan-data.json` — `PLAN_STATUS === "In Execution"` AND `LAST_INVOCATION_AT` within the last 60 minutes. `check-alm-plan.js` refreshes `LAST_INVOCATION_AT` automatically on every invocation that finds the plan in execution, so each in-chain skill keeps the chain alive for the next one — even multi-hour deploys (deploy-pipeline alone can take 60 min per stage) survive the window without the chain incorrectly de-classifying. Stalled chains (no heartbeat for > 60 min) reclassify as `stale-heartbeat` and Phase 0 gates fire normally so an abandoned plan doesn't silently bypass user confirmation. + +When `inExecution.status` is anything other than `"active"` (`"not-running"`, `"stale-heartbeat"`, `"no-plan"`), run the Phase 0 gate flow below. Branch on the remaining helper fields: + +**Step 1 — Run the gate helper.** + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "." +``` + +The helper returns JSON with `{ exists, deferred, stale, staleness: { reason, detail }, generatedAt, planStatus, ... }`. Sync mode (when `.solution-manifest.json` already exists) may additionally pass `--envUrl`, `--token`, `--solutionId` once Phase 1 has acquired them, but for the initial gate the existence-only check is sufficient. + +**Step 2 — Branch on the result.** + +| Result | Behavior | +|---|---| +| `deferred: true` | The user has explicitly deferred ALM for this project (`.alm-deferred` marker present). Pass through silently to Phase 1 — do not nag. | +| `exists: false` | The user hasn't run `plan-alm` yet. See Step 3. | +| `exists: true, stale: false` | Plan is current. Pass through silently to Phase 1. | +| `exists: true, stale: true` (reason: `solution-modified`) | The solution changed after the plan was generated. See Step 4. | + +**Step 3 — No plan.** Tell the user: + +> "No ALM plan exists for this project. `/power-pages:plan-alm` builds one — it detects the project state, asks about your promotion strategy (PP Pipelines vs Manual export/import), and orchestrates the right skills (including this one) in the right order. Want me to run plan-alm now?" + + +> 🚦 **Gate (intent · setup-solution:0.no-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `exists:false`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Run `/power-pages:plan-alm` first? | ALM plan gate | Yes — run /power-pages:plan-alm now (Recommended), Continue without a plan (advanced — I know what I'm doing), Cancel | + +- **Yes (Recommended)** → invoke `/power-pages:plan-alm`. plan-alm's Phase 7 dispatches back into this skill at the appropriate stage. +- **Continue without a plan** → set `BYPASSED_PLAN_GATE = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Step 4 — Stale plan.** Tell the user: + +> "ALM plan exists from `{generatedAt}` but the source solution has been modified since (at `{solution.modifiedon}`). Components may have changed. Re-running `plan-alm` will refresh the analysis and the rendered HTML." + + +> 🚦 **Gate (intent · setup-solution:0.stale-plan):** Fail-closed entry gate when `check-alm-plan.js` returns `stale:true`. Helper-script-backed. + +`AskUserQuestion`: + +| Question | Header | Options | +|---|---|---| +| Refresh the plan first? | ALM plan freshness | Refresh — re-run /power-pages:plan-alm (Recommended), Continue with the existing plan, Cancel | + +- **Refresh (Recommended)** → invoke `/power-pages:plan-alm`. After completion, re-run the Phase 0 helper once to confirm freshness; if still stale, surface the detail and proceed to Phase 1 anyway (don't infinite-loop). +- **Continue** → set `STALE_PLAN_ACK = true` and proceed to Phase 1. +- **Cancel** → exit cleanly. + +**Why this gate exists.** Direct invocation of `setup-solution` builds (or syncs) a solution without consulting the orchestrator's plan. If a plan already exists and recommends a multi-solution split, running this skill standalone may consolidate components into the wrong base solution. If no plan exists yet, `plan-alm` would have surfaced split recommendations, the asset-size advisory, and missing-component gaps before any solution was created — running `setup-solution` first burns through those decisions silently. The gate ensures `setup-solution` runs in the right context, while still leaving an explicit bypass for users who genuinely know they want a one-off solution. + +### Phase 1 — Verify Prerequisites + +**Create all tasks upfront at the start of this phase.** + +Tasks to create: +1. "Verify prerequisites" +2. "Gather solution configuration" +3. "Check existing publishers and solutions" +4. "Create publisher and solution" +5. "Add site components to solution" +6. "Verify and write manifest" +7. "Present summary" + +Steps: +1. Run `pac env who` — extract `environmentUrl`, `organizationId` (shown to user for confirmation) +2. Run `verify-alm-prerequisites.js` to confirm PAC CLI auth, acquire a token, and verify API access: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{environmentUrl}" + ``` + Capture output as JSON; extract `.envUrl` (store as `envUrl`) and `.token` (store as `token`). If the script exits non-zero, stop and explain what is missing (reference `${CLAUDE_PLUGIN_ROOT}/references/dataverse-prerequisites.md`). +3. Locate `powerpages.config.json` — read `siteName` and `websiteRecordId` +4. Confirm `.powerpages-site/` folder exists (required to find component records) +5. **Check for ALM plan context** — look for `docs/alm/alm-plan-context.json`: + + > 🚦 **Gate (plan · setup-solution:1.preloaded):** Use pre-loaded plan classifications, or re-discover. No write happens before this choice. + + - If found, ask via `AskUserQuestion`: + > "An ALM plan was previously generated for this site. It includes a pre-classified list of site settings (keepAsIs, promoteToEnvVar, authNoValue, excluded). Would you like to use those choices, or re-discover and re-classify everything now?" + - Options: **"Use pre-loaded choices from plan"** / **"Re-discover and re-classify"** + - If user chooses pre-loaded: read `docs/alm/alm-plan-context.json`, store the `siteSettings` object as `preloadedSettings`. When Step 5.3 is reached, **skip the query and classification logic** — use `preloadedSettings` directly. + - If user chooses re-discover: proceed normally (Steps 5.3–5.4 query Dataverse and reclassify). +6. **Detect sync mode** — check whether `.solution-manifest.json` exists in the project root. + - **If present**: read it and verify the `solutionId` still exists in the target environment via `GET {envUrl}/api/data/v9.2/solutions({solutionId})?$select=solutionid,uniquename,version,ismanaged`. + - If the solution is still present and unmanaged in this environment: set `syncMode = true` and store `existingSolution` = the manifest contents. + + > 🚦 **Gate (consent · setup-solution:1.stale-manifest):** Manifest references a solution missing from the current env. Start fresh (back up the manifest and create a new solution) or abort. + + - If the solution was not found, is managed, or is in a different environment: treat as a **stale manifest**, inform the user, and ask via `AskUserQuestion`: + > "The existing `.solution-manifest.json` points to solution `{uniqueName}` v{version} which I could not find in the current environment. Would you like to: 1) Start fresh (back up the manifest and create a new solution), 2) Abort so you can investigate?" + Proceed only after an explicit choice. + - **If absent**: set `syncMode = false` — this is a fresh setup. +7. **Report the chosen mode** to the user: + - `syncMode = true`: "Found existing solution `{uniqueName}` v{version}. Running in **sync mode** — I'll discover the current site inventory, diff against what's already in the solution, and only add missing components." + - `syncMode = false`: "No existing solution manifest found. Running a **fresh setup** — I'll create a publisher and solution, then add all site components." + +8. **Check for split plan (multi-solution mode)** — look for `docs/alm/alm-split-plan.json` (written by `plan-alm` Phase 1 Step 10): + - If found and `proposedSolutions.length > 1`, set `MULTI_SOLUTION_MODE = true` and store the array as `PROPOSED_SOLUTIONS`. + - In multi-solution mode: + - Phase 2 asks for publisher details **once** (shared across all solutions) and presents the proposed solution names/versions for **confirmation** (user can override each before proceeding). + - Phase 4 creates the publisher first (single serial step — every solution binds to it), then creates the solutions in `PROPOSED_SOLUTIONS` **in parallel**. The `order` field is data for downstream pipeline-stage ordering — it does NOT constrain creation order, since each solution is independent (distinct `uniqueName`, shared `publisherId`, no inter-solution dependency). + - Phase 5 partitions `AddSolutionComponent` calls per solution based on `proposedSolutions[i].componentTypes` and `tableLogicalNames` (for Strategy 3). + - Phase 6 writes manifest v2 (see below). + - If not found or `proposedSolutions.length === 1`, proceed in single-solution mode (existing flow). + +### Phase 1.5 — Ground in current ALM documentation + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/alm-docs-grounding.md` + +Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline. + +1. Run `microsoft_docs_search` with the query: `Power Pages solution publisher creation Dataverse component types ALM`. +2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/solution-concepts-alm` (and at most one sister page if the search surfaces a relevant new tutorial — e.g. multi-solution layering, managed-properties guidance) in parallel via `microsoft_docs_fetch`. +3. Extract a one-paragraph summary of what Microsoft Learn currently says about solution components, publisher prefix immutability, managed vs unmanaged choice, and component-type integers. Compare against `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` and flag any divergence (new component types, changed action signatures, deprecated patterns). +4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 4 (Create Publisher and Solution). + +### Phase 2 — Gather Solution Configuration + +> **Skip this entire phase when `syncMode = true`.** Use `existingSolution.publisher` and `existingSolution.solution` from the manifest instead. Jump to Phase 5. + + +> 🚦 **Gate (consent · setup-solution:2.publisher-prefix):** Publisher prefix is PERMANENT and prefixed to every component logical name. Must be confirmed explicitly. Cancel exits before any publisher/solution write. + +Ask user (via `AskUserQuestion`) for: + +1. **Publisher unique name** (e.g., `contoso`) — lowercase letters/numbers only, no spaces. **Explain this is permanent and cannot be changed.** +2. **Publisher friendly name** (e.g., `Contoso`) — display name +3. **Publisher prefix** (e.g., `con`) — 2–8 lowercase letters, prefixed to all components. **Explain this is permanent and cannot be changed.** +4. **Solution unique name** (e.g., `ContosoSite`) — letters/numbers/underscores, no spaces +5. **Solution friendly name** (e.g., `Contoso Site`) — display name +6. **Solution version** (default: `1.0.0.0`) — must be `major.minor.build.revision` format + +Present a confirmation summary of all values and wait for user approval before proceeding. + +> **Key Decision Point**: Publisher prefix and publisher unique name are **irreversible** — pause and explicitly confirm with the user before proceeding. + +### Phase 3 — Check Existing State + +> **Skip this entire phase when `syncMode = true`.** The manifest guarantees the solution exists and we already validated it in Phase 1 Step 6. + +Before creating anything, check if publisher and solution already exist: + +1. Query publisher: `GET {envUrl}/api/data/v9.2/publishers?$filter=uniquename eq '{publisherUniqueName}'&$select=publisherid,uniquename,customizationprefix` + (No dedicated script for publishers — query the OData endpoint directly.) +2. Check solution existence using `verify-solution-exists.js`: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-solution-exists.js" \ + --envUrl "{envUrl}" \ + --uniqueName "{solutionUniqueName}" \ + --token "{token}" + ``` + Capture output as JSON; check `.found` (boolean). If `found`, also read `.solutionId`, `.version`, and `.isManaged` for display. + +Report findings to user: +- If publisher exists: "Found existing publisher `{name}` (prefix: `{prefix}`). Will reuse it." +- If solution exists: "Found existing solution `{name}` version `{version}`. Will reuse it and add components." +- If neither exists: "Will create new publisher and solution." + +Wait for user confirmation before proceeding. + +### Phase 4 — Create Publisher and Solution + +> **Skip this entire phase when `syncMode = true`.** The publisher and solution already exist. +> +> **Version bump in sync mode**: before any add operations in Phase 5, bump the existing solution's patch segment so the post-sync manifest and any subsequent export cleanly supersede the prior version. Use the shared helper — it is the single source of truth for the bump rule (pad-with-zero for missing segments, integer-numeric `1.0.0.9 → 1.0.0.10`, reject `1.0.0.a`, reject more-than-4 segments). The same helper is called from `export-solution` Phase 4 Step 4.0 — both skills must produce identical bumps for the same input version. +> +> ```bash +> node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/bump-solution-version.js" \ +> --envUrl "{envUrl}" \ +> --token "{token}" \ +> --solutionId "{solutionId}" \ +> --projectRoot "." +> ``` +> +> Capture output as JSON; the helper returns `{ previous, next, bumped: true, manifestUpdated, manifestUpdateReason }`. Passing `--projectRoot "."` lets the helper update `.solution-manifest.json`'s `solution.version` (single-solution) or matching `solutions[].version` (multi-solution) field automatically — without it, the manifest drifts behind every bump. Update `existingSolution.solution.version` locally to `.next` so the final manifest write reflects the bump. Do this **before** Step 5.6's component adds, so the manifest stays consistent if the skill is interrupted midway. **Do not inline the PATCH** — diverging the rule between this skill and `export-solution` is exactly the bug class the helper exists to prevent. + +Refer to `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` for exact request body templates. + +1. **Create publisher** (if not existing): + - `POST {envUrl}/api/data/v9.2/publishers` with publisher body + - Extract `publisherId` from `OData-EntityId` response header + - On failure: report error, stop (do not proceed to solution creation) + - This step **must complete before any solution creation** — every solution body binds `publisherid@odata.bind`. Single serial step, no parallelization. + +2. **Create solution(s)**: + + **Single-solution mode** (`MULTI_SOLUTION_MODE = false`) — call `create-solution.js`. Omit `--token` so the helper refreshes via `getAuthToken(envUrl)` at call time (passing a possibly-stale cached token would surface as a 401 the helper doesn't retry): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-solution.js" \ + --envUrl "{envUrl}" \ + --uniqueName "{solutionUniqueName}" \ + --friendlyName "{solutionFriendlyName}" \ + --version "{version}" \ + --publisherId "{publisherId}" \ + --description "Power Pages solution for {siteName}" + ``` + Capture output as JSON; extract `.solutionId` (store as `solutionId`). On failure (non-zero exit or `created: false`): report error, stop. + + **Multi-solution mode** (`MULTI_SOLUTION_MODE = true`) — call `create-solutions-batch.js`, which fans out all `PROPOSED_SOLUTIONS` in parallel via `Promise.allSettled` (typical 5-6 solution splits complete in ~2s vs ~10s serial). The helper skips `isFutureBuffer: true` entries automatically (the reserved buffer is created later when the user actually adds new components) and handles 409 races idempotently via `verify-solution-exists.js`. Write the specs to a tmp JSON file, then invoke: + ```bash + node -e "require('fs').writeFileSync('./docs/alm/.solutions-batch.json', JSON.stringify({{PROPOSED_SOLUTIONS_AS_SPECS}}))" + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-solutions-batch.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --publisherId "{publisherId}" \ + --solutionsFile ./docs/alm/.solutions-batch.json + ``` + Where `{{PROPOSED_SOLUTIONS_AS_SPECS}}` is `PROPOSED_SOLUTIONS` mapped to `{ uniqueName, friendlyName: displayName, version: "1.0.0.0", description, isFutureBuffer }` per entry (carry the `isFutureBuffer` flag through so the helper can skip it). Capture the output as JSON; build `SOLUTIONS_BY_NAME = { uniqueName → { solutionId, created } }` from `result.results` (entries with `skipped: true` are not added — `Future` buffer solutions don't exist in Dataverse yet). If `result.failed > 0`, surface the per-entry `error` strings and stop — successfully-created solutions remain in Dataverse and the user can re-run setup-solution in sync mode to recover. Delete the tmp file after the call (`./docs/alm/.solutions-batch.json`). + + Token must be fresh before the batch — `create-solutions-batch.js` refreshes once at start via `getAuthToken(envUrl)` if no `--token` is passed, so prefer omitting `--token` over passing a stale one. + +3. Report: "Publisher `{name}` is ready. Created `{N}` solution(s): `{name1}`, `{name2}`, …" (single-solution mode: report just the one). + +### Phase 5 — Add Site Components + +Refer to `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` for `AddSolutionComponent` body templates and `powerpagecomponents` discovery patterns. + +> **Sync-mode behavior**: When `syncMode = true`, run the discovery helper with `--solutionId` populated and use the returned `missing.*` arrays as the candidate set. Everything else in this phase (dynamic component-type lookup in 5.1, categorization in 5.3, OAuth secret conversion in 5.4, env var adoption in 5.4b, **orphan ppc adoption in 5.4c**, manifest summary in 5.5, bulk add in 5.6) runs the same way, just with a pre-filtered "only things that aren't already in the solution" list. The goal of sync mode is: a user who added a server logic, bot, flow, env var, or page *after* `setup-solution` last ran can re-invoke the skill and get those components adopted without any fresh-setup prompts. +> +> **Fresh-mode behavior** (`syncMode = false`): run the full discovery as documented below — every ppc, every site language, every custom table, every publisher-prefix env var becomes a candidate for inclusion. + +#### Step 5.1 — Discover Component Types Dynamically + +**Do not hardcode component type numbers.** Component type codes are environment-specific metadata and vary across tenants. Always resolve them at runtime using `discover-component-types.js`. + +Run `discover-component-types.js` with the website record ID plus one sample powerpagecomponent ID and one site language ID (obtained from the preliminary discovery queries in Step 5.2 below — run those first if not yet available): +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-component-types.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --websiteRecordId "{websiteRecordId}" \ + --powerpageComponentId "{anyPowerpageComponentId}" \ + --siteLanguageId "{siteLanguageId}" +``` +Capture output as JSON; extract `.websiteComponentType`, `.subComponentType`, and `.siteLanguageComponentType`. **Use the JSON values returned by the helper exactly as-is — do not substitute "typical" values from documentation.** Observed reference values across tenants include `10426`/`10427`/`10428` and `10429`/`10428`/`10430`, but the actual values vary per environment and must come from this script's runtime query. The three sibling unified entities each have their own componenttype — site language is NOT included by `AddRequiredComponents: true` on the website and must be added explicitly. See `references/solution-api-patterns.md` for the full 3-entity model. + +If the script reports the website record is not yet in any solution, stop and inform the user that the site must be deployed (via `/power-pages:deploy-site`) before it can be solutionized. If `subComponentType` is absent (no sub-components indexed yet), proceed anyway — you will discover all component IDs in Step 5.2. + +#### Step 5.2 — Discover All Components + +Run six discovery queries in parallel: + +**A. Component type labels** (for display names): +``` +GET {envUrl}/api/data/v9.2/GlobalOptionSetDefinitions(Name='powerpagecomponenttype') +``` +Build a `typeLabel` map: `{ [Value]: Label.UserLocalizedLabel.Label }`. Fall back to the static table in `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` Section 3b if this fails. + +**B. All Power Pages sub-components for this site**: +``` +GET {envUrl}/api/data/v9.2/powerpagecomponents + ?$filter=_powerpagesiteid_value eq '{websiteRecordId}' + &$select=powerpagecomponentid,name,powerpagecomponenttype + &$orderby=powerpagecomponenttype +``` +Follow `@odata.nextLink` pagination. Group by `powerpagecomponenttype` using `typeLabel` for display names. + +**C. Site language records**: +``` +GET {envUrl}/api/data/v9.2/powerpagesitelanguages?$filter=_powerpagesiteid_value eq '{websiteRecordId}'&$select=powerpagesitelanguageid,languagecode,displayname +``` +Store all language IDs. + +**D. Dataverse tables** — always discover from the environment, don't rely on a manifest file alone: + +1. Read `.datamodel-manifest.json` if present (for the known list of tables created by `setup-datamodel`) +2. **Also** query the environment directly for all custom unmanaged tables, filtering by the publisher prefix: +``` +GET {envUrl}/api/data/v9.2/EntityDefinitions?$select=LogicalName,MetadataId,IsManaged,IsCustomEntity +``` +Filter client-side: `IsCustomEntity === true && IsManaged === false`. Group by publisher prefix (characters before first `_`). Present only tables whose prefix matches the site publisher — or if no prefix match, present all custom unmanaged tables and let the user decide. + +> **Important note on tables**: Dataverse solutions carry **schema only** — entity definitions, columns, relationships, forms, and views. Table **data/records** do NOT travel with the solution. If the target environment needs seed/reference data, that requires a separate data migration step. + +**E. Cloud Flow link components (powerpagecomponenttype 33) — runtime field introspection:** + +Query the `powerpagecomponent` records that link this site to Cloud Flows: +``` +GET {envUrl}/api/data/v9.2/powerpagecomponents + ?$filter=_powerpagesiteid_value eq '{websiteRecordId}' and powerpagecomponenttype eq 33 + &$select=powerpagecomponentid,name +``` + +If results are returned, fetch the first record **without** a `$select` to discover the workflow lookup field: +``` +GET {envUrl}/api/data/v9.2/powerpagecomponents({firstComponentId}) +``` +Scan the response JSON for `_*_value` keys with non-null GUIDs that do not equal `websiteRecordId`. The remaining key is the workflow lookup field (e.g., `_adx_workflow_value`). Re-query all type-33 components with that field in `$select` to collect all backing `workflowId` GUIDs. Then resolve each workflow name and status: +``` +GET {envUrl}/api/data/v9.2/workflows({workflowId})?$select=name,workflowid,statecode +``` +Also discover the workflow's component type (for `AddSolutionComponent`): +``` +GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=objectid eq '{workflowId}'&$select=componenttype&$top=1 +``` +Store as `workflowComponentType`. If the query returns empty (flow not yet in any solution), note it — the backing flow record still exists and can be added. + +If type-33 query returns no records, store `cloudFlows = []` and skip. + +**F. Bot Consumer link components (powerpagecomponenttype 27) — runtime field introspection:** + +Same pattern as Query E. Query type-27 `powerpagecomponent` records, discover the bot lookup field via introspection on the first record, collect bot GUIDs, resolve bot names via: +``` +GET {envUrl}/api/data/v9.2/bots({botId})?$select=name,botid,statecode +``` +And discover bot component type via `solutioncomponents`. Store as `botComponents`. If no type-27 records exist, store `botComponents = []` and skip. + +**G. Connection references used by cloud flows in this solution:** + +Cloud flows reference connectors via `connectionreference` records. These records are separate Dataverse entities; if they aren't in the solution, the solution will export cleanly but **fail to import** in the target environment with a `MissingDependency` / connection-reference validation error. We must enumerate them here and add them in Step 5.6. + +Skip this query if Query E returned `cloudFlows = []`. + +1. Query connection references owned by this site's publisher: + ``` + GET {envUrl}/api/data/v9.2/connectionreferences + ?$filter=startswith(connectionreferencelogicalname,'{publisherPrefix}_') + &$select=connectionreferenceid,connectionreferencelogicalname,connectionreferencedisplayname,connectorid + ``` + +2. For each cloud flow (from Query E), parse its `clientdata` JSON (`workflows({workflowId})?$select=clientdata`) to find which `connectionReferenceLogicalName`s it uses. Filter the Query G.1 result to just those references — these are the ones that **must** be in the solution. + +3. **Resolve the connection-reference componenttype at runtime** — the value is environment-specific (observed values include `10137` and `10160` across tenants; do NOT hardcode): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-component-types.js" \ + --envUrl "{envUrl}" --token "{token}" \ + --websiteRecordId "{websiteRecordId}" \ + --objectIds "{firstConnectionReferenceId}" + ``` + Read `.resolved[0].componentType` and store as `connectionReferenceComponentType`. If the connection ref is not yet in any solution (`resolved[0].componentType === null`), it has never been added — fall back to passing one ID per call until one resolves, or query a known sibling connection ref. Without a runtime-resolved value, do **not** guess. + + Store the filtered list as `connectionReferences[]`. + +If Query G returns no references (the cloud flows don't use connectors, or the publisher prefix doesn't match — rare), store `connectionReferences = []` and skip. Surface a soft warning if cloud flows exist but no matching connection refs were found — the user should verify whether their flows are using connectors that need binding in target envs. + +#### Step 5.3 — Categorize Site Settings + +**If `preloadedSettings` is available** (user chose "Use pre-loaded choices from plan" in Phase 1 Step 5), skip the classification below — use `preloadedSettings.keepAsIs`, `preloadedSettings.promoteToEnvVar`, `preloadedSettings.authNoValue`, and `preloadedSettings.credentialNeedsDecision` directly. (Plans generated before 2026-05-08 use the older `excluded` bucket — treat its contents as `credentialNeedsDecision` for backward compatibility.) + +**Otherwise**, run the shared classifier — `${CLAUDE_PLUGIN_ROOT}/scripts/lib/classify-site-settings.js` — which is the **single source of truth** for the credential regex + tier mapping shared with `plan-alm` Phase 1 Step 7. Either invoke the CLI (pipe JSON to stdin) or `require()` it inline. The output is the same four-bucket shape `plan-alm` produces: + +```js +{ + keepAsIs: [{name}], // Tier 3 — added to the solution unchanged + authNoValue: [{name}], // Tier 2b — Authentication/AzureAD with empty value; added as-is, user sets per-env + promoteToEnvVar: [{name, value}], // Tier 2a — Authentication/AzureAD with value; reviewed at Step 5.4.A + credentialNeedsDecision: [{name, value}] // Tier 1 — credential-style names; bulk-with-override prompt at Step 5.4.C +} +``` + +Tier definitions (mirroring the regex in `classify-site-settings.js`): + +| Tier | Bucket | Matcher | Handling | +|---|---|---|---| +| 1 — Credential-style | `credentialNeedsDecision` | `CREDENTIAL_REGEX` (`ConsumerKey\|ConsumerSecret\|ClientId\|ClientSecret\|AppSecret\|AppKey\|ApiKey\|Password`, case-insensitive) | Bulk-with-override prompt at Step 5.4.C — auto-classify (Secret/String defaults), all-Secret, all-String, skip-all, or pick-per-credential | +| 2a — Auth config with value | `promoteToEnvVar` | `AUTH_PREFIX_REGEX` (`Authentication/` or `AzureAD/`) AND NOT credential AND has a value | Multi-select prompt at Step 5.4.A — which to back with env vars | +| 2b — Auth config, no value | `authNoValue` | Same prefix, no value | Added to solution as-is with a note (user sets value per env) | +| 3 — All other settings | `keepAsIs` | Anything else | Included in solution unchanged | + +**Do NOT inline the regex here** — if it's wrong in this skill but right in `plan-alm`, classifications drift between plan time and execution time. The regex lives in `classify-site-settings.js` exclusively; both skills require it. + +**Note on `authNoValue` settings**: These are auth configuration settings where no value has been set in the dev environment. They will be added to the solution as-is. After deploying to each target environment, the correct value should be configured there. Present these in a warning note box during the manifest review (Step 5.5). + +#### Step 5.4 — Handle Auth Settings: Promote to Env Var? + +Before presenting the final manifest, handle the three non-keepAsIs categories: + +**A. `promoteToEnvVar` settings (auth config with values):** + + +> 🚦 **Gate (plan · setup-solution:5.4a.promote):** Multi-select over auth settings — which to promote to env vars. Leave others as plain site settings. + +Ask via `AskUserQuestion` with `multiSelect: true`, listing each `promoteToEnvVar` setting by name + current value: + +> "These authentication configuration settings have values set in your dev environment. If any of them should have **different values per environment** (e.g., feature flags, login modes, AzureAD tenant settings), promote them to environment variables — they'll be tracked in the solution and injected per stage at deploy time. Leave others as plain site settings." + +- One option per setting (e.g. `Authentication/Registration/LocalLoginEnabled = true`) +- Plus options: **"Promote all of them to env vars"** and **"Keep all as plain site settings"** + +For each setting the user selects to promote: +1. Generate the canonical schema name with `${CLAUDE_PLUGIN_ROOT}/scripts/lib/generate-env-var-schema-name.js` so it matches what `configure-env-variables` and `deploy-pipeline` will expect later: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/generate-env-var-schema-name.js" \ + --publisherPrefix "{prefix}" \ + --settingName "{settingName}" + ``` + Output: `{ schemaName, sanitized }`. The helper is the **single source of truth** for the canonical rule (`{prefix}_{sanitized(settingName)}.toLowerCase()`) — do not inline it. setup-solution and configure-env-variables MUST emit identical schema names for the same logical setting; inlining the rule risks divergent outputs. + +2. Create an `environmentvariabledefinition` using the resolved schema name: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-env-var-definition.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --schemaName "{schemaName from step 1}" \ + --displayName "{friendlyName}" \ + --type 100000000 + ``` + Use type `100000000` (String) for auth config settings (not Secret — these are feature flags, not credentials). Capture output as JSON; extract `.definitionId` and `.schemaName`. +2. Record the `definitionId` for inclusion in the components list (Step 5.6, `ComponentType: 380`). +3. **Link the site setting to the env var** using `link-site-setting-to-env-var.js`: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/link-site-setting-to-env-var.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --siteSettingId "{settingId}" \ + --definitionId "{definitionId}" \ + --schemaName "{schemaName}" + ``` + Check `.ok` and `.verified` are both `true`. + +Settings the user chose NOT to promote move from `promoteToEnvVar` into `keepAsIs` — they will be included in the solution as plain site settings. + +**B. `authNoValue` settings (auth config, no dev value):** + +No user decision required. These are automatically included in the solution as-is. At Step 5.5, display them in a warning box: +> "The following auth settings have no value set in your dev environment. They will be added to the solution as-is. After deploying to each target environment, verify or set the correct value there." + +**C. `credentialNeedsDecision` settings (credential-style — bulk-with-override prompt):** + +These are credential-style site settings (ConsumerKey / ClientSecret / etc.) that need a decision before going into the solution. Shipping raw values inside the solution zip is a real exposure, so the safe path is to add the site-setting record to the solution and route the value through an environment variable per stage. **Asking per credential is too much when N is large** (a typical site has 20+ auth-related credentials across multiple OAuth providers), so this step uses a **bulk-with-override** prompt: one question covers all N credentials, with a per-credential escape hatch for granular control. + +**Step 5.4.C.1 — Auto-classify by name pattern.** + +Call `autoClassifyCredential(name)` from `${CLAUDE_PLUGIN_ROOT}/scripts/lib/classify-site-settings.js` for each setting. The helper applies these regexes in order (the **single source of truth** — do not duplicate them here): + +| Default | Matcher in helper | When it fires | +|---|---|---| +| **Secret env var** (`type: 100000005`) | `CREDENTIAL_SECRET_REGEX` (`Secret\|Password\|ApiKey\|AppKey`) | Names with these substrings — `*ClientSecret`, `*AppSecret`, `*Password`, `*ApiKey`, `*AppKey` | +| **String env var** (`type: 100000000`) | `CREDENTIAL_STRING_REGEX` (`Id\|ConsumerKey`) AND not Secret | Names like `*ClientId`, `*ConsumerKey`, `*TenantId`, `*AppId` | +| **Secret env var** (fallback) | (helper's defensive default when neither matches) | Anything else — defensive: credential names are sensitive by default | + +The helper returns `{ default: 'secret' | 'string', reason }` for each setting. Group the results into `AUTO_CLASSIFY = { secrets: [...], strings: [...] }` and show the user a one-line summary: *"Auto-classified {N} credential-style settings: {S} as Secret env vars (Key Vault per stage), {T} as String env vars (plain text per stage)."* + +**Step 5.4.C.2 — Bulk prompt.** + + +> 🚦 **Gate (consent · setup-solution:5.4c.credentials):** Bulk credential handling decision — Secret env var (Key Vault per stage), String env var (plain per stage), or skip. Per-credential choice. Determines whether secret values ship in the solution zip. + +Ask **one** `AskUserQuestion` covering all N credentials: + +> "{N} credential-style site settings detected (`{firstFew.join(', ')}{N>3?', ...':''}`). How should I handle them? +> +> Shipping their values inside the solution zip is a real exposure, so the recommended approaches add the site-setting record to the solution and route the value through an environment variable per stage. The actual secret value never ships in the zip — it's set per-environment in `deploymentsettingsjson`." + +Options: +1. **Auto-classify by name** *(recommended)* — Apply the auto-classification from Step 5.4.C.1: {S} as Secret env vars, {T} as String env vars. One confirmation, all {N} handled. (Default option.) +2. **All as Secret env vars** — Treat every credential as a Key-Vault-backed Secret env var. Conservative; works for any credential but adds Key Vault dependency for stage values that don't actually need it. +3. **All as String env vars** — Treat every credential as a plain-text per-stage env var. Use only when none of the credentials are true secrets (e.g. an internal-only test setup). +4. **Skip all** — Don't add any to the solution. The user manages all credential values out-of-band per environment. Equivalent to the pre-IronItOut "excluded" behavior. +5. **Pick per credential** — Run a per-credential prompt for granular control (Secret / String / Skip per setting). Reach for this when you have a mix of true secrets and non-sensitive IDs that don't fit the auto-classification cleanly. + +Branching logic: + +- **Option 1 (Auto-classify)**: For each setting in `AUTO_CLASSIFY.secrets`, run the env-var-creation steps below with `--type 100000005`. For each in `AUTO_CLASSIFY.strings`, run with `--type 100000000`. No additional prompts. +- **Option 2 (All Secret)**: Treat all N as Secret. Same loop with `--type 100000005`. +- **Option 3 (All String)**: Treat all N as String. Same loop with `--type 100000000`. +- **Option 4 (Skip all)**: Move all N into a `userOptedOutOfSolution` bucket. Surface in Step 5.5: *"The following credential-style settings were skipped at user request and are NOT in the solution. Configure them manually in each target environment after deployment: `{names}`."* +- **Option 5 (Pick per credential)**: For each setting, run a 3-option `AskUserQuestion` (Secret env var / String env var / Skip). The auto-classification informs the per-prompt default but the user can override. + +**Step 5.4.C.3 — Env var creation (shared by Options 1, 2, 3, and 5's non-Skip selections).** + +For each setting routed to env-var-backed handling: + +1. Generate the canonical schema name with `${CLAUDE_PLUGIN_ROOT}/scripts/lib/generate-env-var-schema-name.js` (same helper Step 5.4.A uses — single source of truth so configure-env-variables and deploy-pipeline can reference the same schema names later): + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/generate-env-var-schema-name.js" \ + --publisherPrefix "{prefix}" \ + --settingName "{settingName}" + ``` + +2. Create an `environmentvariabledefinition` using the resolved schema name: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/create-env-var-definition.js" \ + --envUrl "{envUrl}" \ + --token "{token}" \ + --schemaName "{schemaName from step 1}" \ + --displayName "{friendlyName}" \ + --type "{100000005 for Secret, 100000000 for String}" + ``` + For Secret env vars, do NOT pass `--defaultValue` — the dev value goes into Key Vault per stage, not into the definition. For String env vars, capture the dev value as the default. +3. Record the `definitionId` for inclusion in the components list (Step 5.6, `ComponentType: 380`). +4. Link the site setting to the env var via `link-site-setting-to-env-var.js` (same call as Step 5.4.A above). +5. The site setting itself is added to the solution alongside the env var definition — both are tracked components. + +If any single env-var creation fails (token expired mid-loop, schema-name collision, etc.), surface the failure with the setting name + reason and ask the user whether to retry, skip just that setting, or abort the whole bulk operation. Do not silently drop credentials. + +**Backward compatibility**: when reading a `preloadedSettings` plan generated before 2026-05-08, treat any entries in `preloadedSettings.excluded` as `credentialNeedsDecision` and run the bulk-with-override prompt above. + +#### Step 5.4b — Adopt Orphaned Env Var Definitions + +Separately from the OAuth-secret conversion above, other skills (notably `setup-auth`, `add-server-logic`, and `configure-env-variables`) may have previously created environment variable definitions that were never added to a user solution — they land in the `Default` solution and silently drift. This step discovers and adopts them. + +Run the shared discovery helper to get the complete site inventory in one call: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-site-components.js" \ + --envUrl "{envUrl}" --token "{token}" \ + --siteId "{websiteRecordId}" \ + --publisherPrefix "{publisherPrefix}" \ + --solutionId "{solutionId}" +``` + +Parse stdout as JSON and read `missing.envVars` — env var definitions whose `schemaname` starts with the publisher prefix but are not already `solutioncomponents` of this solution. + +For each entry, also query which solution it currently belongs to (so the user can tell `Default`-only orphans apart from env vars that another user solution intentionally owns): + +``` +GET {envUrl}/api/data/v9.2/solutioncomponents + ?$filter=objectid eq {definitionId}&$select=_solutionid_value +``` + +Then fetch the solution's `uniquename` for each hit. Build per-env-var tags: +- `DEFAULT-ONLY` — only the `Default` solution owns it (classic orphan from another skill). +- `IN OTHER SOLUTION: ` — owned by a user solution; the user may intentionally want it scoped there. + +If at least one env var has the `DEFAULT-ONLY` tag, prompt via `AskUserQuestion` with `multiSelect: true`: + + +> 🚦 **Gate (plan · setup-solution:5.4b.orphan-envvars):** Adopt env var definitions that match the publisher prefix but aren't yet in this solution. `DEFAULT-ONLY` orphans are pre-selected as Recommended; env vars already owned by another user solution are listed but not pre-selected (user opts in only if they intend to move ownership). + +> "We found env var definitions with your publisher prefix (`{prefix}_`) that aren't in **{solutionUniqueName}** yet. Select the ones you want to include. Definitions only — values stay per-environment and won't travel. +> +> 1. `{schemaName}` ({displayName}) — type {type}, currently in: **{tag}** +> 2. ... +> +> Plus: **Include all DEFAULT-ONLY orphans (Recommended)** / **Skip for now**" + +Collect selected entries into `adoptedEnvVars: [{ definitionId, schemaName, displayName, type }]`. + +If none are selected or the list is empty, `adoptedEnvVars` stays empty — the skill continues silently. + +> **Why this step exists**: before this check, env vars created by other skills were silently excluded from the site's solution and didn't travel to staging/prod. Surfacing them here is the cross-skill safety net required by the ALM-aware-by-default principle in `AGENTS.md`. + +#### Step 5.4c — Adopt Orphaned Power Pages Components + +Symmetric to 5.4b but for `powerpagecomponent` rows. Catches components on the site that were created by other skills or by `pac pages upload-code-site` without being wrapped into a user solution. Canonical examples surfaced in 2026-04-22 live validation: + +- **`invoice-checker` server logic** (type 35) — added via `/power-pages:add-server-logic` in an earlier session, never registered into the user solution. +- **`index.html`** (type 3) — the current SPA entry page refreshed by `pac pages upload-code-site`; on every rebuild a new `index.html` record is created but nothing auto-adds it to the user solution. + +Use the shared discovery helper to collect the orphan list (it already excludes Vite/Rollup bundle chunks — `Home-XYZ.js`, `index-XYZ.css`, etc. — so the prompt doesn't drown the user in hash-named noise): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-site-components.js" \ + --envUrl "{envUrl}" --token "{token}" \ + --siteId "{websiteRecordId}" \ + --publisherPrefix "{publisherPrefix}" \ + --solutionId "{solutionId}" +``` + +From the JSON output, take `missing.powerpagecomponents` and partition: + +- **Real content orphans** — entries whose `name` does NOT match the bundle-chunk regex (`[-.][A-Za-z0-9_-]{7,14}\.(js|mjs|cjs|css)(\.map)?$`). These are the ones to adopt. +- **Bundle-chunk orphans** — keep a count for the summary, but do NOT prompt for adoption. They're stale build artifacts, not real content. Report them in the Phase 7 summary with a suggestion to clean up via a separate housekeeping pass. + +For each real-content orphan, also deduplicate by `name`: if there are multiple `index.html` rows and one is already in the solution (newer `modifiedon`), the older orphan is a stale duplicate — **exclude it from the adoption prompt** and log it as a stale duplicate instead. Rule: keep only the most-recent orphan per `(powerpagecomponenttype, name)` pair. + +**Also take `missing.siteLanguages`** — these are `powerpagesitelanguage` records (componenttype 10428) that exist on the site but aren't in the user solution. They are NOT optional: an imported site without its language records silently fails to render post-auth because `powerpagesite.content.defaultlanguage` references an ID that doesn't exist in the target env. Include every entry verbatim in the orphan-adoption prompt — there is no bundle-chunk noise to filter for languages — and pre-select them as recommended. + +If the real-content orphan list (or `missing.siteLanguages`) is non-empty, prompt via `AskUserQuestion` with `multiSelect: true`: + + +> 🚦 **Gate (plan · setup-solution:5.4c.orphan-ppcs):** Adopt orphan `powerpagecomponent` rows (incl. `powerpagesitelanguage` records) that exist on the site but aren't in this solution. Site languages are pre-selected as Recommended because omitting them silently breaks post-auth rendering. Other ppc orphans (e.g. `invoice-checker` server logic) are pre-selected if they appear to be real content; stale build-artifact bundle chunks are filtered out upstream. + +> "Found **{N}** site components not yet in **{solutionUniqueName}**: +> +> 1. `{name}` (type {type} {typeLabel}) — currently in: **{currentSolution}** +> 2. ... +> +> Plus: **Include all orphans (Recommended)** / **Skip for now**" + +Collect selections into `adoptedPpcs: [{ id, name, type, typeLabel }]`. + +When the user selects, call `AddSolutionComponent` per entry with `AddRequiredComponents: false` and the right `ComponentType` (use the values resolved by `discover-component-types.js` in Step 5.1 — do not hardcode): +- `subComponentType` for `missing.powerpagecomponents` entries +- `siteLanguageComponentType` for `missing.siteLanguages` entries + +Do **not** set `DoNotIncludeSubcomponents: true` — the Dataverse API rejects that flag for non-Entity root components (HTTP 400 `0x80040216`) and it's not needed for these unified-entity rows. + +If both `missing.powerpagecomponents` (after filtering) and `missing.siteLanguages` are empty, the step runs silently. + +> **Why this step exists**: before this check, a recurring failure pattern was that `setup-solution` finished with the user convinced everything was wrapped up, while `invoice-checker` / `index.html` / similar site-linked records quietly stayed in the `Active` solution and didn't travel to staging/prod. Today's live validation found 1 real orphan (`invoice-checker`) on SupplierInvoicePortal — adopted via AddSolutionComponent, solution bumped from v1.0.0.1 → v1.0.0.2. + +#### Step 5.5 — Present Full Manifest and Get User Confirmation + +**This is the key decision point.** Build a full manifest of everything that will be added and present it to the user before writing anything. + +If custom tables were discovered, ask via `AskUserQuestion` with `multiSelect: true` **before** showing the final manifest: +- First option: **"Include all N tables (Recommended)"** — pre-selected default +- Then one option per table: `{logicalName} ({DisplayName})` +- Last option: **"Exclude all tables"** + +Present as a structured summary: + +``` +Here is everything that will be added to solution "{solutionName}": + +WEBSITE & LANGUAGE + ✓ Website record: {siteName} + ✓ Site language(s): English (en-US) + +SITE COMPONENTS ({total} components across {K} types) + ✓ Publishing States (2) + ✓ Web Pages (10) + ✓ Web Files (90) — compiled JS/CSS/HTML assets + ✓ Page Templates (5) + ✓ Web Templates (13) + ✓ Content Snippets (11) + ✓ Web Roles (2) + ✓ Website Access (6) + ✓ Table Permissions (13) — required for Web API authorization in target env + ✓ Site Markers (5) + ✓ Webpage Rules (2) + +SITE SETTINGS (64 included) + ✓ Web API settings (14): Webapi/crd50_invoice/enabled, ... + ✓ Feature flags (32): CodeSite/Enabled, Search/Enabled, ... + ✓ Auth config (18): Authentication/Registration/LocalLoginEnabled, ... + ~ OAuth as env vars (3): ids_auth_openauth_microsoft_clientsecret, ... [ENV VAR] + ✗ OAuth excluded (5): Authentication/OpenAuth/Facebook/AppSecret, ... [EXCLUDED] + +CLOUD FLOWS ({N} linked via powerpagecomponent type 33) + ✓ Invoice Approval Flow (workflowId: {guid}, Active) + ~ Draft Flow (workflowId: {guid}, Inactive — excluded by default) + +BOT CONSUMERS ({N} linked via powerpagecomponent type 27) + ✓ Support Bot (botId: {guid}, Active) + +DATAVERSE TABLES (schema only — no data) + ✓ crd50_invoice (Invoice) + ... + +ENV VAR DEFINITIONS (componenttype 380) + ✓ ids_auth_openauth_microsoft_clientsecret (Secret) [converted from OAuth secret] + ✓ crd50_auth_openauth_microsoft_clientsecret (Secret) [ADOPTED — was in Default only] + ... + +Total to add: ~{N} components +``` + +For clarity, use these tags after each env var entry in the manifest: +- `[converted from OAuth secret]` — created in Step 5.4 from a site setting +- `[ADOPTED — was in Default only]` — existed before this run; being pulled into the solution in Step 5.4b +- `[ADOPTED ppc — was in Active only]` — powerpagecomponent adopted in Step 5.4c (e.g. `invoice-checker` server logic, real site pages not yet registered) +- `[ADOPTED — also in {otherSolutionName}]` — existed in another user solution; being additionally added here (user explicitly opted in) + +If `cloudFlows` is non-empty, use `AskUserQuestion` with `multiSelect: true`: +- Option: "Include all N active cloud flows (Recommended)" +- One option per flow: `{name} ({workflowId})` +- Option: "Exclude all cloud flows" + +Default: include active flows, exclude inactive ones. **If a flow is already in a different solution**, warn the user: *"This flow is in solution X — adding it here will move it."* + +If `botComponents` is non-empty, use `AskUserQuestion` with `multiSelect: true` (same pattern). + +If both are empty, skip and display `(None discovered)`. + +After presenting the manifest summary, add a free-text escape hatch: +> "If you know of cloud flows or bots that should be in this solution but are not shown above, paste their GUIDs here (comma-separated). Leave blank to continue." + + +> 🚦 **Gate (plan · setup-solution:5.5.manifest-confirm):** Final manifest confirmation before any `AddSolutionComponent` write. Covers tables, flows, bots, env vars, orphan adoption. Cancel here keeps the in-memory manifest but no Dataverse writes happen. + +Ask via `AskUserQuestion`: +> "Does this look right? You can proceed, or tell me which categories or tables to exclude." + +Options: "Proceed with this selection" / "I want to change something" + +Wait for explicit confirmation before Step 5.6. + +#### Step 5.6 — Add All Confirmed Components + +Build a JSON array of all components to add, then call `scripts/lib/add-components-to-solution.js` to perform the bulk operation with token refresh and idempotency handling built in. + +The components array should be built in this order: + +1. **Website record** — `{ componentId: websiteRecordId, componentType: websiteComponentType, addRequired: true, description: "Website: {siteName}" }` +2. **Site language records** — one entry per language with `siteLanguageComponentType` (NOT auto-included by `AddRequiredComponents`) +3. **All confirmed powerpagecomponent groups** — one entry per component using `subComponentType` + - Table Permissions (type 18) are standard powerpagecomponents — include by default + - Exclude OAuth secret site settings that were not converted to env vars +4. **Env var definitions** — one entry per definition with `{ componentType: 380 }`. Include: + - Every env var created in Step 5.4 (OAuth-secret conversion) + - Every entry in `adoptedEnvVars` from Step 5.4b (orphans the user chose to include) +5. **Dataverse tables** — `{ componentType: 1, componentId: MetadataId }` +6. **Confirmed cloud flows** (from Step 5.5) — `{ componentId: workflowId, componentType: workflowComponentType }` (uses runtime-discovered type) +7. **Confirmed bot components** — `{ componentId: botId, componentType: botComponentType }` (uses runtime-discovered type) +8. **Connection references used by the confirmed cloud flows** (from Step 5.2 Query G) — one entry per reference: `{ componentId: connectionReferenceId, componentType: connectionReferenceComponentType, addRequired: false }`. Skip if `connectionReferences = []`. Use the **runtime-resolved** `connectionReferenceComponentType` — do **not** hardcode (observed values across tenants include `10137` and `10160`; the value is env-specific). Without these entries, the solution exports cleanly but the target import fails with a `MissingDependency` error — `deploy-pipeline` Phase 6.6.1 will surface it as a "missing connection reference" validation failure. +9. **Adopted orphan ppcs** (from Step 5.4c) — `{ componentId: ppc.id, componentType: subComponentType, addRequired: false }`. Use the `subComponentType` value resolved by `discover-component-types.js` in Step 5.1 — do **not** hardcode. Do **not** set `DoNotIncludeSubcomponents: true` — Dataverse rejects that flag on non-Entity components (HTTP 400 `0x80040216`). + +**Single-solution mode** (`MULTI_SOLUTION_MODE = false`): write the array to a temp file (e.g., `C:/Users/{user}/AppData/Local/Temp/components-to-add.json`), then run: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/add-components-to-solution.js" \ + --envUrl "{envUrl}" \ + --componentsFile "C:/Users/{user}/AppData/Local/Temp/components-to-add.json" \ + --solutionUniqueName "{solutionUniqueName}" +``` + +**Multi-solution mode** (`MULTI_SOLUTION_MODE = true`): partition the unified component list across `PROPOSED_SOLUTIONS` based on each solution's `componentTypes` (and `tableLogicalNames` for Strategy 3), then run `add-components-to-solution.js` once per solution. The per-solution loop SHOULD run serially across solutions (each helper call already batches + refreshes tokens internally; running solutions in parallel multiplies the token-refresh load with no real wall-clock win since the bottleneck is per-component Dataverse calls inside each batch). For each entry in `PROPOSED_SOLUTIONS` (skip `isFutureBuffer: true`): +1. Filter the unified component array down to components whose Dataverse type-name maps into this solution's `componentTypes` array. The mapping from numeric `componentType` → name is the same one `discover-component-types.js` and `discover-site-components.js` use (`PPC_TYPE_LABELS`). Tables route to the solution whose `tableLogicalNames` includes the table's logical name (Strategy 3) or to whichever solution claims `'Table'` (Strategies 1 and 2). +2. Write the per-solution sub-array to a temp file (e.g., `C:/Users/{user}/AppData/Local/Temp/components-{uniqueName}.json`), then invoke the helper with all three required flags: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/add-components-to-solution.js" \ + --envUrl "{envUrl}" \ + --componentsFile "C:/Users/{user}/AppData/Local/Temp/components-{uniqueName}.json" \ + --solutionUniqueName "{proposedSolutions[i].uniqueName}" + ``` + Capture the JSON summary keyed by `uniqueName`, delete the temp file. **All three flags are required** — omitting `--envUrl` or `--componentsFile` (only passing `--solutionUniqueName`) causes the helper to exit 1 with `--envUrl is required` / `--componentsFile is required`. Both must be passed per iteration, even though `--envUrl` is the same across all iterations of the loop. +3. If a component's type doesn't match any solution's `componentTypes`, surface a per-component warning and STOP — the partitioning lost a component. This usually means the split plan dropped a type (regression in `compute-split-plan.js`); the user needs to re-plan rather than silently leaking components into `Default`. + +Use `SOLUTIONS_BY_NAME` from Phase 4 to resolve each `uniqueName → solutionId` if the helper's resolution by name isn't sufficient. + +The script handles token refresh every 20 calls, treats "already in solution" as success, and outputs a JSON summary `{ total, success, skipped, failed, failures }`. Delete the temp file(s) after completion. + +### Phase 6 — Verify and Write Manifest + +1. Verify components: `GET {envUrl}/api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq '{solutionId}'&$select=objectid,componenttype` +2. Count components by type, confirm the website record (using `websiteComponentType`) is present + +2b. **Capture the post-setup env var snapshot for the rendered ALM plan.** Ensure `docs/alm/` exists, then run the discovery helper and write its output to a sidecar marker file (`docs/alm/last-env-vars.json`). The plan-refresh helper (Phase 7's self-refresh) ingests this sidecar into `planData.envVars` so the rendered plan's Env Variables tab shows the definitions setup-solution just created/adopted (without it the tab stays empty even after Phase 5.4 / 5.4.C / 5.4b created definitions): + + ```bash + node -e "require('fs').mkdirSync('docs/alm',{recursive:true})" + node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-env-var-definitions.js" \ + --envUrl "{envUrl}" \ + --publisherPrefix "{publisherPrefix}" \ + --websiteRecordId "{websiteRecordId}" \ + --token "{token}" > docs/alm/last-env-vars.json.tmp \ + && mv docs/alm/last-env-vars.json.tmp docs/alm/last-env-vars.json + ``` + + The tmp-file write pattern preserves a prior good `docs/alm/last-env-vars.json` on a transient discovery failure (parallel to the `docs/alm/alm-size-estimate.json` pattern in plan-alm Phase 1). If the helper exits non-zero, log the stderr and continue — the existing sidecar (or absence of one) is acceptable; the refresh just won't update env vars this run. + + The sidecar's shape mirrors what `discover-env-var-definitions.js` already returns: `{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count }`. Don't transform — the renderer reads these fields directly. + +3. Write `.solution-manifest.json` to project root (alongside `powerpages.config.json`): + - See manifest format in `${CLAUDE_PLUGIN_ROOT}/references/solution-api-patterns.md` Section 7 + - If cloud flows were confirmed, include a `cloudFlows` array: `[{ "workflowId": "...", "name": "...", "status": "active|inactive" }]` + - If bot components were confirmed, include a `botComponents` array: `[{ "botId": "...", "name": "..." }]` + - Omit these arrays entirely if no flows/bots were discovered or confirmed (absence = not tracked; `[]` = tracked but none selected) + + **In `MULTI_SOLUTION_MODE`, write manifest v2** with a `solutions[]` array: + ```json + { + "schemaVersion": 2, + "publisher": { "publisherId": "...", "uniqueName": "...", "friendlyName": "...", "customizationPrefix": "..." }, + "solutions": [ + { + "uniqueName": "IdeaSphere_Core", + "solutionId": "...", + "version": "1.0.0.0", + "order": 1, + "componentTypes": ["Table", "Site Setting", ...], + "components": [ { "componentId": "...", "componentType": 1, "description": "..." } ] + }, + { + "uniqueName": "IdeaSphere_WebAssets", + "solutionId": "...", + "version": "1.0.0.0", + "order": 2, + "componentTypes": ["Web File"], + "components": [ ... ] + } + ], + "splitStrategy": "strategy-1-layer", + "assetAdvisory": [ /* pass-through from plan context */ ] + } + ``` + + **v1 single-solution manifest stays backward compatible.** Readers (`export-solution`, `import-solution`, `setup-pipeline`, `deploy-pipeline`) check `schemaVersion`: + - `schemaVersion` absent or `1` → treat as single-solution (existing behavior). + - `schemaVersion: 2` → iterate `solutions[]` in `order`. + +4. Commit: `git add .solution-manifest.json && git commit -m "Add solution manifest for ALM"` + +### Phase 7 — Present Summary + +Display a summary table: + +| Item | Value | +|---|---| +| Publisher | `{friendlyName}` (`{uniqueName}`, prefix: `{prefix}`) | +| Solution | `{friendlyName}` (`{uniqueName}`, v`{version}`) | +| Solution ID | `{solutionId}` | +| Components added | N | +| Env var definitions added | N (if any OAuth secrets converted) | +| Manifest written | `.solution-manifest.json` | + +**If any auth settings were promoted to env vars**, confirm that each site setting was automatically linked. Show a brief confirmation: + +``` +Auth settings promoted to environment variables: + ✓ Authentication/Registration/LocalLoginEnabled → ids_authentication_registration_localloginenabled + ✓ Authentication/Registration/AzureADLoginEnabled → ids_authentication_registration_azureadloginenabled +``` + +Note: Per-environment values must still be set via `configure-env-variables` or the Power Pages Management UI. + +**If any `authNoValue` settings were included**, show a reminder: +``` +Auth settings included without a dev value (configure in each target env after deploy): + ⚠ Authentication/OpenAuth/Facebook/AppId + ⚠ Authentication/Registration/LoginButtonAuthenticationType +``` + + +> 🚦 **Gate (plan · setup-solution:7.next-step):** Routing choice for downstream deployment skill — PP Pipelines, manual export/import, or defer. All Dataverse writes for this skill are already complete; this gate selects what runs next. + +**Ask what the user wants to do next** via `AskUserQuestion`: + +> "How would you like to deploy this solution to other environments?" + +Options: +1. **"Use Power Platform Pipelines (Recommended)"** — sets up a pipeline in the PP Pipelines host environment; supports staged deployments, approval gates, and env var overrides per stage. +2. **"Export and import manually"** — exports the solution as a zip and imports it directly to a target environment. Simpler for one-off deployments. +3. **"I'll decide later"** — shows next step suggestions and exits. + +If the user selects **option 1**, immediately invoke `/power-pages:setup-pipeline`. +If the user selects **option 2**, immediately invoke `/power-pages:export-solution`. +If the user selects **option 3**, show: +- Run `/power-pages:setup-pipeline` for automated staged deployments +- Run `/power-pages:export-solution` to export a zip for manual import +- Run `/power-pages:configure-env-variables` if environment-specific values need to be set per stage + +### Tip: Adding Components Later + +> **When the live site grows beyond what's in this solution** — server logic from `add-server-logic`, cloud flows from `add-cloud-flow`, env vars from `setup-auth` or `configure-env-variables`, new tables from `setup-datamodel`, or new web roles — **re-run `/power-pages:setup-solution`**. The skill auto-detects sync mode when `.solution-manifest.json` exists in the project root, runs the discovery pass, diffs the live site against the solution, bumps the version, and adds only the missing components. No need for a separate "add to solution" workflow. + +### Record Skill Usage + +> Reference: `${CLAUDE_PLUGIN_ROOT}/references/skill-tracking-reference.md` + +Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "SetupSolution"`. + +### Refresh the ALM plan (if one exists) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase setup-solution \ + --render +``` + +The helper resets `planData.plannedEnvVarCount` to 0 (the planned env vars have either been created or skipped at the user's request) and re-renders `docs/alm-plan.html` so the Overview stat card and Env Variables tab reflect post-setup state. When `docs/.alm-plan-data.json` is absent (standalone invocation, not via plan-alm), the helper returns `ok:false` as a soft no-op — safe to run unconditionally. + +## Key Decision Points (Wait for User) + +1. **Phase 2**: Publisher prefix confirmation — permanent, cannot be changed +2. **Phase 3**: Reuse vs create confirmation — before any writes +3. **Phase 1, Step 5**: ALM plan context — use pre-loaded site settings classification from plan-alm, or re-discover and reclassify +4. **Phase 5, Step 5.4**: Auth settings with values — multi-select which to promote to env vars vs keep as plain site settings; **per-credential prompt** for credential-style settings (Secret env var / String env var / skip) +5. **Phase 5, Step 5.5**: Full manifest review — user sees everything (website, site language, all component categories, tables, env var definitions, authNoValue warnings) and confirms or adjusts before any components are written +5. **Phase 7**: Next step — PP Pipelines (recommended) vs export/import manually vs decide later + +## Error Handling + +- If publisher creation fails with "duplicate" error: re-query and use existing publisher +- If solution creation fails with "duplicate" error: re-query and use existing solution +- If `AddSolutionComponent` returns "already in solution": treat as success (idempotent) +- Never attempt rollback on failure — report what succeeded and what failed + +## Progress Tracking Table + +| Task subject | activeForm | Description | +|---|---|---| +| Verify prerequisites | Verifying prerequisites | Confirm PAC CLI auth, acquire Azure CLI token, verify API access, locate powerpages.config.json | +| Gather solution configuration | Gathering solution configuration | Collect publisher name, prefix, solution name, version from user — confirm irreversible choices | +| Check existing publishers and solutions | Checking existing state | Query Dataverse for existing publisher and solution to avoid duplicate creation | +| Create publisher and solution | Creating publisher and solution | POST publisher and solution to Dataverse OData API, capture IDs | +| Add site components to solution | Adding site components | Discover website/language/powerpagecomponents/tables/cloud flows (type 33)/bot consumers (type 27) via runtime field introspection; split site settings by category; present full manifest including CLOUD FLOWS and BOT CONSUMERS sections with active/inactive status; get user confirmation; call add-components-to-solution.js for website, site language(s), all confirmed components, tables (ComponentType=1), confirmed cloud flows, and confirmed bot components | +| Verify and write manifest | Verifying solution and writing manifest | Confirm components in solution, write .solution-manifest.json, commit | +| Present summary | Presenting summary | Show solution details, component count, and next steps | diff --git a/plugins/power-pages/skills/setup-solution/scripts/validate-solution.js b/plugins/power-pages/skills/setup-solution/scripts/validate-solution.js new file mode 100644 index 000000000..9369a132e --- /dev/null +++ b/plugins/power-pages/skills/setup-solution/scripts/validate-solution.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +// Validates that setup-solution completed: checks for .solution-manifest.json in project root. +// Queries Dataverse OData to confirm the solution actually exists in the environment. +// Gracefully exits 0 when no manifest is found (not a setup-solution session). + +const fs = require('fs'); +const path = require('path'); +const { approve, block, runValidation, findProjectRoot, getAuthToken, getEnvironmentUrl, makeRequest, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); + +runValidation(async (cwd) => { + if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. + const projectRoot = findProjectRoot(cwd); + + // Not a setup-solution session — no project root found + if (!projectRoot) return approve(); + + const manifestPath = path.join(projectRoot, '.solution-manifest.json'); + + // No manifest — this was not a setup-solution session + if (!fs.existsSync(manifestPath)) return approve(); + + // Manifest exists — validate its contents + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + return block('.solution-manifest.json exists but could not be parsed as JSON. Re-run setup-solution.'); + } + + // Dispatch on schemaVersion. v1 (absent or === 1) uses the singular + // `manifest.solution` block. v2 (`schemaVersion: 2`) uses a `solutions[]` + // array — one entry per split solution from setup-solution Phase 6's + // multi-solution write path. Both shapes are normalized into a + // `solutionsToVerify[]` list of `{ uniqueName, solutionId, components }` + // so the downstream component checks + Dataverse round-trip handle both + // without branching code below. + const isV2 = manifest.schemaVersion === 2 || Array.isArray(manifest.solutions); + let solutionsToVerify; + if (isV2) { + if (!Array.isArray(manifest.solutions) || manifest.solutions.length === 0) { + return block('.solution-manifest.json has schemaVersion 2 but `solutions[]` is missing or empty. Re-run setup-solution.'); + } + if (!manifest.publisher?.publisherId) { + return block('.solution-manifest.json is missing publisher.publisherId. Re-run setup-solution.'); + } + for (const s of manifest.solutions) { + if (!s || !s.uniqueName) { + return block('.solution-manifest.json schemaVersion 2 entry is missing uniqueName. Re-run setup-solution.'); + } + if (!s.solutionId) { + return block(`.solution-manifest.json schemaVersion 2 entry '${s.uniqueName}' is missing solutionId. Re-run setup-solution.`); + } + } + solutionsToVerify = manifest.solutions.map((s) => ({ + uniqueName: s.uniqueName, + solutionId: s.solutionId, + components: Array.isArray(s.components) ? s.components : [], + })); + } else { + // v1 path (legacy single-solution manifest) + if (!manifest.solution?.uniqueName) { + return block('.solution-manifest.json is missing solution.uniqueName. Re-run setup-solution.'); + } + if (!manifest.solution?.solutionId) { + return block('.solution-manifest.json is missing solution.solutionId. Re-run setup-solution.'); + } + if (!manifest.publisher?.publisherId) { + return block('.solution-manifest.json is missing publisher.publisherId. Re-run setup-solution.'); + } + if (!manifest.components || manifest.components.length === 0) { + return block('.solution-manifest.json has no components. The website record was not added to the solution.'); + } + solutionsToVerify = [{ + uniqueName: manifest.solution.uniqueName, + solutionId: manifest.solution.solutionId, + components: manifest.components, + }]; + } + + // The Power Pages website (componentType 61) must be in at least ONE of + // the solutions — for multi-solution splits, it typically lives in the + // Core / Foundation solution. For single-solution it's in the only + // solution. v2 entries may have empty components[] arrays for solutions + // that don't claim the website record (e.g. an EnvVars-only solution); + // we only require the website record to be present somewhere. + const websiteComponentSomewhere = solutionsToVerify.some( + (s) => s.components.some((c) => c.componentType === 61), + ); + if (!websiteComponentSomewhere) { + return block('No website component (componentType 61) found in any solution in .solution-manifest.json. The Power Pages site was not added to any solution.'); + } + + // Try to verify against Dataverse (graceful on auth failure). For v2, verify + // each solution exists. For v1, just the one. + const envUrl = manifest.environmentUrl || getEnvironmentUrl(); + if (!envUrl) return approve(); // Can't verify without env URL — don't block + + const token = getAuthToken(envUrl); + if (!token) return approve(); // Token unavailable — don't block on auth issues + + for (const sol of solutionsToVerify) { + try { + const result = await makeRequest({ + url: `${envUrl}/api/data/v9.2/solutions?$filter=uniquename eq '${sol.uniqueName}'&$select=solutionid,uniquename,version`, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'OData-Version': '4.0', + }, + timeout: 15000, + }); + + if (result.error || result.statusCode === 401) return approve(); // Auth/network issue — don't block + + if (result.statusCode === 200) { + const data = JSON.parse(result.body); + const solutions = data.value || []; + if (solutions.length === 0) { + return block(`Solution '${sol.uniqueName}' was not found in the Dataverse environment. Setup may have failed.`); + } + } + } catch { + return approve(); // Network error — don't block on transient issues + } + } + + return approve(); +}); diff --git a/plugins/power-pages/skills/test-site/SKILL.md b/plugins/power-pages/skills/test-site/SKILL.md index a0d4bf700..e7cde20a2 100644 --- a/plugins/power-pages/skills/test-site/SKILL.md +++ b/plugins/power-pages/skills/test-site/SKILL.md @@ -26,6 +26,22 @@ Test a deployed, activated Power Pages site at runtime. Navigate the site in a b - **User-controlled authentication**: Never attempt to log in automatically. Always ask the user to log in via the browser window when authentication is required. - **Bounded crawling**: Cap page crawling at 25 pages to prevent infinite loops on sites with dynamic or paginated URLs. +## Validation Test Categories + +Every run produces a categorized test report (`docs/alm/last-test-site.json` — see Phase 6.7a). Stable category IDs and the source phase that produces each: + +| Category `id` | Display Name | Source phase | What it covers | +|---|---|---|---| +| `site-load` | **Site Load** | Phase 2 | Homepage HTTP status, redirect handling, initial render. One card for the homepage; failures are critical. | +| `authentication` | **Authentication** | Phase 3 | Anonymous-to-Entra redirect, private-site gate detection, login flow integrity. Critical for private sites. | +| `page-crawl` | **Page Crawl** | Phase 4 | One card per page tested (up to 25). Each card carries the page URL, HTTP status, and any console errors. Severity scales with HTTP class (5xx → critical, 4xx on public → high). | +| `web-api` | **Web API** | Phase 5 | One card per `/_api/` endpoint observed during the run. Captures status code, response shape, and remediation hints (table-permissions / site-settings / inner-error settings). | +| `auth-pages` | **Authenticated Pages** | Phase 5.6 | Pages that only became reachable after login. Skipped when the user opts out of authenticated testing. | +| `auth-api` | **Authenticated API** | Phase 5.6 | API endpoints that only became callable after login. Skipped when authenticated testing is skipped. | +| `console` | **Console Health** | Aggregated | Rolled-up count of console errors observed across all phases. Severity is medium by default. | + +`plan-alm`'s Validation tab consumes this shape directly — each category becomes a collapsible group in the per-stage sub-tab, and the rolled-up `runOutcome` (`passed` / `passed-with-warnings` / `failed`) drives the green / yellow / red Outcome badge in both the Validation tab and the Execution checklist substep. + **Initial request:** $ARGUMENTS --- @@ -70,6 +86,8 @@ If no URL was provided, attempt auto-detection: #### 1.4 Ask the User + + If auto-detection failed or was inconclusive, use `AskUserQuestion`: | Question | Header | Options | @@ -151,6 +169,9 @@ Review the browser snapshot from Phase 2.4 and the current browser URL for signs #### 3.2 Handle Private Site Gate + +> 🚦 **Gate (pause · test-site:3.2.private-gate-login):** External wait — site redirected to identity provider; skill pauses until user completes login or cancels. + If a private site gate is detected, use `AskUserQuestion`: | Question | Header | Options | @@ -161,6 +182,9 @@ If a private site gate is detected, use `AskUserQuestion`: 1. Use `browser_snapshot` to verify the user is now on the actual site (site content visible, navigation present, URL is back on the `SITE_URL` domain). 2. If still on the identity provider login page: + + > 🚦 **Gate (pause · test-site:3.2.login-retry):** Login not yet complete — re-prompt or cancel. + - Use `AskUserQuestion` again: "It looks like the login hasn't completed yet. The browser should still be open — please complete the login and try again." - Repeat until login is confirmed or user cancels. 3. Once confirmed, re-run Phase 2.5 and 2.6 (capture console errors and network requests on the now-loaded homepage). @@ -187,6 +211,9 @@ If neither a private site gate nor site-level authentication indicators are foun #### 3.5 Handle Site-Level Authentication + +> 🚦 **Gate (plan · test-site:3.5.public-vs-auth):** Site has Sign-in UI — test as authenticated user, skip auth-gated pages, or cancel. + If site-level authentication indicators are detected (login links in navigation, etc.), use `AskUserQuestion`: | Question | Header | Options | @@ -197,6 +224,9 @@ If site-level authentication indicators are detected (login links in navigation, 1. Use `browser_snapshot` to verify the user is now logged in (login link replaced with user name/profile, or authenticated content is visible). 2. If the login form is still showing: + + > 🚦 **Gate (pause · test-site:3.5.login-retry):** Site-level login not yet complete — re-prompt or cancel. + - Use `AskUserQuestion` again: "It looks like the login hasn't completed yet. The browser should still be open — please complete the login and try again." - Repeat until login is confirmed or user cancels. 3. Create an additional task for testing authenticated scenarios using `TaskCreate`: @@ -573,6 +603,141 @@ For each failure, reiterate the specific remediation guidance from Phase 5.4. Gr - Use `browser_close` to clean up the browser session. +#### 6.7a Write Machine-Readable Report (`docs/alm/last-test-site.json`) + +Always write a structured JSON report so other skills (notably `plan-alm`) can ingest the run without re-parsing the markdown summary. The file is overwritten on every run. Ensure the `docs/alm/` directory exists before writing — `node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"`. + +**Shape:** +```json +{ + "url": "https://contoso.powerappsportals.com", + "stageName": "Staging", + "runAt": "2026-04-29T08:50:00.000Z", + "durationSec": 95, + "runOutcome": "passed | passed-with-warnings | failed", + "summary": { + "critical": 0, + "high": 1, + "medium": 0, + "low": 2, + "total": 3, + "automated": 2, + "manual": 1, + "passed": 2, + "failed": 1, + "skipped": 0 + }, + "categories": [ + { + "id": "site-load", + "name": "Site Load", + "icon": "📦", + "tests": [ + { + "id": "t01", + "name": "Homepage returns 200 OK", + "severity": "critical | high | medium | low", + "type": "automated | manual", + "status": "passed | failed | skipped", + "description": "Short why-this-matters sentence.", + "steps": ["GET /", "Expect 200"], + "expected": "200 OK", + "actual": "200 OK", + "validates": "Site activation" + } + ] + } + ] +} +``` + +**Category mapping** — emit one category per test-site phase that produced findings. Use these stable `id` values so consumers can recognize them: + +| `id` | `name` | Source phase | +|------------------|----------------------|-------------------------------------------------| +| `site-load` | Site Load | Phase 2 (homepage HTTP, redirect, render) | +| `authentication` | Authentication | Phase 3 (login redirect, anonymous gate) | +| `page-crawl` | Page Crawl | Phase 4 (each tested page becomes one card) | +| `web-api` | Web API | Phase 5 (each tested endpoint becomes one card) | +| `auth-pages` | Authenticated Pages | Phase 5.6 page tests | +| `auth-api` | Authenticated API | Phase 5.6 API tests | +| `console` | Console Health | Aggregated console errors across all pages | + +**Do NOT include a top-level `notes` string that embeds component counts, version numbers, or other run-specific data from prior deploys.** Real-world failure: a `last-test-site.json` notes field hardcoded `"4,037 components"` from a deploy two runs ago, then surfaced via plan-alm even though the latest deploy shipped 4,051 components. If the marker needs a freeform narrative, base it strictly on the CURRENT run's data — never carry numbers across runs. The structured `summary` + `categories[]` is the audit trail; prose summaries belong in the rendered HTML, not in the JSON marker. + +**Severity rules** (apply per test card): +- HTTP 5xx response → `critical` +- HTTP 4xx response on a public page or a documented public API → `high` +- HTTP 4xx response on an authenticated-only resource accessed anonymously → `low` (expected gate) +- Console errors on an otherwise-passing page → `medium` +- All other findings (info-only) → `low` + +**Computing the `summary` object — read carefully.** The summary buckets MUST be aggregated by counting each test's `severity` field across all `categories[].tests[]`, not derived from the category itself or defaulted to `low`. plan-alm's Validation tab and the rendered HTML's severity grid both rely on the summary directly — if every test lands in `low` regardless of its actual severity, every stage looks healthy even when critical failures exist. Real-world reproduction (Citizens portal, 2026-05-21): four tests with severities `critical / high / high / low` were dumped into `summary: {critical:0, high:0, medium:0, low:4}` because the agent skipped per-test counting. + +Compute it as: + +```js +const summary = { critical: 0, high: 0, medium: 0, low: 0, total: 0, automated: 0, manual: 0, passed: 0, failed: 0, skipped: 0 }; +for (const cat of (report.categories || [])) { + for (const test of (cat.tests || [])) { + summary.total += 1; + const sev = String(test.severity || '').toLowerCase(); + if (sev === 'critical') summary.critical += 1; + else if (sev === 'high') summary.high += 1; + else if (sev === 'medium') summary.medium += 1; + else summary.low += 1; // default for unknown / missing severity + if (test.type === 'manual') summary.manual += 1; else summary.automated += 1; + if (test.status === 'failed') summary.failed += 1; + else if (test.status === 'skipped') summary.skipped += 1; + else summary.passed += 1; + } +} +``` + +The plan-alm renderer (`render-alm-plan.js` `buildValidationStagePane`) draws four severity cards — Critical / High / Medium / Low — independently. Each summary bucket must reflect the actual count for that severity, not a combined "Medium / Low" total. + +**Status rules**: +- `passed` — assertion held (200, no console errors, expected redirect, etc.) +- `failed` — assertion did not hold (5xx, 4xx where 200 was expected, login flow broke, etc.) +- `skipped` — phase or test was deliberately bypassed (e.g. user picked "Skip authenticated pages" in 3.5) + +**`summary`** is computed from `categories`: +- `critical`/`high`/`medium`/`low` — count of tests at each severity, **regardless of status** (so reviewers see the test surface even when everything passed). +- `total` — total test cards. +- `automated`/`manual` — split by `type`. +- `passed`/`failed`/`skipped` — split by `status`. + +**`runOutcome`** is the rolled-up verdict: +- `failed` if any test has `status: "failed"` AND `severity: "critical"` OR `"high"`. +- `passed-with-warnings` if no critical/high failures but `summary.failed > 0` OR there are console errors logged. +- `passed` otherwise. + +**Write the file** (Node.js, run from the project root): +```bash +node -e "require('fs').mkdirSync('docs/alm',{recursive:true})" +node -e "require('fs').writeFileSync('docs/alm/last-test-site.json', process.argv[1])" "$(cat <<'EOF' +{...the JSON above...} +EOF +)" +``` +or — when invoked from `plan-alm`, the orchestrator may supply the JSON inline. Either way, the marker file location is fixed: `docs/alm/last-test-site.json` (sibling to `docs/alm/last-deploy.json` and `docs/alm/last-pipeline.json`). + +**Always include `stageName` in the marker when known.** The agent learns the stage label from the upstream context — plan-alm Phase 7's per-target loop, `docs/alm/last-deploy.json`'s `stageName`, or an explicit user mention. If the stage cannot be inferred (e.g. test-site invoked standalone against an arbitrary URL), set `stageName` to `null`; the refresh helper has fallback resolution paths but the explicit field is the most reliable signal. + +#### 6.7b Refresh the ALM plan (if one exists) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase test-site \ + --stageName "{stageName}" \ + --render +``` + +`{stageName}` is the stage label this run tested (e.g. `Staging`, `Production`). Pass an empty string when unknown — `refreshTestSite` falls back to (1) the marker's `stageName` field (set in 6.7a above), then (2) the single target stage in `planData.stages` if there's only one. Multi-stage plans with no explicit stageName + no marker stageName won't be captured (the refresh re-renders without a per-stage validationRun update); always pass it explicitly when you can. + +The helper reads `docs/alm/last-test-site.json`, populates `planData.validationRuns[{resolvedStage}]` with the categorized test outcome, and re-renders `docs/alm-plan.html` so the Validation tab updates immediately. When `docs/.alm-plan-data.json` is absent (standalone invocation, no plan in the project), the helper returns `ok:false` as a soft no-op — safe to run unconditionally. + #### 6.8 Suggest Next Steps Based on the test results, suggest relevant skills: