plan-alm
SkillDocs & knowledgeCreates an ALM (Application Lifecycle Management) plan for deploying a Power Pages site across environments. Gathers your promotion strategy, target environments, and approval requirements upfront, then generates a visual HTML plan document for your review and approval. **plan-alm does not deploy anything itself** — it is a planner. After you approve the plan, run the individual ALM skills (setup-solution, setup-pipeline, deploy-pipeline, or export-solution/import-solution); each detects the approved plan and executes the right step in order, keeping the plan updated as it runs. Use when asked to: "plan my alm", "set up alm", "create deployment plan", "plan my deployments", "help me deploy to multiple environments", "set up promotion strategy", "create cicd plan", "plan site promotion", "help me go to production", "set up pipeline for my site".
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the plan-alm skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/power-pages/skills/plan-alm/SKILL.md and read by ahel’s review.
Plugin check: Run
node "${PLUGIN_ROOT}/scripts/check-version.js"— if it outputs a message, show it to the user before proceeding.
plan-alm
A 4-phase planner that gathers ALM strategy from the user, generates an HTML deployment plan, and gets approval. It does not execute anything — execution is delegated to the individual ALM skills, which the user runs afterward.
Overview
This skill detects the current project state (existing solution, pipeline), asks targeted questions about the desired promotion strategy (Power Platform Pipelines or Manual export/import), generates a visual docs/alm-plan.html, and gets user approval. The four phases are: Phase 1 — Detect, Phase 2 — Gather strategy, Phase 3 — Generate plan, Phase 4 — Approve & save.
plan-alm never deploys. The plan's steps[] array records the recommended execution sequence. After approval, the user invokes the individual skills — setup-solution, setup-pipeline (or export-solution), and deploy-pipeline (or import-solution) — in that order. Each of those skills detects the approved plan via its Phase 0 gate, proceeds without re-nagging, and refreshes the plan on completion. This separation is deliberate: it keeps plan-alm safe to run unattended (e.g. under autopilot) because no single answer can trigger an irreversible deployment.
Do NOT create tasks at the start — strategy is unknown until Phase 2 completes. Create both tasks in Phase 3 once the strategy is determined.
Phase 1 — Detect Project State
Do NOT create tasks yet. Use natural language progress reporting only during this phase.
Steps:
-
Detect prior ALM deferral for this project. Before any discovery work, check whether the project root contains a
.alm-deferredmarker file. The marker is written by users who explicitly opted ALM-skill validators out of "missing artifacts" warnings (e.g. "this site is handled separately" or "ni-dev — no ALM"). If a user is now invokingplan-alm, we should surface that the marker is present and ask what to do, rather than silently proceeding (which would build a plan the user previously decided not to maintain) or silently removing the marker (which would re-enable nags on every other ALM skill).node "${PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "." --no-heartbeatUse
--no-heartbeathere: this is a read-only deferral check by the planner, not an execution-skill Phase 0 gate. Without it,check-alm-plan.jswould promote an already-Approvedplan toIn Execution(and refresh the heartbeat) just because you re-openedplan-alm— but re-planning isn't execution. Execution skills call it without--no-heartbeatso the first one to run does theApproved → In Executionpromotion.🚦 Gate (progress · plan-alm:1.deferral):
.alm-deferredmarker present — continue and remove, continue and keep marker, or cancel. Determines whether downstream ALM skills resume gate enforcement.The helper returns
{ deferred, deferral, ... }. Ifdeferred === true, read the deferral reason (deferral.reasonor the raw marker text) and ask viaAskUserQuestion:"This project has an
.alm-deferredmarker —{reason}. ALM was previously deferred here, so the other ALM skills (setup-solution,setup-pipeline,deploy-pipeline, …) skip their plan-completeness checks for this project. How would you like to proceed?"Question Header Options How would you like to proceed? ALM deferral marker Continue planning and remove the marker (Recommended), Continue planning but keep the marker (record deferral context in plan), Cancel - Continue and remove marker (Recommended) → delete
.alm-deferred(the user is re-engaging with ALM). SetDEFERRAL_CLEARED = trueand proceed to step 1. - Continue and keep marker → set
DEFERRAL_PRESERVED = trueandDEFERRAL_REASON = {reason}. Proceed to step 1. Surface a one-line note in the Phase 1 step 9 user report (e.g. "Note:.alm-deferredis preserved — other ALM skills will continue to skip plan-completeness checks for this project.") so the user remembers the marker remains in effect after planning. - Cancel → exit cleanly (don't touch the marker).
If
deferred === false, skip this step silently and proceed to step 0b. - Continue and remove marker (Recommended) → delete
0b. Offer to approve an existing Draft in place (skip re-planning). The same check-alm-plan.js output from step 0 also carries exists and planStatus. When exists === true and planStatus === "Draft", the user already has a saved Draft plan — offer to approve it directly instead of regenerating the whole plan. (This is the only Draft→Approved path; without it, approving a draft means a full re-plan.)
🚦 Gate (plan · plan-alm:1.approve-draft): An existing Draft plan was found — approve it in place (no re-plan), re-plan from scratch, or cancel. Approving here writes the status via
set-plan-status.jsand exits without re-running discovery; no deployment is triggered.
Ask via AskUserQuestion:
"This site already has an ALM plan saved as Draft (
docs/alm-plan.html). What would you like to do?"
| Question | Header | Options |
|---|---|---|
| What would you like to do? | Existing draft plan | Approve this draft now — no re-plan (Recommended), Re-plan from scratch, Cancel |
-
Approve this draft now (Recommended) → capture the approver using the Phase 4 approver-capture procedure (the always-interactive prompt with git/OS-name prefill), then write the status atomically with the helper:
node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Approved --approver "{APPROVER}" --renderCommit (
git add docs/alm-plan.html docs/.alm-plan-data.json && git commit -m "Approve ALM plan for {siteName}"), run skill tracking (Phase 4 finalize), print the Phase 4 next-steps guidance, and exit. Do not continue to step 1 — there is nothing to re-plan. -
Re-plan from scratch → proceed to step 1 (the rest of Phase 1 regenerates the plan; Phase 4 saves the new version).
-
Cancel → exit cleanly (leave the Draft as-is).
If exists === false, or planStatus is anything other than "Draft" (Approved / In Execution / Completed / null), skip this step silently and proceed to step 1.
-
Resolve the site identity from the local project.
.powerpages-site/website.ymlis the source of truth forwebsiteRecordIdandsiteName, and it is present for both Power Pages site types:- Code / SPA sites — scaffolded by
/power-pages:create-siteand downloaded withpac pages download-code-site. These also have apowerpages.config.jsonand SPA source (src/, build output indist//build/). - Data-model sites (standard and enhanced data model / "EDM") — downloaded with
pac pages download --modelVersion 1|2. These have nopowerpages.config.json; instead.powerpages-site/holds the config tree (web-pages/,web-templates/,content-snippets/, …) plus a.powerpages-site/.portalconfig/manifest pair. There is no local build output.
Resolution order (first match wins):
.powerpages-site/website.yml(preferred, present for every downloaded/deployed site) — read with theReadtool and extract:idfield →websiteRecordIdnamefield →siteName(the file uses short keys; it isname:, notadx_name:)
powerpages.config.json(fallback — code/SPA sites only; used during plugin development from this repo root or for sites scaffolded but not yet deployed) — readsiteNameandwebsiteRecordId.
Determine
SITE_TYPE(recorded in planData assiteTypeand used to skip SPA-only assumptions below; it is a data field indocs/.alm-plan-data.json, not rendered in the HTML):declarativewhen.powerpages-site/.portalconfig/exists, or.powerpages-site/website.ymlresolved while nopowerpages.config.jsonis present. (This value was formerlydata-model; plans written before the rename may still carrydata-model, which is equivalent.)codewhenpowerpages.config.jsonis present.
If neither marker is found, stop with:
"No Power Pages site found in the current directory. Run this skill from your site project root — that's where
.powerpages-site/lives afterpac pages download-code-site(code/SPA site) orpac pages download --modelVersion 2(enhanced data-model site). If you haven't created the site yet, run/power-pages:create-sitefirst."environmentUrlis always re-confirmed frompac env whoin step 4 — it does not need to come from either source. - Code / SPA sites — scaffolded by
-
Check for
.solution-manifest.jsonin the project root:- Store
SOLUTION_DONE = trueif found,falseotherwise - If found, read
solution.uniqueNameand store asSOLUTION_UNIQUE_NAME
- Store
-
Check for
docs/alm/last-pipeline.jsonin the project root:- Store
PIPELINE_DONE = trueif found,falseotherwise - If found, read
pipelineNameandstages[]for later use
- Store
-
Run silently:
pac env whoCapture the environment URL and display name. Store as
DEV_ENV_URLandDEV_ENV_NAME. The URL label varies by PAC version: current PAC (2.8.x) prints it underOrg URL:; older builds usedEnvironment URL:— read whichever is present (there is noEnvironment URL:line on 2.8.x, so do not look only for that label). The display name is theFriendly Name:/Connected to...value. If you can't parse it reliably, leaveDEV_ENV_URLempty — Step 6'sverify-alm-prerequisites.jsresolves the authoritative URL frompac env whovia the sharedgetEnvironmentUrl()helper (which matches both labels) and returns it as.envUrl. -
Run silently:
node "${PLUGIN_ROOT}/scripts/lib/list-environments.js"Store the JSON array as
ENV_LISTfor pre-filling environment URLs in Phase 2. (This helper parsespac env list; the oldpac env list --output jsonis invalid on current PAC CLI —pac env listonly accepts--filter— so the helper exists to produce the JSON the table form doesn't. It prints[]and exits 0 if PAC is unauthenticated, so pre-fill simply degrades to manual entry.) Each entry is{ displayName, environmentId, environmentUrl, uniqueName, active }. -
Acquire dev environment token (silently):
node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{DEV_ENV_URL}"Store
.tokenasDEV_TOKENand.userIdasuserId.Track plan quality. Initialize a
PLAN_QUALITYaccumulator to"complete"at the start of Phase 1. If this token acquisition fails (auth error), setDEV_TOKEN = null, setPLAN_QUALITY = "degraded", and record the cause (e.g. "dev-environment auth failed — contents/size/host discovery skipped") — then continue. Contents discovery is skipped gracefully, but the resulting plan is built on partial inputs; Phase 3 surfaces this as a prominent risk so the user reviews before executing. (There is no execute path to block here —plan-almonly plans — but a degraded plan must be visibly flagged.)
6b. Environment-match guard — confirm pac env who points at the project's environment before running discovery. DEV_ENV_URL comes from whatever environment PAC happens to be connected to, which is not guaranteed to be the project's. If it isn't, every query in Steps 7–12 runs against the wrong environment and silently produces a degraded plan (zero or wrong site settings, wrong size, wrong host) that looks valid. Cross-check both signals available:
1. **Recorded-URL comparison** (no token needed): collect any environment URL the project already records — `powerpages.config.json` → top-level `environmentUrl` (code/SPA sites; absent for declarative/EDM sites) and `.solution-manifest.json` → top-level `environmentUrl` if present. Normalize by **origin** (lowercase host, drop trailing slash + path/query). If any recorded URL exists and its origin **differs** from `DEV_ENV_URL`'s origin → **mismatch**.
2. **Site-existence probe** (covers declarative/EDM sites that record no URL; only when `DEV_TOKEN` is available): verify the site's `websiteRecordId` actually exists in the connected env:
```
GET {DEV_ENV_URL}/api/data/v9.2/powerpagesites({websiteRecordId})?$select=powerpagesiteid
Authorization: Bearer {DEV_TOKEN}
```
A `404` (or empty result) means the connected environment does not contain this site → **mismatch**. (Skip this probe when `DEV_TOKEN = null` — Step 6 already degraded the plan; don't double-prompt.)
If **neither** signal indicates a mismatch, continue silently to Step 7 — do not prompt. Only prompt on a detected mismatch:
<!-- gate: plan-alm:1.env-match | category=progress | cancel-leaves=nothing -->
> 🚦 **Gate (progress · plan-alm:1.env-match):** PAC CLI is connected to an environment that does not match the project's. Switch and re-run, or continue against the connected env (degraded plan).
Ask via `AskUserQuestion`:
| Question | Header | Options |
|---|---|---|
| PAC CLI is connected to **{DEV_ENV_NAME}** (`{DEV_ENV_URL}`), which does not match this project's configured environment ({recorded URL, or "this site was not found there"}). Discovery will run against the connected environment. How do you want to proceed? | Env Mismatch | Switch PAC env & re-run (Recommended), Continue against {DEV_ENV_NAME} anyway |
Exactly two outcomes (both halt-or-proceed; no separate "cancel" — "Switch & re-run" already stops the skill):
- **Switch PAC env & re-run (Recommended)**: stop the skill. Tell the user to point PAC at the right environment (`pac auth select --name <profile>` or `pac org select --environment <url>`) and re-run `/power-pages:plan-alm`. Nothing has been written.
- **Continue against {DEV_ENV_NAME} anyway**: proceed to Step 7 against `DEV_ENV_URL`, but set `PLAN_QUALITY = "degraded"` and record the cause (*"discovery ran against {DEV_ENV_NAME}, which may not be the project's environment — verify the plan's site settings / size / host before executing"*) so Phase 3 surfaces it as a prominent risk.
> **Why this exists**: a real EDM-site run produced a valid-looking plan after PAC had silently stayed connected to a different env than the project targeted. The site-existence probe + recorded-URL comparison catch that at the earliest gate, before any discovery runs.
7. Discover and classify site settings (if DEV_TOKEN is available and websiteRecordId is known):
Use Node.js https module to query. Paginate via @odata.nextLink — sites with > 500 settings would otherwise silently truncate, dropping tier classifications and underreporting plannedEnvVarCount. Send Prefer: odata.maxpagesize=5000 so Dataverse emits the continuation link, then loop until exhausted:
GET {DEV_ENV_URL}/api/data/v9.2/mspp_sitesettings?$filter=_mspp_websiteid_value eq '{websiteRecordId}'&$select=mspp_name,mspp_value&$top=5000
Authorization: Bearer {DEV_TOKEN}
Prefer: odata.maxpagesize=5000
OData-MaxVersion: 4.0
OData-Version: 4.0
Accept: application/json
On each response, append value[] to the running array. If @odata.nextLink is present, GET that URL with the same headers (no need to re-add the filter — the nextLink already encodes the query). Stop when the response has no @odata.nextLink. Cap at 100 iterations for safety.
Classify the returned settings using ${PLUGIN_ROOT}/scripts/lib/classify-site-settings.js — the single source of truth for the credential regex and tier mapping shared with setup-solution Phase 5. Either pipe the JSON array of {name, value} rows into the script's stdin (CLI mode) or require() it inline:
echo '<JSON array of {name,value}>' \
| node "${PLUGIN_ROOT}/scripts/lib/classify-site-settings.js"
Output (the four-bucket shape that downstream phases + setup-solution consume directly):
SITE_SETTINGS_DATA = {
keepAsIs: [{name}], // regular settings (Tier 3 — Search/Bootstrap/WebApi/feature flags)
authNoValue: [{name}], // Authentication/* or AzureAD/* with empty value (Tier 2b — added as-is, set in target env)
promoteToEnvVar: [{name, value}], // Authentication/* or AzureAD/* with value (Tier 2a — setup-solution offers env-var promotion)
credentialNeedsDecision: [{name, value}] // ConsumerKey/ConsumerSecret/ClientId/ClientSecret/AppSecret/AppKey/ApiKey/Password (Tier 1 — bulk-with-override prompt in setup-solution Phase 5.4.C)
}
Tier semantics in plain English (so reviewers reading the plan know what each bucket implies):
- Tier 1 (
credentialNeedsDecision) — credential-style names. Setup-solution Phase 5.4.C runs a single bulk prompt: auto-classify by name (Secret-typed env var for*Secret/*Password/*ApiKey/*AppKey; String-typed for*Id/*ConsumerKey), all-as-Secret, all-as-String, skip-all, or pick-per-credential. - Tier 2a (
promoteToEnvVar) — auth config with a dev value. Setup-solution Phase 5.4.A asks which to back with env vars so each stage can use different values. - Tier 2b (
authNoValue) — auth config with no dev value yet. Added to the solution as-is; user sets the value in each target env after deployment. - Tier 3 (
keepAsIs) — everything else. Added unchanged.
If the OData query fails or the helper errors out, set SITE_SETTINGS_DATA = null and continue — the plan still renders, it just can't break down site settings by tier.
-
Build
SOLUTION_CONTENTS_DATA:{ tables: solutionManifest?.components?.tables || [], // from .solution-manifest.json if SOLUTION_DONE botComponents: solutionManifest?.botComponents || [], // from manifest if available siteSettings: SITE_SETTINGS_DATA // from step 7, or null }If
SOLUTION_DONE = falseand manifest is absent,tablesandbotComponentswill be empty arrays — the plan will show a note that they will be discovered during setup-solution. -
Report to user:
Found: **{siteName}** on `{devEnvUrl}`. Solution: {✓ already set up ({solutionUniqueName}) / ✗ not yet}. Pipeline: {✓ already set up ({pipelineName}) / ✗ not yet}. Site settings: {N total — K regular (keep as-is), P auth settings to review for env var, A auth settings (no dev value), C credential-style settings (setup-solution will prompt per credential) / unable to query}. -
Estimate solution size and evaluate the split decision tree. First ensure the ALM artifacts directory exists (all
.alm-*andlast-*artifacts live underdocs/alm/to keep the project root uncluttered):node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"Run the estimate helper to classify the site across size, component count, schema heaviness, web file aggregate, and env var count. Use the tmp-file write pattern — if the estimator fails, a prior good
docs/alm/alm-size-estimate.jsonis preserved instead of being overwritten with an empty/partial file. WhenSOLUTION_DONE = true(a.solution-manifest.jsonexists), pass--solutionId {solutionId}so the env var count is scoped to the target solution — without it, the estimator falls back to a publisher-prefix tenant-wide query and overcounts whenever the prefix is shared across projects (the commonnew_/cr5fe_regression):node "${PLUGIN_ROOT}/scripts/lib/estimate-solution-size.js" \ --envUrl "{DEV_ENV_URL}" --websiteRecordId "{websiteRecordId}" \ --publisherPrefix "{publisherPrefix}" --siteName "{siteName}" \ {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} \ --projectRoot "." --siteType "{SITE_TYPE}" \ --datamodelManifest "./.datamodel-manifest.json" > ./docs/alm/alm-size-estimate.json.tmp \ && mv ./docs/alm/alm-size-estimate.json.tmp ./docs/alm/alm-size-estimate.jsonWhen
SOLUTION_DONE = false, omit--solutionId; the estimator's output will includeenvVarCountScope: "publisher-prefix"to signal the wider scope, and the renderer surfaces this caveat in the Env Variables tab so reviewers know the number reflects the tenant view, not a specific solution.--projectRoot "."enables the disk cross-check — the estimator walks the local build output (dist/,public-output/,build/,.output/) and surfaceswebFilesDiskMeasuredMB. When that number is much larger than the Dataverse-measuredwebFilesAggregateMB, the estimator flipstruncationSuspected: truewith a warning — file-typed columns whose bytes aren't returned by$select=contentare the usual cause and the plan should trust the disk number.
SITE_TYPE = "declarative"(EDM/standard data-model) sites have no build output, so the disk cross-check finds nodist//build/directory andwebFilesDiskMeasuredMBstaysnull— this is expected, not a problem. Web files for declarative sites live as records under.powerpages-site/web-files/and are measured via the Dataverse query, so the size estimate is still valid; there's simply no SPA bundle on disk to cross-check against. Pass--projectRoot "."regardless — it's a harmless no-op for these sites. Then run the decision tree (same tmp-file pattern):bash node "${PLUGIN_ROOT}/scripts/lib/compute-split-plan.js" \ --estimate ./docs/alm/alm-size-estimate.json \ --projectRoot "." \ --siteName "{siteName}" \ --publisherPrefix "{publisherPrefix}" > ./docs/alm/alm-split-plan.json.tmp \ && mv ./docs/alm/alm-split-plan.json.tmp ./docs/alm/alm-split-plan.jsonIf either command exits non-zero, stop and report the stderr message to the user. Do not proceed to Q1b in Phase 2 without a valid split plan. Store the output asSPLIT_PLAN. Fields to read:splitStrategy,proposedSolutions[],appliedStrategies[],assetAdvisory,sizeAnalysis,recommendations[].
If `SPLIT_PLAN.proposedSolutions.length > 1`, set `RECOMMEND_SPLIT = true`. Otherwise `false`.
Report to the user:
```
Estimated size: {totalSizeMB} MB — components: {count} — tier: {overall tier}.
Tables: {tableCount} — scoped to the site's table permissions ({tableCountScope}).
Decision tree result: {splitStrategy} → {N} solutions recommended.
Asset advisory: {K} files flagged for Azure Blob externalization.
```
> **Table count is site-referenced, not publisher-prefix.** The estimator scopes custom tables to the tables the site actually references (its table permissions + datamodel manifest), so a shared/default publisher (`new_`) no longer inflates the count. `tableCountScope` reports how it was scoped: `site-referenced` (table permissions), `manifest-only`, or `unavailable` (no local `.powerpages-site/` signal — table count is 0, never an env-wide dump). When `unavailable`, note that the table-based split signal was skipped. The estimate command already passes `--projectRoot "."`, which supplies the local table permissions.
10b. Enumerate environment variable definitions (runs whenever DEV_TOKEN is available — the size estimator gives a count but not per-variable metadata).
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 859
- Forks
- 176
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
plan-alm- Source
- github.com/microsoft/power-platform-skills