A plugin for creating, deploying, and managing Power Pages code sites. Supports static SPA frameworks (React, Vue, Angular, Astro) with Dataverse integration, Web API access, and browser-based previews via Playwright.
Server-rendered frameworks (Next.js, Nuxt, Remix, SvelteKit) are NOT supported.
Read PLUGIN_DEVELOPMENT_GUIDE.md for UX and reliability standards when creating new skills and agents.
- 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 inreferences/. Always check for existing helpers before writing new code. - Validation scripts must import from
scripts/lib/validation-helpers.jsfor 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.jsanywhere a script reads.powerpages-sitetable-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. - Script changes require tests — Whenever you add a new script or modify an existing script, add or update
node:testcoverage underscripts/tests/. Prefer one*.test.jsfile per script/module being tested, and keep the test command passing:node --test plugins/power-pages/scripts/tests/(Node's built-in runner discovers*.test.jsfiles under the given directory). Validator changes are not an exception; they must always ship with test coverage. - Secure process validation — After changing Power Pages
child_processusage, runnode scripts/validate-secure-process-execution.jsfrom the repository root. Its fixture suite isnode --test scripts/tests/validate-secure-process-execution.test.js. - Dataverse-backed validation must stay opt-in for local runs only. Do not require live Dataverse connectivity in CI workflows or default test runs; gate it behind explicit local flags such as
--validate-dataverse-relationships. - Azure CLI
--allow-no-subscriptions— this flag is only valid onaz login. Otherazsubcommands (az account get-access-token,az account show, etc.) reject it as an unrecognized argument and exit 2, so do NOT add it to anything other thanaz login. When the user is not logged in to the Azure CLI, suggest plainaz loginfirst; only suggestaz login --allow-no-subscriptionsas a fallback if they don't have any associated Azure subscription, since that variant lets subscription-less accounts sign in and still mint AAD-scoped Dataverse/Power Platform tokens via subsequentaz account get-access-tokencalls. Reuse the sharedgetAuthTokenhelper inscripts/lib/validation-helpers.jsinstead of shelling out toazdirectly. - Reference docs shared across skills live in
references/— reference via${PLUGIN_ROOT}/references/paths, don't duplicate. - Templates use
__PLACEHOLDER__tokens (e.g.,__SITE_NAME__) replaced during scaffolding. Thegitignorefile is stored without the dot prefix and renamed to.gitignoreduring scaffolding. - Hooks are defined centrally in
hooks/hooks.json, usingPostToolUsewith matcherSkillso 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.jsrecommends 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.jsonif 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-solutionPhase 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 olderexcludedbucket — setup-solution's preloadedSettings handler treats those ascredentialNeedsDecisionfor 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 inreferences/alm-docs-grounding.md. Add the same Phase 1.5 + the twomcp__plugin_power-pages_microsoft-learn__microsoft_docs_search/fetchtools toallowed-toolswhen introducing a new ALM skill. - ALM artifacts live under
docs/alm/— every ALM-only state file (5 plan/decision JSONs and 9last-*.jsonskill-run markers, includinglast-export.jsonwritten byexport-solutionPhase 7.1) writes to<projectRoot>/docs/alm/, not the project root. Always resolve paths throughscripts/lib/alm-paths.js(almPath(root, 'lastDeploy'),almPath(root, 'planContext'), etc.) and callensureAlmDir(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 bysetup-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 toFILE_NAMESinalm-paths.js, then write through the helper. - New skills must be added to
README.md— Whenever you add a new user-invocable skill underskills/, you must also document it inREADME.mdunder the appropriate section (Site scaffolding and deployment / Data modeling / Backend integration / Security and access / ALM and CI/CD / Polish / Support), update the skill count in the## Skillsintro, and — if the skill is part of the recommended end-to-end flow — update the Typical Workflow code block. The README is the user-facing source of truth for what the plugin can do; an undocumented skill is effectively invisible to users browsing the marketplace.
.plugin/plugin.json ← Open Plugins 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
table-permissions-architect.md ← Agent: proposes table permissions plan (read-only)
webapi-settings-architect.md ← Agent: proposes Web API site settings with validated column names (read-only)
ai-webapi-integration.md ← Agent: implements generative-AI summarization service code + UI wiring
ai-webapi-settings-architect.md ← Agent: proposes Summarization/* site settings (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
add-ai-webapi/
SKILL.md ← Generative-AI summarization integration skill (Layer 3; preview)
references/ai-api-reference.md ← Canonical Search/Data Summarization API shapes, headers, error codes
references/explore-prompt.md ← Phase 2 Explore-agent prompt body + manifest shape
references/scope-classification.md ← Phase 3 list-trigger / scope-confirmation question mapping
references/agent-invocation-prompt.md ← Phase 5 ai-webapi-integration prompt template
references/framework-equivalents.md ← Vue/Angular/Astro safe-markdown + citation rendering snippets
scripts/validate-ai-webapi.js ← Node script validating summarization code, headers, and Summarization/* settings
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
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. plan-alm is a planner: it detects the project state, runs the pre-plan completeness check, asks about promotion strategy, and writes a rendered HTML plan (whose steps[] array is the recommended execution sequence). It does not deploy anything. After the user approves the plan, the user runs the individual skills (setup-solution, setup-pipeline, deploy-pipeline, or export-solution/import-solution, plus activate-site/test-site) in the plan's order. Each detects the approved plan via its Phase 0 gate, proceeds without re-nagging, refreshes the plan on completion, and points the user at the next step — but never auto-chains. This separation keeps plan-alm safe to run unattended (no single answer can trigger an irreversible deployment).
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 without a plan first misses the planner's analysis (completeness check, host resolution, deployment-strategy selection, size/split decisions, 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.
Every ALM execution skill enforces this with a Phase 0 ALM-plan gate. If a user invokes one directly without a plan, the skill recommends running plan-alm first (option 1, recommended) with a "continue without a plan" escape hatch; choosing to plan runs plan-alm (which only plans) and then the skill proceeds. The Phase 0 gate is meant to fail closed — don't bypass it on the user's behalf.
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). Usespac 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 sharedpowerPagesApi.tsclient (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.bindfor 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.table-permissions-architect: Read-only agent that analyzes site code, discovers existing web roles and table permissions, and proposes a table-permissions plan (web roles → table permissions with CRUD flags and scopes) rendered as a Mermaid flowchart. Checks for.powerpages-sitefolder to verify site deployment. Presents the plan via plan mode; after approval, creates web role and table-permission YAML files using deterministic scripts. Supports an AI-only read posture (invoked transitively by/add-ai-webapivia/integrate-webapi) that proposesread: trueonly, with Parent scope +appendTofor$expandtargets. Invoked by/integrate-webapiand/audit-permissions.webapi-settings-architect: Read-only agent that queries Dataverse for exact column LogicalNames (case-sensitive) and proposesWebapi/<table>/enabledandWebapi/<table>/fieldssite settings. Never uses*for field settings except for aggregate OData queries — always lists specific columns. Presents the plan via plan mode; after approval, creates site-setting YAML files usingcreate-site-setting.js. Supports the AI-only read posture (minimal fields list: no primary key, only_<col>_valuelookup read forms). Invoked by/integrate-webapi.ai-webapi-integration: Implementation agent that creates production-ready generative-AI summarization service code for a Power Pages SPA site — Search Summary (/_api/search/v1.0/summary) and Data Summarization (/_api/summarization/data/v1.0/...). Uses rawfetch(never the OData wrapper), attaches the__RequestVerificationTokenCSRF header, groups all functions in a singleaiSummaryService.*file, emits a framework-idiomatic wrapper (React hook / Vue composable / Angular service / Astro util), and wires real UI call sites with loading/error/content/empty branches, citation rendering, and a safe-markdown renderer. Invoked sequentially per target by/add-ai-webapi(every target shares the one service file, so parallel runs would conflict).ai-webapi-settings-architect: Read-only agent that proposes the three Layer-3 summarization settings —Summarization/Data/Enable, per-promptSummarization/prompt/<identifier>, andSummarization/Data/ContentSizeLimit(mandatory200000for list summaries). Cross-checks that Layer 1/2 prerequisites (Webapi/<table>/*, table permissions) exist for every summarised table and$expandtarget. Presents the plan via plan mode; after approval, creates site-setting YAMLs (script path, or hand-written block-literal YAML for long/complex prompts). Invoked by/add-ai-webapiPhase 6.
User-invocable via /power-pages:<skill-name>:
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 usingskills/create-site/references/design-aesthetics.mdand live Playwright preview, review, deploydeploy-site: 6-step workflow — verify PAC CLI, authenticate, confirm environment, upload viapac pages upload-code-site, verify deployment (confirm.powerpages-sitefolder, commit, offer activation), handle blocked JS attachmentssetup-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.jsonfor hook validation.add-sample-data: 6-step workflow — verify prerequisites, discover tables (from.datamodel-manifest.jsonor 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 viaskills/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 sharedscripts/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, invokewebapi-integrationagent per table to create API client/types/services/hooks, verify integrations (validate all files exist, project builds), invoketable-permissions-architectandwebapi-settings-architectagents (in parallel) to configure table permissions and site settings, review & deploy viadeploy-siteskill. Supports an[AI-READ-ONLY]sentinel that hardens the flow to read-only when invoked by/add-ai-webapi.add-ai-webapi: 8-phase workflow — verify site/deployment, Explore-agent scan for search/data summarization candidates, review plan with user, delegate Layer 1/2 (Web API site settings + table permissions) to/integrate-webapiin AI-only read mode and to/create-webroles, invokeai-webapi-integrationagent sequentially per target to create the summarization service + framework wrapper + UI wiring, invokeai-webapi-settings-architectfor Layer 3 (Summarization/*settings), verify (header-contract grep,$selectgrep, build, validator), review & deploy. This skill owns Layer 3 only and delegates everything else. Validator:skills/add-ai-webapi/scripts/validate-ai-webapi.js. AI summarization APIs are a preview feature gated by a three-level admin hierarchy.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), createProfileRedirectEnabledsite 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 viaAddSolutionComponent, verify components and write.solution-manifest.json, present summary. Reusesreferences/solution-api-patterns.md.export-solution: 7-step workflow — verify prerequisites, identify solution (from.solution-manifest.jsonor user input), confirm managed vs unmanaged export (irreversible choice), triggerExportSolutionAsync, poll viascripts/poll-async-operation.js, download and decode solution zip viaDownloadSolutionExportData, verify zip containsSolution.xml. Reusesscripts/poll-async-operation.jsandreferences/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 viaStageSolutionto check missing dependencies, import viaImportSolutionAsyncand poll, verify solution exists in target and writedocs/alm/last-import.jsonmarker, present component results. Reusesscripts/poll-async-operation.js,scripts/encode-solution-file.js, andreferences/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-runningpac pages upload-code-sitein capture mode and parsing viascripts/parse-deployment-errors.js, query recent Dataverse async operation failures, pattern-match againstreferences/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), createdeploymentenvironmentsrecords for source + each target (pollvalidationstatusuntil Succeeded), createdeploymentpipelinesrecord +$refassociate source env (relative path +@odata.context) + createdeploymentstagesper target, verify and writedocs/alm/last-pipeline.json+docs/pipeline-setup.md+ commit. Usesreferences/cicd-pipeline-patterns.mdfor 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 indocs/alm/last-pipeline.json; warn if last deploy failed), pre-flight check on the target env'sblockedattachmentssetting viafix-blocked-attachments.js --dry-run(Phase 2.5, Power Pages projects only — prompts the user to unblock.js/.cssproactively 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 viaRetrieveDeploymentPipelineInfo(v9.1) to getSourceDeploymentEnvironmentIdand available artifacts, createdeploymentstagerunsrecord + callValidatePackageAsync(204) + polloperationfield until not200000201(surfacevalidationresultsissues), optionally PATCHdeploymentsettingsjsonfor env var / connection reference overrides, final deploy consent gate at Phase 6.0 (explicitDeploy now / CancelAskUserQuestionbefore eitherDeployPackageAsyncor thepac pipeline deployfallback — closes a gap where Phase 5 → Phase 6.1 could fire without a final confirmation when validation passes cleanly), callDeployPackageAsync+ pollstagerunstatusuntil terminal (handle approval gates with user pause), writedocs/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 (fromdocs/alm/last-host-check.json,docs/alm/last-pipeline.json, or user input) and source dev env's BAP env GUID, resolve or create thedeploymentenvironmentsrecord on the new host (re-querying byenvironmentidto recover the record ID whencreate-deployment-environment.jsthrows on the "already associated" validation failure), require explicitAskUserQuestionconsent 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 stalevalidationstatus; reversible by re-running from the previous host), callscripts/lib/force-link-environment.jsto POSTManageEnvironmentStamp+ re-pollvalidationstatusuntil Succeeded, writedocs/alm/last-force-link.jsonmarker. Auto-fix entry point for Pattern 15 inreferences/deployment-error-catalog.md.plan-alm: 4-phase planner 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 a recommended-execution checklist), then save it (Approved or Draft) and commit. It does not execute any deployment. The user runs the individual ALM skills afterward —setup-solution,setup-pipeline/export-solution,deploy-pipeline/import-solution,activate-site,test-site— each of which detects the plan (Phase 0 gate), proceeds, and refreshes the plan on completion (viarefresh-alm-plan-data.js, which also reports the next recommended step). This keepsplan-almsafe under autopilot: it never triggers an irreversible action.
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.
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 derives tracked skills directly from skills/*/SKILL.md via scripts/lib/powerpages-hook-utils.js, looks up an optional skills/<skill>/scripts/validate*.js validator for the skill that just completed, and invokes it with the current cwd.
ALM plan reconcile backstop (auto-heal). After any ALM plan skill completes (powerpages-hook-utils.js → ALM_PLAN_SKILLS / isAlmPlanSkill) and a docs/.alm-plan-data.json exists in the cwd, the runner also spawnSyncs refresh-alm-plan-data.js --reconcile --render. The refresh-alm-plan-data.js calls in each SKILL.md are advisory — silently dropped on session fragmentation, manual execution, or oversight — so the reconcile performs any refresh whose marker (docs/alm/last-*.json) is newer than the plan. This is best-effort and non-blocking: it never changes the hook's exit code, honors .alm-deferred, and is idempotent. Because it fires on any ALM skill (not just the marker's writer), a skip in skill A is healed when the next ALM skill (B) completes. Skills keep their explicit per-phase refresh calls as defense-in-depth + immediate render; the hook is the backstop.
To wire a new skill into validation:
- Write the validator at
skills/<skill>/scripts/validate-<skill>.jsusing therunValidation((cwd) => { ... })pattern fromscripts/lib/validation-helpers.js. - No manual tracked-skill registration is needed. Any folder with
skills/<skill>/SKILL.mdis automatically tracked for telemetry and hook detection. - Add or update test coverage in
scripts/tests/powerpages-hook-utils.test.jsif you introduce a new validator naming pattern.
All skill folders are tracked. Skills without a scripts/validate*.js file are tracked for telemetry/detection but skip validation.
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 utility scripts live at scripts/ and are referenced by multiple skills and agents via ${PLUGIN_ROOT}/scripts/.
generate-uuid.js: Generates a random UUID v4. Self-contained, no dependencies. Used bycreate-webrolesand the main agent when creating table permission / site setting files from thewebapi-permissionsagent plan.update-skill-tracking.js: Updates skill usage tracking site settings. Takes--projectRoot,--skillName, and--authoringToolargs. The agent passes its own name as--authoringTool(e.g.,ClaudeCode,GitHubCopilot). Creates/increments a per-skill counter (Site-AI-<SkillName>.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--projectRootarg. Site-identity resolution (resolveSiteIdentity(), exported + injectable for tests): (1)powerpages.config.json(code/SPA sites) →siteName+ optionalwebsiteRecordId; (2) else.powerpages-site/website.yml(declarative/data-model sites) →name→siteName,id→websiteRecordId; (3)pac pages listONLY when the GUID is still unknown — declarative sites (and code sites whose config included the GUID) skip thepac pages listexec entirely. Then queries the Power Platform GET websites API and matches by bothwebsiteRecordIdandname. Outputs JSON:{ activated: true/false, siteName, websiteRecordId, websiteUrl }or{ error }. The CLI flow is guarded byrequire.main === module;module.exports = { resolveSiteIdentity, getWebsites }. Used bydeploy-siteandactivate-site.poll-async-operation.js: Polls a Dataverseasyncoperationsrecord 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 byexport-solutionandimport-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 byimport-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 bydiagnose-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/.
-
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.jsonnot found),--expectedEnvUrl(opt — env-drift guard: assert the resolved env matches this origin and HARD-STOP on mismatch). Output:{ envUrl, token, userId, organizationId, tenantId }. Exit 0 on success, exit 1 on any failure.--expectedEnvUrlis the recommended guard for any ALM skill that runs against the project's source/dev env: sincegetEnvironmentUrl()now parses PAC 2.8.x'sOrg URL:successfully, a drifted PAC context resolves silently instead of failing loudly (the old parse-miss had been an accidental safety net), so an ALM op could target the wrong environment (e.g. PROD). Skills pass the project's env URL (from.solution-manifest.jsontop-levelenvironmentUrl/powerpages.config.jsonenvironmentUrl/ the approved plan's source env) so a mismatch stops the run before any token/write. Prefer this over pinning--envUrl, which only redirects the Dataverse-API calls while later PAC-CLI ops (pac pipeline deploy,pac env select) still follow the ambient context. Used bysetup-solution,export-solution,import-solution,setup-pipeline,deploy-pipeline,plan-alm. -
scripts/lib/detect-project-context.js: Reads Power Pages project context from the project root. ThesiteTypediscriminator is the build axis — code/SPA vs declarative (design-studio) site — NOT the Dataverse data-model axis (a declarative site can be on the standard OR enhanced data model; both download to a.powerpages-site/tree).siteType: "declarative"is the declarative bucket (it was historically labeled"data-model"; that value is now the legacy alias — nothing branches on the literal, so older plan-data carrying"data-model"stays equivalent). Resolves identity in order: (1)powerpages.config.json→siteType: "code"(code/SPA sites); (2).powerpages-site/→siteType: "declarative"(declarative design-studio sites — standard or enhanced data model — which have nopowerpages.config.json). The authoritative declarative marker is the.powerpages-site/.portalconfig/directory (only declarative sites have it);website.ymlis the identity source (id→websiteRecordId,name→siteName) but is NOT a reliable declarative signal alone because both site types carry it.environmentUrl: nullfor declarative sites (no env URL in the local files — callers re-confirm viapac env who). Also reads.solution-manifest.jsonand.datamodel-manifest.json. Args:--projectRoot(opt). Output:{ projectRoot, siteType, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }. Exit 0 on success, exit 1 only if neitherpowerpages.config.jsonnor a.powerpages-site/(.portalconfig//website.yml) marker is found. Note:findProjectRoot(invalidation-helpers.js) likewise treats a.powerpages-site/directory as a project-root marker. -
scripts/lib/alm-paths.js: Single source of truth for ALM artifact paths. ExportsALM_DIR(alwaysdocs/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 + 9last-*.jsonskill-run markers includinglast-export.json) writes under<projectRoot>/docs/alm/. Always resolve through this helper — never inline a rawdocs/alm/...path in a script. Files intentionally NOT moved here (and not inFILE_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 toFILE_NAMESfirst;almPaththrows 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-deferredmarker, 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 forsolutions(solutionId)?$select=modifiedonand compares againstplanData.generatedAt. Used bysetup-solution,setup-pipeline,deploy-pipeline,export-solution,import-solution,configure-env-variables,ensure-pipelines-host,force-link-environmentPhase 0 gates — the "fail closed when no plan" pattern. PLAN_STATUS lifecycle — promotesApproved→In Execution: plan-alm is plan-only and leaves the planApproved; this helper performs theApproved→In Executiontransition (and writes the first heartbeat) the first time an execution skill's Phase 0 runs — it is the only thing that setsIn Execution, so without it the heartbeat/active-chain machinery (multi-hour-deploystale-heartbeatreclassification) never engages. Gated on heartbeat-write: read-only callers pass--no-heartbeat(plan-alm's own deferral check, audits, tests) and are never promoted. The terminalIn Execution→Completedtransition is owned byrefresh-alm-plan-data.js(completion evaluator). -
scripts/lib/set-plan-status.js: The single deterministic owner of the creation-timeDraft/Approvedwrite — the one PLAN_STATUS transition that used to be done by hand-authoredEdits in plan-alm Phase 4 (to the HTML spans and the JSON), with no helper. Because the badge +approved-by/approval-datespans are re-derived fromdocs/.alm-plan-data.jsonon every render, the old manual HTML Edit was non-durable (reverted on the next refresh) and a partial write left the plan "approver recorded but PLAN_STATUS=Draft" — stuck forever, sincecheck-alm-plan.jsonly promotes fromApproved. This helper writesPLAN_STATUS+PLAN_MODE+APPROVED_BY+APPROVAL_DATEtogether (atomic temp+rename) and optionally re-renders (reusesrefresh-alm-plan-data.js → findRendererPath/invokeRenderer). Enforced invariants: onlyDraft/Approvedare settable here (In Executionis owned bycheck-alm-plan.js,Completedbyrefresh-alm-plan-data.js);Approvedrequires a non-empty--approver;Draftclears the approver fields; a plan alreadyIn Execution/Completedis not re-drafted without--force. Args:--projectRoot,--status Draft|Approved,--approver,--approvalDate(opt — defaults to now),--force,--render,--rendererPath(opt). Output:{ ok, previousStatus, status, mode, approver, approvalDate, rendered }. Called by plan-alm Phase 4 (both save options) and the Phase 1 step-0b in-place Draft→Approved fast-path. Thevalidate-plan-alm.jsconsistency guard blocks the two half-written states (Draft+approver,Approved+no-approver) for plans created the old way or hand-edited. -
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.jsonin the project root; (3) neither → throwNoSolutionConfiguredError. The module NEVER auto-picks from Dataverse — interactive prompt UX is the caller's responsibility (catch the error, present anAskUserQuestionlist, re-invoke withexplicitpopulated). Callers that need to confirm the solution still exists in Dataverse can passverifyExists: true; the module then enriches the result with{ solutionId, version, ismanaged }. Component-creation scripts must require this helper and pass through--solutionUniqueNameso records land in the user's solution instead ofDefault.
scripts/lib/alm-thresholds.js: Central default threshold constants for the split decision tree. Loads optional.alm-config.jsonfrom project root and merges over defaults. ExportsDEFAULTS,DEFAULT_CONFIG,loadConfig(projectRoot),classifyTier(value, greenUpperExclusive, yellowUpperExclusive),deepMerge(target, source). Used byestimate-solution-size.jsandcompute-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, tableCountScope, schemaAttrCount, webFilesAggregateMB, webFilesIndividual[], webFileCount, webFileSampleSize, webFilesDiskMeasuredMB, webFilesDiskMeasuredPath, webFilesDiskFileCount, cloudFlowCount, botCount, envVarCount, envVarCountScope, envVarCountTenantWide, mediaRatio, siteType, tables[], tableRelationships[], breakdown, estimationMethod, estimationAccuracyPct, truncationSuspected, truncationWarnings, ppcGroundTruthCount }. Table discovery is site-referenced, NOT publisher-prefix:tableCount/tables[]are scoped to the custom tables the site actually references — its.powerpages-site/table-permissions/(+ datamodel manifest) intersected with the env's custom-unmanaged tables (viaresolve-site-tables.js+query-metadata.js).tableCountScope∈"site-referenced" | "manifest-only" | "unavailable"(the last → 0 tables, never an env-wide prefix dump).--publisherPrefixnow scopes ONLY the env var count, not tables.tableRelationships[]are[a,b]dependency edges (lookups + N:N, viaquery-table-relationships.js) among the scoped tables, consumed bycompute-split-plan.jsto cluster related tables into the same solution. 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--projectRootwas 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 flipstruncationSuspected: truewith a per-causetruncationWarnings[]entry. Used byplan-almPhase 1 Step 10.scripts/lib/compute-split-plan.js: Runs the split decision tree against a size-estimate blob. Args:--estimate <path>,--projectRoot(opt — for.alm-config.jsonoverrides),--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). Schema Segmentation is dependency-aware + capacity-bounded: it builds connected-component clusters fromestimate.tableRelationships(union-find), then bin-packs whole clusters (never splitting a relationship) into the fewest solutions that keep each undermaxTableCount/maxSchemaAttrswhere possible — capped atmaxSchemaSplitSolutions(default 8). This replaced the old one-solution-per-table-name-stem heuristic that produced ~one solution per table. Two cases CAN exceed a per-solution cap, and BOTH raise anrecommendations[]warning rather than failing silently: (a) an indivisible dependency cluster larger thanmaxTableCountstays whole (oversized-cluster table-count warning); (b) when MORE thanmaxSchemaSplitSolutionsindependent attr-heavy clusters must share the capped solution count, the FFD least-loaded fallback co-locates clusters and a solution's summed columns exceedmaxSchemaAttrs(oversized-schema attr-cap warning). The split trigger + thresholds are unchanged — only the packing. 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_Integrationwhenever the parent had any flows or bots — coverage takes priority over thechangeFreqMinFlowsheuristic, which governs only the TOP-LEVEL strategy choice). When additive Strategy 4 is firing concurrently (a top-level_EnvVarssolution),_ConfigdropsEnvironment Variablefrom its componentTypes to avoid double-claim; when it isn't,_Configabsorbs env vars so they have an owner. Sub-partitioning setscompositeSubPartitioned: trueand appendscomposite-sub-partitiontoappliedStrategies.validateSplitschecks BOTH the size AND component-count cap per split (skippingisFutureBuffersolutions). Supports.alm-config.jsonoverrides includingstrategyOverrideto bypass the tree. Seesolution-splitting-logic.mdspec in design docs for full logic.scripts/lib/resolve-site-tables.js: Single source of truth for "which custom tables does this site actually use."collectReferencedEntityNames({ projectRoot, datamodelManifestPath })reads.powerpages-site/table-permissions/*.tablepermission.yml(entitylogicalname, viapowerpages-config.js → loadTablePermissions) + the datamodel manifest →{ names:Set, available, sources }.scopeCustomTables(referencedNames, customUnmanagedTables)intersects that set with the env's custom-unmanaged tables. SME-confirmed: table permissions are the complete signal ("if a table is used in the site there will be permissions for it"), so forms/lists are NOT scanned. Used byestimate-solution-size.jsanddiscover-site-components.jsto replace the publisher-prefix table dump.scripts/lib/query-metadata.js:queryCustomUnmanagedTables(envUrl, token, makeRequest?)→[{ logicalName, metadataId, schemaName, displayName }](the singleEntityDefinitions?$filter=IsCustomEntityquery,IsManaged===falsefiltered). Consolidates the formerly-triplicated custom-table query (estimator, discover-site-components, setup-solution). ReusesodataGetAllfromvalidation-helpers.js.scripts/lib/query-table-relationships.js:fetchTableRelationships(envUrl, table, token, makeRequest?)→{ oneToMany[], manyToMany[] }. Extracted fromskills/audit-permissions/scripts/query-table-relationships.js(now a thin CLI wrapper over this lib) and extended with ManyToMany. OneToMany errors propagate; ManyToMany is best-effort. Used by the estimator to buildtableRelationships[]and by audit-permissions for relationship-scope validation.scripts/lib/validation-helpers.jsalso exportsodataGet(url, token, makeRequest?)+odataGetAll(url, token, makeRequest?, maxPages?)— the shared, injectable OData GET +@odata.nextLinkpagination used by the new metadata/relationship helpers (avoids each lib rolling its own paginator).
scripts/lib/verify-solution-exists.js: Checks whether a Dataverse solution exists by unique name via ODatasolutions?$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 }wherecreated: falsemeans 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 with0(so1.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 bysetup-solutionPhase 4 sync-mode bump ANDexport-solutionPhase 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 (--uniqueNameOR--solutionId),--token(opt — refreshed viagetAuthTokenif 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 exportscompareVersions(a, b) → -1|0|1andparseVersionToSegments(v) → number[4]as programmatic helpers —compareVersionsis the canonical way for any caller to compare two Dataverse version strings (import-solutionPhase 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 say1.0.0.9 > 1.0.0.10is 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 tonode -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 bysetup-solutionPhase 4 Step 2 inMULTI_SOLUTION_MODEwhen the split plan recommends N solutions. Fans out viaPromise.allSettledso 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 <path>(JSON array of{ uniqueName, friendlyName, version, description, isFutureBuffer? }),--token(opt; refreshed once at batch start viagetAuthTokenif omitted). Skips entries withisFutureBuffer: 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 inspectsfailed+ per-entryerror); 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 queryingsolutioncomponentsfor 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 viaAddSolutionComponentOData action. Refreshes the Azure CLI token every--batchSizecalls (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 returnedundefinedand 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 inspectsfailures); 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 byplan-almPhase 1 Step 7 andsetup-solutionPhase 5. Exportsclassify,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 bysetup-solution(creates definitions) andconfigure-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 anenvironmentvariabledefinitionrecord 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 anddiscover-env-var-definitions.jshad 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 anmspp_sitesettingrecord to anenvironmentvariabledefinitionvia OData PATCH on the v9.0 API (not v9.2). HAR-confirmed: navigation property isEnvironmentValue@odata.bind; headersif-match: *andclienthost: Browserare 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: BAPapplicationPackagesLIST +/installPOST → 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 boundmspp_sitesetting(if any). Used byplan-almPhase 1 Step 10b to populateplanData.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: Updatesdocs/.alm-plan-data.jsonwith 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-rendersdocs/alm-plan.html. Driven by the execution skills' final-phase refresh (and the PostToolUse--reconcilebackstop) — NOT by plan-alm, which is now a plan-only 4-phase planner that only renders the initial plan in Phase 3 — 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/ensure-pipelines-host/finalize) OR--reconcile(mutually exclusive with--phase),--render(also invoke renderer),--stageName(required fortest-site; preferred forimport-solution/activate-sitethough both can resolve via marker URL match). Output:{ ok, phase, dataPath, htmlPath, rendered }. Returnsok:false(soft no-op) whendocs/.alm-plan-data.jsonis 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 bycheck-alm-plan.jsfor downstream Phase 0 ALM-plan gates and by this helper for post-run refreshes. Cross-cutting behaviors: (a)setStepStatusflips the matching entry inplanData.steps[]tocompleted(orfailedwhen the phase's marker indicates failure) — case-insensitive keyword match + stage filter, respectsskip: true, never regresses completed→pending; (b)deploy-pipelineANDconfigure-env-variablesboth backfillplanData.envVars[i].values{}from the project root'sdeployment-settings.jsonso the rendered plan's "Values by Environment" matrix auto-populates (accepts both top-level-stage and nested-stagesshapes;SchemaName/Valueand camelCase variants; never overwrites a populated cell — manual override wins); (c)configure-env-variablesandsetup-solutionboth re-ingestdocs/alm/last-env-vars.json(when present) so freshly-created definitions appear inplanData.envVars[]andplannedEnvVarCountzeros out; (d)export-solutioningestsdocs/alm/last-export.jsonintoplanData.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 (nomanualMeta.lastExport: nullrow in the rendered plan); (e)deploy-pipelineingests thebatchValidationblock fromlast-deploy.jsonintoplanData.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 tonullfor single-solution / legacy v2 deploys so renderers can branch on it; legacyelapsedSecondsApproxfield name is accepted and normalized toelapsedSecondson ingest.ensure-pipelines-hostphase: host-only update ofplanData.hostResolutionfromlast-host-check.json(drops NoHost risks) WITHOUT touchingpipelineMetaor theSetup pipelinestep — for when the host was resolved but the pipeline doesn't exist yet.--reconcilemode: the enforcement backstop — scans thelast-*.jsonmarkers and, for each one newer thandocs/.alm-plan-data.json(a skipped refresh), applies the mapped phase (MARKER_TO_PHASE;lastPipeline→setup-pipeline supersedes the host-only phase;lastEnvVars→configure-env-variables ifdeployment-settings.jsonexists else setup-solution) against a single loaded planData, writes once, renders once. Honors.alm-deferred, soft no-op when no plan, idempotent. Output{ ok, reconciled:[phases healed], failed:[{phase,error}], rendered }— a phase whose refresh throws (e.g. a marker schema it can't parse) is captured infailed(and written to stderr) instead of being silently swallowed, while the remaining phases still heal. Invoked by the PostToolUse hook after every ALM skill (see Hooks). Completion evaluator (In Execution→Completed): after every phase's step-sync (bothrefresh()andreconcile()),evaluatePlanCompletionflipsPLAN_STATUStoCompleted+ stampsCOMPLETED_ATonce every non-skipstep iscompletedand none isfailed. This is what makes the LAST execution skill terminate the plan automatically — no skill calls--phase finalize(the explicitfinalizephase /refreshFinalizeexists but nothing invoked it, so the lifecycle previously never reachedCompleted). Only advances fromIn Execution(the normal post-promotion state — seecheck-alm-plan.js) orApproved(defensive fallback); never regresses aDraftor already-Completedplan, and afailedstep blocks completion so a failed deploy can't look "done".
scripts/lib/list-environments.js: Enumerates the Dataverse environments the signed-in PAC user can access, as JSON, forENV_LISTpre-fill (plan-alm Phase 1 Step 5, setup-pipeline, ensure-pipelines-host "Other (paste URL)" prompts). Why it exists: the skills used to runpac env list --output json, which is INVALID on current PAC CLI (verified 2.8.1 —pac env listaccepts only--filterand errors on--output), so the JSON pre-fill silently never worked. This helper runs the plainpac env listand parses its table (anchored on the env GUID + https URL + unique-name tokens, so display names with spaces survive).pac admin list --jsonwas rejected as the source — it's admin-only and tenant-wide, the wrong scope for a per-user pre-fill. ExportsparseEnvList(stdout)(pure, tested) +listEnvironments(). CLI prints a JSON array of{ displayName, environmentId, environmentUrl, uniqueName, active }; prints[]and exits 0 on any failure (unauthenticated PAC, parse miss) so callers degrade to manual entry. Match envs byenvironmentUrlorigin.scripts/lib/discover-pipelines-host.js: Discovers the tenant-level default Power Platform Pipelines host environment URL by callingRetrieveSetting('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 adeploymentenvironmentsrecord in the Pipelines host environment using the unprefixed field schema (name,environmentid,environmenttype), then pollsvalidationstatusuntil Succeeded (200000001) or Failed (200000002). Args:--hostEnvUrl,--token,--name,--bapEnvId,--environmentType(200000000Dev /200000001Target),--environmentUrl(opt, only echoed in output marker). Idempotent: if a record already exists for the sameenvironmentid, returns it withreused: true. Output:{ deploymentEnvironmentId, name, bapEnvId, environmentUrl, environmentType, validationStatus, reused }.scripts/lib/create-deployment-pipeline.js: Creates adeploymentpipelinesrecord, associates the source environment via$ref(relative path +@odata.context), and createsdeploymentstagesrecords 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 adeploymentstagerunsrecord 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: Pollsstagerunstatuson adeploymentstagerunsrecord 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 bydeploy-pipelinePhase 3.6 inMULTI_RUN_MODE(multi-solution v3 manifest) to compress validation fromN × ~120sto roughly the slowest single validation. For each solution, runscreate-stage-run+POST ValidatePackageAsync+poll-validation-statusconcurrently viaPromise.all— wrap-errors-into-result-object pattern (helper never rejects per-solution; errors land on the result object'sstatus+errorfields). 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 <path>(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 about200000005, so a poll timeout triggers a single?$select=stagerunstatusre-query to distinguish "still validating" from "awaiting approval").elapsedSecondsis wall-clock measured around the fan-out (excludes token-acquire prelude) so deploy-pipeline Phase 3.6.6 can persist it intolast-deploy.json'sbatchValidationblock without out-of-band timing. Also supports--rePollmode: solutionsFile entries must includestageRunId(carried from the original batch's results); the helper skips create-stage-run + ValidatePackageAsync and only runs the poll-and-probe pattern. Used bydeploy-pipelinePhase 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/--sourceDeploymentEnvironmentIdare not required. Exit 0 always (caller inspectsallPassed); exit 1 only on fatal setup errors.scripts/lib/poll-deployment-status.js: Pollsstagerunstatuson adeploymentstagerunsrecord 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 withactionTaken: "none". Used byplan-almPhase 1 step 12 and other orchestrators that want to inspect host state without inviting user prompts. Resolution order mirrorsProjectHostProvider.tsxfrom 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 BAPgetOrCreateendpoint. 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 callmake.powerapps.com → Pipelines → Get startedmakes. Args:--bapToken,--tenantId,--correlationId(opt — defaults to a fresh UUID),--bapBase(opt — defaults tohttps://api.bap.microsoft.com),--timeoutSec(opt). Output:{ status, alreadyExisted, envId, envUrl, envName, region, provisioningState, lifecycleOpId, durationSec, correlationId }. Used byensure-pipelines-hostPhase 4.0.scripts/lib/provision-custom-host.js: Provisions a new Power Platform Pipelines Custom Host via the BAP env-create API with theD365_ProjectHostorganization template (template pre-installs the Pipelines app so the env is immediately host-capable). Same template PPAC'sNew custom hostbutton uses. Args:--bapToken,--tenantId,--displayName,--region(opt),--sku(opt —Sandbox/Trial/Production),--correlationId,--bapBase,--timeoutSec. On 409 capacity errors the helper surfaceserrorCodeso the caller can offer a SKU fallback (e.g. Sandbox → Trial → Production). Output:{ status, envId, envUrl, envName, sku, lifecycleOpId, durationSec, correlationId }. Used byensure-pipelines-hostPhase 4.A.scripts/lib/force-link-environment.js: Force-links an existingdeploymentenvironmentsrecord (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/ManageEnvironmentStampwith the GUID in upper-case-in-braces format (HAR-confirmed againstsupplierportalpipelineshostch.crm17, 2026-05-11). Idempotent: re-running on an already-stamped env is a 204 no-op. Output:{ ok, deploymentEnvironmentId, hostEnvUrl, validationStatus, errorCode? }. Used byforce-link-environmentskill (Pattern 15 auto-fix indeployment-error-catalog.md).scripts/lib/pac-bap-shim.js: PAC-CLI shim for BAP env-list / env-GET. Provides the same data shape thatresolve-env-by-id.jsandlist-tenant-envs.jsconsume from BAP, but sourced frompac admin list --jsoninstead. Why this exists: the BAP API atapi.bap.microsoft.comrejects 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. ExportslistTenantEnvs(),resolveEnvById(envId)with the same return shape as the BAP-backed callers.scripts/lib/verify-env-var-values.js: Verifies thatenvironmentvariablevaluesrecords actually landed on a target environment after deploy / import / configure. Read-only — no Dataverse writes. Why this exists:deploy-pipelinePhase 5.2 PATCHesdeploymentsettingsjsononto 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 anmspp_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 (--schemaNamescomma-separated OR--settingsFile <path>to derive fromdeployment-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 bydeploy-pipelinePhase 7.6.5,import-solutionPhase 6b.verify,configure-env-variablesPhase 7.scripts/lib/validate-deployment-settings.js: Pre-deploy validator fordeployment-settings.json. Classifies eachEnvironmentVariables[]entry byvalueFormat(kv-uri/kv-resource-id/kv-placeholder/empty/plain-text/invalid-uri) andstatus(valid/invalid/unknown-type/skipped). When--envUrlis 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 bydeploy-pipelinePhase 5.1b (pre-PATCH gate). The catalog of canonical Secret formats lives in this helper — do NOT duplicate the regex elsewhere.
scripts/lib/export-solution-async.js: Triggers async Dataverse solution export viaExportSolutionAsyncand pollsasyncoperationsuntil 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 viaDownloadSolutionExportData. Decodes the base64 response and writes the zip file to disk. Args:--envUrl,--asyncOperationId,--outputPath,--token(opt). Output:{ zipPath, fileSizeBytes }.
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 bysetup-datamodelandadd-sample-data.dataverse-prerequisites.md: PAC CLI auth check (pac env who), Azure CLI token acquisition, API access verification (WhoAmI). Used bysetup-datamodel,add-sample-data,setup-solution,export-solution, andimport-solution.framework-conventions.md: Supported frameworks, framework → build tool / router / build output / public dir / index HTML mapping, framework detection viapackage.json, route discovery patterns. Used bycreate-siteandadd-seo.datamodel-manifest-schema.md: Schema spec for.datamodel-manifest.json(fields, types, usage). Written bysetup-datamodel, read byadd-sample-data, validated byvalidate-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 viaupdate-skill-tracking.js.solution-api-patterns.md: OData body templates for publisher POST, solution POST,AddSolutionComponent,ExportSolutionAsync,DownloadSolutionExportData,ImportSolutionAsync,StageSolution. Also documents.solution-manifest.jsonformat. Used bysetup-solution,export-solution, andimport-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 bydiagnose-deployment.cicd-pipeline-patterns.md: PAC CLI service principal auth syntax; complete ADOazure-pipelines.ymltemplate; complete GitHub Actionsdeploy.ymltemplate; 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 viaRetrieveSetting,deploymentenvironmentscreate +validationstatuspoll,deploymentpipelinescreate,$refassociate source (relative path format),deploymentstagescreate,RetrieveDeploymentPipelineInfo, stage run create +ValidatePackageAsync(204) +operationpoll,deploymentsettingsjsonPATCH,DeployPackageAsync,stagerunstatusterminal values,docs/alm/last-pipeline.jsonanddocs/alm/last-deploy.jsonformats. Used bysetup-pipelineanddeploy-pipeline.approval-gates.md: Canonical terminology, marker syntax, and catalog of every user-confirmation point ("Approval Gate") across the entire power-pages skill set (12 ALM + 12 non-ALM). Defines six categories (intent/plan/progress/consent/final/pause), an explicit-pairing marker (<!-- gate: skill:phase | category=X | cancel-leaves=Y -->+ human> 🚦 Gate (...)block), thecancel-leavesvocabulary, and the seven gate-related lint rules enforced byscripts/lint-skills-alm.jsat hard-fail severity:GATE-must-have-marker,GATE-id-must-be-unique,GATE-must-be-in-catalog,GATE-intent-must-call-helper,GATE-cancel-leaves-known-vocab,GATE-prose-block-required(marker must be followed by a 🚦 prose block within 10 lines, outside any code fence), andCATALOG-row-must-have-marker(reverse ofGATE-must-be-in-catalog— everykind: gatecatalog row must have a SKILL.md marker). §6.1–§6.12 catalogue the ALM skills; §6.13–§6.24 catalogue the non-ALM skills (create-site,deploy-site,add-server-logic,add-cloud-flow,setup-auth,integrate-webapi,setup-datamodel,add-sample-data,add-seo,create-webroles,audit-permissions,integrate-backend).report-issueis excluded because its workflow lives in the cross-plugin shared file. New skills must extend §6 in the same PR they introduce anAskUserQuestion— lint will block the PR otherwise.
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 ${PLUGIN_ROOT}/references/ paths for common content.
Playwright MCP server for browser automation and live site previews during development.
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.
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.
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).
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 <urlset> and <loc> 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.
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).
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).
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).
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.
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.
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.
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.
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).
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.
Every skill is a sequence of phases (typically 5-8): Prerequisites, Discover/Gather, Plan/Review, Implement, Verify (mandatory standalone phase), Deploy/Summarize. Never skip or reorder phases.
Create all tasks upfront at Phase 1 start using TaskCreate (one per phase). Each task needs subject (imperative), activeForm (present continuous for spinner), and description. Mark in_progress when starting, completed when done. Include a progress tracking table at the end of the SKILL.md.
---
name: <skill-name>
description: >-
<when to use this skill>
user-invocable: true
argument-hint: <optional>
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Task, TaskCreate, TaskUpdate, TaskList, AskUserQuestion
model: opus
---Note: allowed-tools must be a comma-separated list, not JSON array or YAML list syntax. Do not add hooks to skill frontmatter; Power Pages skills register lifecycle hooks centrally.
Every SKILL.md must include the following line immediately after the closing --- of the frontmatter (before the # title):
> **Plugin check**: Run `node "${PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding.This runs a lightweight check comparing the local plugin version against origin/main and shows an update notice if a newer version is available.
- Approval Gates — Every load-bearing
AskUserQuestionis an Approval Gate. Pause at minimum after gathering requirements, after presenting a plan, after implementation, and before deployment (Three-Point Approval Pattern). Every skill in this plugin (ALM and non-ALM alike) must (a) catalogue each gate inreferences/approval-gates.md§6 with a stablegate-id, and (b) mark it in SKILL.md with the explicit-pairing comment<!-- gate: skill:phase | category=<intent|plan|progress|consent|final|pause> | cancel-leaves=<vocab> -->followed by a human-readable> 🚦 **Gate (...)**block. Pure data-gathering prompts (free-text fallbacks, configuration sub-prompts) take a<!-- not-a-gate: <reason> -->comment instead.scripts/lint-skills-alm.jsenforces this at hard-fail severity across the whole plugin — there is no warn-only carve-out for any skill class. When you add a new skill that introduces anAskUserQuestion, you must extendreferences/approval-gates.md§6 with the new gate-id(s) in the same PR; CI will block the PR otherwise. 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-siteif yes. - Lifecycle hooks — Hook registration is centralized in
hooks/hooks.json;scripts/lib/powerpages-hook-utils.jsderives tracked skills fromskills/*/SKILL.mdand discovers optionalscripts/validate*.jsvalidators. Do not define hook registration in individualSKILL.mdfiles. - Graceful failure — Track API call results, never auto-rollback, report failures clearly, continue with remaining items.
- Token refresh — Refresh Azure CLI token every ~20 records / 3-4 tables / ~60 seconds.
- Git commits — Commit after every significant milestone (each page/component, design foundations, phase completion).
- Agent spawning — Process sequentially (not parallel), wait for completion, present output for approval.
- Skill tracking — Every skill must record usage in its final phase via
> Reference: ${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 inreferences/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
```bashfences (or plain```) only for cross-platform commands likepac,az,dotnet, andnode. 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<placeholder>angle-bracket style there (e.g.,<envUrl>). 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 importgetAuthTokenandmakeRequestfromscripts/lib/validation-helpers.js. Never use inline PowerShellInvoke-RestMethodfor 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.jsonexists. Concrete rules:- Solution selection — strict resolution order. When a skill or script needs "which solution?" for an
AddSolutionComponentcall, resolve in this order and stop at the first match:- Explicit
--solutionUniqueNameCLI arg (orsolutionName=…skill argument). Always wins. Used by advanced flows and CI. .solution-manifest.jsonin the project root — readsolution.uniqueName. This is the default path for nearly every invocation.- No manifest AND no explicit arg:
- Interactive skill: query Dataverse for unmanaged solutions whose publisher prefix matches the site publisher, present them via
AskUserQuestionalongside the option "Run/power-pages:setup-solutionfirst (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 toDefault. Skills must never auto-pick "the first solution that looks relevant" — auto-selection masks misconfigurations (wrong env, wrong branch, wrong project).
- Interactive skill: query Dataverse for unmanaged solutions whose publisher prefix matches the site publisher, present them via
- Explicit
- Component-creation scripts must accept a
--solutionUniqueNameargument and, when provided, add the created record to that solution viaAddSolutionComponent. Test thatsolutionUniqueNameflows through end to end. - Skill workflows must read
.solution-manifest.jsonduring prerequisite checks and pass the solution'suniqueNameto 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 inDefault. - Skills that can leave Dataverse artifacts uncovered (e.g.
setup-authwriting OAuth secrets as env vars) must end by prompting the user to run/power-pages:setup-solutionin 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 thePPC_TYPE_LABELSenum. Discovery should never silently skip a type.
- Solution selection — strict resolution order. When a skill or script needs "which solution?" for an
The following skills are planned but require POC validation before implementation:
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 whetherpac pageshas 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 (environmentvariablevalueswithkeyVaultReferenceJSON) and validation ofaz keyvault set-policyassignment in same session.
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-pipelineGitHub/ADO paths: Currently "coming soon" stubs. Full implementation spec is atC:\Users\nityagi\OneDrive - Microsoft\Design Documents\Plans\ALM skills for plugin\ado-cicd-skills-guide.md.
These patterns have caused repeated PR review feedback. Check for them before submitting changes to skills, validators, or hooks.
- Phase cross-references break silently — When renumbering or reordering phases in a SKILL.md, also update:
references/docs that mention phase numbers, the Key Decision Points section, and any other files that cross-reference this skill's phases. After any phase reorder, grep for the old phase number across the skill directory and its references. - Validators must match the exact constraint — If the rule is "no exports at all", block all
module.exports/exports— don't just check if exported names are in an allowlist. If the rule is "try/catch required", verify bothtryANDcatchexist. Re-read the exact constraint from SKILL.md and test the boundary cases. - Hook scripts run on every Skill tool use — The PostToolUse hook fires for all tracked skills, so unconditional
process.stderr.writecreates noise. Gate debug logging behindprocess.env.DEBUG. Only errors should go to stderr unconditionally. - Template placeholders are context-encoded — bare string placeholders render as HTML text, while structured values render as script-safe JSON. Use
__JSON_KEY__for every JavaScript orapplication/jsonvalue,__ATTR_KEY__for attributes, and reserve__RAW_KEY__for code-owned trusted markup. - Guidance must be consistent within a skill — If one section says "always use raw fetch", a framework-specific table in the same file must not recommend a different HTTP client without qualification. Reviewers will flag contradictions.
These requirements apply to scripts, hooks, skills, templates, reports, and documentation.
- Process execution: Non-constant data from users, files, environment variables, APIs, or CLI output MUST NEVER reach
exec/execSynccommand strings or a child process launched withshell: true. New and changed calls MUST useexecFile,execFileSync,spawn, orspawnSyncwith a fixed executable, an argv array, andshell: false; follow the argv patterns inscripts/lib/pac-bap-shim.jsandscripts/lib/telemetry/lib/pac-auth.jsinstead of adding quoting helpers. - Authenticated URL boundary: Introduce one authenticated-URL validator in
scripts/lib/validation-helpers.js, then require every caller to reuse it beforegetAuthToken, anAuthorizationheader, or any authenticated request is created. The validator MUST require HTTPS, reject credentials and unexpected ports, and match the parsed hostname against the Microsoft cloud mappings inCLOUD_TO_APIand the approved Dataverse host suffixes; validate redirects and@odata.nextLinkvalues again, and NEVER use substring host checks or local one-off regexes. - Untrusted report data: Keep report data as data. Prefer DOM
textContent; otherwise use the encoder for the exact HTML text, HTML attribute, JavaScript string, or URL component context, and NEVER put raw values intoinnerHTML, event-handler attributes, script source, or navigation URLs. Reuse or extend the encoding boundary inscripts/lib/render-template.jsandscripts/lib/templates/security-review-report.htmlrather than adding per-report escaping. - Script embedding: Every value embedded in
<script>, including strings, MUST pass throughJSON.stringify; escape characters that can terminate or alter the script context after serialization. The non-string branch inscripts/lib/render-template.jsshows part of this pattern but is not a complete encoder for string placeholders. Quoted__PLACEHOLDER__substitution is not JSON-safe. Treat filenames, labels, findings, and scanner output as untrusted even when the plugin produced the file. - Plugin code resolution: Resolve plugin-owned code from
__dirnameor the host-provided${PLUGIN_ROOT}/${CLAUDE_PLUGIN_ROOT}and fail closed when neither is available.process.cwd()is a project-input location only and MUST NEVER be a fallback for locating plugin scripts, hooks, templates, configuration, or dependencies. - Runtime dependencies: Runtime packages MUST use an exact version and a committed lock or equivalent integrity record at the owning package boundary. NEVER fetch and execute
@latest, an unpinned package, or installer output during a plugin run; this includes MCP launchers such asscripts/launch-playwright-mcp.js. - Archives: Treat archive entry names and metadata as hostile before listing or extraction. Upgrade existing solution-archive entry points, including
skills/export-solution/scripts/validate-export.js, to use a shell-free library or fixed executable plus argv, inspect entries first, enforce file/count/size limits, reject absolute paths, traversal, links, and special files, and extract only into a new private temporary directory. - Approval guardrails:
references/approval-gates.mdandscripts/lint-skills-alm.jsare the source of truth for plugin Approval Gates, and host tool approvals MUST remain enabled. Documentation and skills MAY recommend narrow command allowlists, but MUST NEVER recommend global permission bypasses or options such as--dangerously-skip-permissions. - Telemetry and privacy: Follow the
## Telemetrycontract below and minimize every event to its allowlisted operational fields; NEVER collect prompts, report contents, secrets, tokens, full paths, URLs, hostnames, tenant data, or user content. Disclosures MUST accurately state the shipped default, local mirror behavior, and opt-out precedence; telemetry-capable CI jobs MUST setPOWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1. Editshared/telemetry/first, refreshscripts/lib/telemetry/libin the same change, and NEVER copy another plugin'sikey.json, resolver, instrumentation key, or event stream. - Secret temporary files: Prefer stdin and keep secrets out of argv, environment variables, logs, and errors.
scripts/store-keyvault-secret.jsdemonstrates stdin handling, mode0600, andfinallycleanup; it does not yet provide private-directory or exclusive-creation guarantees. When a tool requires a file, it MUST also create a unique private temporary directory and open the secret file exclusively (wx/O_EXCL), then remove both infinally. - Security regression tests: Security fixes and security-sensitive code MUST add
node:testcoverage underscripts/tests/for inert shell metacharacters, hostile HTML and script-closing text, traversal-style filenames and archive entries, and Windows and POSIX path/process behavior. Tests MUST prove that data remains data and that validation fails closed without placing a working exploit in documentation or fixtures. - Private vulnerability handling: Suspected exploitable reports MUST stay in a GitHub private security advisory or another approved private channel until remediation and disclosure are coordinated. Do not paste exploit details, secrets, customer data, or working payloads into public issues, PRs, logs, reports, or test output; public changes should describe the affected class and the guardrail.
This plugin ships 1DS telemetry for skill-run and script-run signals. The canonical shared library lives at the repo-root shared/telemetry/; scripts/lib/telemetry/lib is a physical copy bundled with this plugin so local checkouts and installed plugins do not depend on symlink handling. Zero npm dependencies — nothing to install.
scripts/lib/telemetry/libis copied from the repo-rootshared/telemetry/lib— editshared/telemetry/lib/first, then refresh this plugin's copy in the same change. The real files next to the copy areikey.json(this plugin's config) andresolver.js(the resolver contract implementation). Posture: the committedikey.jsonshipsdisabled: false— transmission is enabled for power-pages (the tenant-side Kusto stream + annotation forPagesAIPluginEventare provisioned). Setdisabled: trueto hard-off (no POST, no local log) if you need to suppress all telemetry at the source.- Region routing (Artemis geo + cloud stamp) lives in
scripts/lib/telemetry/region/and is wired throughscripts/lib/telemetry/resolver.js, which implements the resolver contract (resolve({ event, cfg, cloud, configDir })/isProvisioned(cfg)). The shared dispatcher is routing-agnostic — it auto-discoversresolver.jsby convention (sibling ofikey.json) and calls it; theregion/implementation is entirely plugin-owned and never referenced by the shared library. - Privacy posture: usage telemetry is default-on. When PAC is signed in, events include the Dataverse organization GUID (
orgId) and Entra tenant GUID (tenantId). Power Pages also includes the signed-in user's Entra object ID (eventInfo.aadObjectId) when PAC exposes it; otherwise that field is omitted. The local diagnostic mirror retains the same fields. Events do not include file paths, prompts, tool inputs, site names, Dataverse URLs, credentials, usernames, or hostnames. There is no consent prompt in skills. Users opt out via/power-pages:telemetry off, which stores a per-plugin choice in~/.power-platform-skills/config.json(telemetry["power-pages"] = "off"). Opting out stops transmission only; the local diagnostic mirror is still written. Re-enable with/power-pages:telemetry on. Automation/CI can disable transmission by settingPOWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1(ortrue); this opt-out has the highest precedence and overrides both a persisted/power-pages:telemetrychoice and/power-pages:telemetry on. - Strict allowlist:
shared/telemetry/lib/events.jsenforces exactly the fields listed in the spec. Never add a field to a builder without first adding it to the allowlist and documenting it in the design doc. - Fail closed: telemetry code must never change a script's exit code or break a skill run. Emission is fire-and-forget via a detached dispatcher child, so the hook or script returns before the HTTPS POST completes.
See shared/telemetry/README.md for the integration guide.
Update when plugin structure or conventions change or you learn something which can be useful for new skills or agents.
Keep this file concise — detailed docs belong in PLUGIN_DEVELOPMENT_GUIDE.md or individual SKILL.md / agent files.