setup-solution
SkillCloud & infraCreates a Dataverse publisher and solution, then adds Power Pages site components to the solution for ALM and deployment management. Use when asked to: "create solution", "set up solution", "add to solution", "package site into solution", "create publisher", "solutionize my site", or "set up ALM for my site".
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 setup-solution skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/power-pages/skills/setup-solution/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.
setup-solution
Creates a Dataverse publisher and solution, then adds Power Pages site components. Writes .solution-manifest.json for use by export-solution, import-solution, and setup-pipeline skills.
Prerequisites
- PAC CLI installed and authenticated (
pac env whoreturns an environment URL) - Azure CLI installed and logged in (
az account showsucceeds) powerpages.config.jsonexists in the project root (site must be deployed at least once so.powerpages-site/exists with component records)
Phases
Phase 0 — ALM plan gate
plan-almis the front door. When the user expresses an ALM intent (promote / ship / deploy / set up CI-CD / move to staging / push to prod), the orchestrator (/power-pages:plan-alm) should run first. This Phase 0 enforces that and is meant to fail closed when there's no plan, not to be a one-time check the user can dismiss forever.
Skip rule. If this skill was invoked as part of an active plan-alm orchestration, skip Phase 0 entirely and proceed to Phase 1. The gate helper exposes this via its inExecution block — pass through silently to Phase 1 when:
inExecution.status === "active"
The helper computes this from docs/.alm-plan-data.json — PLAN_STATUS === "In Execution" AND LAST_INVOCATION_AT within the last 60 minutes. check-alm-plan.js refreshes LAST_INVOCATION_AT automatically on every invocation that finds the plan in execution, so each in-chain skill keeps the chain alive for the next one — even multi-hour deploys (deploy-pipeline alone can take 60 min per stage) survive the window without the chain incorrectly de-classifying. Stalled chains (no heartbeat for > 60 min) reclassify as stale-heartbeat and Phase 0 gates fire normally so an abandoned plan doesn't silently bypass user confirmation.
When inExecution.status is anything other than "active" ("not-running", "stale-heartbeat", "no-plan"), run the Phase 0 gate flow below. Branch on the remaining helper fields:
Step 1 — Run the gate helper.
node "${PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "."
The helper returns JSON with { exists, deferred, stale, staleness: { reason, detail }, generatedAt, planStatus, ... }. Sync mode (when .solution-manifest.json already exists) may additionally pass --envUrl, --token, --solutionId once Phase 1 has acquired them, but for the initial gate the existence-only check is sufficient.
Step 2 — Branch on the result.
| Result | Behavior |
|---|---|
deferred: true | The user has explicitly deferred ALM for this project (.alm-deferred marker present). Pass through silently to Phase 1 — do not nag. |
exists: false | The user hasn't run plan-alm yet. See Step 3. |
exists: true, stale: false | Plan is current. Pass through silently to Phase 1. |
exists: true, stale: true (reason: solution-modified) | The solution changed after the plan was generated. See Step 4. |
Step 3 — No plan. Tell the user:
"No ALM plan exists for this project.
/power-pages:plan-almbuilds one — it detects the project state, asks about your promotion strategy (PP Pipelines vs Manual export/import), and orchestrates the right skills (including this one) in the right order. Want me to run plan-alm now?"
🚦 Gate (intent · setup-solution:0.no-plan): Fail-closed entry gate when
check-alm-plan.jsreturnsexists:false. Helper-script-backed.
AskUserQuestion:
| Question | Header | Options |
|---|---|---|
Run /power-pages:plan-alm first? | ALM plan gate | Yes — run /power-pages:plan-alm now (Recommended), Continue without a plan (advanced — I know what I'm doing), Cancel |
- Yes (Recommended) → invoke
/power-pages:plan-alm. It builds the plan and returns —plan-almis a planner and does not deploy. This skill then re-runs the Phase 0 check (nowexists:true) and proceeds to Phase 1. - Continue without a plan → set
BYPASSED_PLAN_GATE = trueand proceed to Phase 1. - Cancel → exit cleanly.
Step 4 — Stale plan. Tell the user:
"ALM plan exists from
{generatedAt}but the source solution has been modified since (at{solution.modifiedon}). Components may have changed. Re-runningplan-almwill refresh the analysis and the rendered HTML."
🚦 Gate (intent · setup-solution:0.stale-plan): Fail-closed entry gate when
check-alm-plan.jsreturnsstale:true. Helper-script-backed.
AskUserQuestion:
| Question | Header | Options |
|---|---|---|
| Refresh the plan first? | ALM plan freshness | Refresh — re-run /power-pages:plan-alm (Recommended), Continue with the existing plan, Cancel |
- Refresh (Recommended) → invoke
/power-pages:plan-alm. After completion, re-run the Phase 0 helper once to confirm freshness; if still stale, surface the detail and proceed to Phase 1 anyway (don't infinite-loop). - Continue → set
STALE_PLAN_ACK = trueand proceed to Phase 1. - Cancel → exit cleanly.
Why this gate exists. Direct invocation of setup-solution builds (or syncs) a solution without consulting the orchestrator's plan. If a plan already exists and recommends a multi-solution split, running this skill standalone may consolidate components into the wrong base solution. If no plan exists yet, plan-alm would have surfaced split recommendations, the asset-size advisory, and missing-component gaps before any solution was created — running setup-solution first burns through those decisions silently. The gate ensures setup-solution runs in the right context, while still leaving an explicit bypass for users who genuinely know they want a one-off solution.
Phase 1 — Verify Prerequisites
Create all tasks upfront at the start of this phase.
Tasks to create:
- "Verify prerequisites"
- "Gather solution configuration"
- "Check existing publishers and solutions"
- "Create publisher and solution"
- "Add site components to solution"
- "Verify and write manifest"
- "Present summary"
Steps:
-
Run
pac env who— extractenvironmentUrl,organizationId(shown to user for confirmation) -
Run
verify-alm-prerequisites.jsto confirm PAC CLI auth, acquire a token, and verify API access:node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{environmentUrl}"Capture output as JSON; extract
.envUrl(store asenvUrl) and.token(store astoken). If the script exits non-zero, stop and explain what is missing (reference${PLUGIN_ROOT}/references/dataverse-prerequisites.md). -
Locate
powerpages.config.json— readsiteNameandwebsiteRecordId -
Confirm
.powerpages-site/folder exists (required to find component records) -
Check for ALM plan context — look for
docs/alm/alm-plan-context.json:🚦 Gate (plan · setup-solution:1.preloaded): Use pre-loaded plan classifications, or re-discover. No write happens before this choice.
- If found, ask via
AskUserQuestion:"An ALM plan was previously generated for this site. It includes a pre-classified list of site settings (keepAsIs, promoteToEnvVar, authNoValue, excluded). Would you like to use those choices, or re-discover and re-classify everything now?"
- Options: "Use pre-loaded choices from plan" / "Re-discover and re-classify"
- If user chooses pre-loaded: read
docs/alm/alm-plan-context.json, store thesiteSettingsobject aspreloadedSettings. When Step 5.3 is reached, skip the query and classification logic — usepreloadedSettingsdirectly. - If user chooses re-discover: proceed normally (Steps 5.3–5.4 query Dataverse and reclassify).
- If found, ask via
-
Detect sync mode — check whether
.solution-manifest.jsonexists in the project root.-
If present: read it and verify the
solutionIdstill exists in the target environment viaGET {envUrl}/api/data/v9.2/solutions({solutionId})?$select=solutionid,uniquename,version,ismanaged.- If the solution is still present and unmanaged in this environment: set
syncMode = trueand storeexistingSolution= the manifest contents.
🚦 Gate (consent · setup-solution:1.stale-manifest): Manifest references a solution missing from the current env. Start fresh (back up the manifest and create a new solution) or abort.
- If the solution was not found, is managed, or is in a different environment: treat as a stale manifest, inform the user, and ask via
AskUserQuestion:"The existing
.solution-manifest.jsonpoints to solution{uniqueName}v{version} which I could not find in the current environment. Would you like to: 1) Start fresh (back up the manifest and create a new solution), 2) Abort so you can investigate?" Proceed only after an explicit choice.
- If the solution is still present and unmanaged in this environment: set
-
If absent: set
syncMode = false— this is a fresh setup.
-
-
Report the chosen mode to the user:
syncMode = true: "Found existing solution{uniqueName}v{version}. Running in sync mode — I'll discover the current site inventory, diff against what's already in the solution, and only add missing components."syncMode = false: "No existing solution manifest found. Running a fresh setup — I'll create a publisher and solution, then add all site components."
-
Check for split plan (multi-solution mode) — look for
docs/alm/alm-split-plan.json(written byplan-almPhase 1 Step 10):- If found and
proposedSolutions.length > 1, setMULTI_SOLUTION_MODE = trueand store the array asPROPOSED_SOLUTIONS. - In multi-solution mode:
- Phase 2 asks for publisher details once (shared across all solutions) and presents the proposed solution names/versions for confirmation (user can override each before proceeding).
- Phase 4 creates the publisher first (single serial step — every solution binds to it), then creates the solutions in
PROPOSED_SOLUTIONSin parallel. Theorderfield is data for downstream pipeline-stage ordering — it does NOT constrain creation order, since each solution is independent (distinctuniqueName, sharedpublisherId, no inter-solution dependency). - Phase 5 partitions
AddSolutionComponentcalls per solution based onproposedSolutions[i].componentTypesandtableLogicalNames(for Strategy 3). - Phase 6 writes manifest v2 (see below).
- If not found or
proposedSolutions.length === 1, proceed in single-solution mode (existing flow).
- If found and
Phase 1.5 — Ground in current ALM documentation
Reference:
${PLUGIN_ROOT}/references/alm-docs-grounding.md
Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline.
- Run
microsoft_docs_searchwith the query:Power Pages solution publisher creation Dataverse component types ALM. - Fetch
https://learn.microsoft.com/en-us/power-platform/alm/solution-concepts-alm(and at most one sister page if the search surfaces a relevant new tutorial — e.g. multi-solution layering, managed-properties guidance) in parallel viamicrosoft_docs_fetch. - Extract a one-paragraph summary of what Microsoft Learn currently says about solution components, publisher prefix immutability, managed vs unmanaged choice, and component-type integers. Compare against
${PLUGIN_ROOT}/references/solution-api-patterns.mdand flag any divergence (new component types, changed action signatures, deprecated patterns). - Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 4 (Create Publisher and Solution).
Phase 2 — Gather Solution Configuration
Skip this entire phase when
syncMode = true. UseexistingSolution.publisherandexistingSolution.solutionfrom the manifest instead. Jump to Phase 5.
🚦 Gate (consent · setup-solution:2.publisher-prefix): Publisher prefix is PERMANENT and prefixed to every component logical name. Must be confirmed explicitly. Cancel exits before any publisher/solution write.
Ask user (via AskUserQuestion) for:
- Publisher unique name (e.g.,
contoso) — lowercase letters/numbers only, no spaces. Explain this is permanent and cannot be changed. - Publisher friendly name (e.g.,
Contoso) — display name - Publisher prefix (e.g.,
con) — 2–8 lowercase letters, prefixed to all components. Explain this is permanent and cannot be changed. - Solution unique name (e.g.,
ContosoSite) — letters/numbers/underscores, no spaces - Solution friendly name (e.g.,
Contoso Site) — display name - Solution version (default:
1.0.0.0) — must bemajor.minor.build.revisionformat
Present a confirmation summary of all values and wait for user approval before proceeding.
Key Decision Point: Publisher prefix and publisher unique name are irreversible — pause and explicitly confirm with the user before proceeding.
Phase 3 — Check Existing State
Skip this entire phase when
syncMode = true. The manifest guarantees the solution exists and we already validated it in Phase 1 Step 6.
Before creating anything, check if publisher and solution already exist:
- Query publisher:
GET {envUrl}/api/data/v9.2/publishers?$filter=uniquename eq '{publisherUniqueName}'&$select=publisherid,uniquename,customizationprefix(No dedicated script for publishers — query the OData endpoint directly.) - Check solution existence using
verify-solution-exists.js:
Capture output as JSON; checknode "${PLUGIN_ROOT}/scripts/lib/verify-solution-exists.js" \ --envUrl "{envUrl}" \ --uniqueName "{solutionUniqueName}" \ --token "{token}".found(boolean). Iffound, also read.solutionId,.version, and.isManagedfor display.
Report findings to user:
- If publisher exists: "Found existing publisher
{name}(prefix:{prefix}). Will reuse it." - If solution exists: "Found existing solution
{name}version{version}. Will reuse it and add components." - If neither exists: "Will create new publisher and solution."
Wait for user confirmation before proceeding.
Phase 4 — Create Publisher and Solution
Skip this entire phase when
syncMode = true. The publisher and solution already exist.Version bump in sync mode: before any add operations in Phase 5, bump the existing solution's patch segment so the post-sync manifest and any subsequent export cleanly supersede the prior version. Use the shared helper — it is the single source of truth for the bump rule (pad-with-zero for missing segments, integer-numeric
1.0.0.9 → 1.0.0.10, reject1.0.0.a, reject more-than-4 segments). The same helper is called fromexport-solutionPhase 4 Step 4.0 — both skills must produce identical bumps for the same input version.node "${PLUGIN_ROOT}/scripts/lib/bump-solution-version.js" \ --envUrl "{envUrl}" \ --token "{token}" \ --solutionId "{solutionId}" \ --projectRoot "."Capture output as JSON; the helper returns
{ previous, next, bumped: true, manifestUpdated, manifestUpdateReason }. Passing--projectRoot "."lets the helper update.solution-manifest.json'ssolution.version(single-solution) or matchingsolutions[].version(multi-solution) field automatically — without it, the manifest drifts behind every bump. UpdateexistingSolution.solution.versionlocally to.nextso the final manifest write reflects the bump. Do this before Step 5.6's component adds, so the manifest stays consistent if the skill is interrupted midway. Do not inline the PATCH — diverging the rule between this skill andexport-solutionis exactly the bug class the helper exists to prevent.
Refer to ${PLUGIN_ROOT}/references/solution-api-patterns.md for exact request body templates.
-
Create publisher (if not existing):
POST {envUrl}/api/data/v9.2/publisherswith publisher body- Extract
publisherIdfromOData-EntityIdresponse header - On failure: report error, stop (do not proceed to solution creation)
- This step must complete before any solution creation — every solution body binds
publisherid@odata.bind. Single serial step, no parallelization.
-
Create solution(s):
Single-solution mode (
MULTI_SOLUTION_MODE = false) — callcreate-solution.js. Omit--tokenso the helper refreshes viagetAuthToken(envUrl)at call time (passing a possibly-stale cached token would surface as a 401 the helper doesn't retry):node "${PLUGIN_ROOT}/scripts/lib/create-solution.js" \ --envUrl "{envUrl}" \ --uniqueName "{solutionUniqueName}" \ --friendlyName "{solutionFriendlyName}" \ --version "{version}" \ --publisherId "{publisherId}" \ --description "Power Pages solution for {siteName}"Capture output as JSON; extract
.solutionId(store assolutionId). On failure (non-zero exit orcreated: false): report error, stop.Multi-solution mode (
MULTI_SOLUTION_MODE = true) — callcreate-solutions-batch.js, which fans out allPROPOSED_SOLUTIONSin parallel viaPromise.allSettled(typical 5-6 solution splits complete in ~2s vs ~10s serial). The helper skipsisFutureBuffer: trueentries automatically (the reserved buffer is created later when the user actually adds new components) and handles 409 races idempotently viaverify-solution-exists.js. Write the specs to a tmp JSON file, then invoke:node -e "require('fs').writeFileSync('./docs/alm/.solutions-batch.json', JSON.stringify({{PROPOSED_SOLUTIONS_AS_SPECS}}))" node "${PLUGIN_ROOT}/scripts/lib/create-solutions-batch.js" \ --envUrl "{envUrl}" \ --token "{token}" \ --publisherId "{publisherId}" \ --solutionsFile ./docs/alm/.solutions-batch.jsonWhere
{{PROPOSED_SOLUTIONS_AS_SPECS}}isPROPOSED_SOLUTIONSmapped to{ uniqueName, friendlyName: displayName, version: "1.0.0.0", description, isFutureBuffer }per entry (carry theisFutureBufferflag through so the helper can skip it). Capture the output as JSON; buildSOLUTIONS_BY_NAME = { uniqueName → { solutionId, created } }fromresult.results(entries withskipped: trueare not added —Futurebuffer solutions don't exist in Dataverse yet). Ifresult.failed > 0, surface the per-entryerrorstrings and stop — successfully-created solutions remain in Dataverse and the user can re-run setup-solution in sync mode to recover. Delete the tmp file after the call (./docs/alm/.solutions-batch.json).Token must be fresh before the batch —
create-solutions-batch.jsrefreshes once at start viagetAuthToken(envUrl)if no--tokenis passed, so prefer omitting--tokenover passing a stale one. -
Report: "Publisher
{name}is ready. Created{N}solution(s):{name1},{name2}, …" (single-solution mode: report just the one).
Phase 5 — Add Site Components
Refer to ${PLUGIN_ROOT}/references/solution-api-patterns.md for AddSolutionComponent body templates and powerpagecomponents discovery patterns.
Sync-mode behavior: When
syncMode = true, run the discovery helper with--solutionIdpopulated and use the returnedmissing.*arrays as the candidate set. Everything else in this phase (dynamic component-type lookup in 5.1, categorization in 5.3, OAuth secret conversion in 5.4, env var adoption in 5.4b, orphan ppc adoption in 5.4c, manifest summary in 5.5, bulk add in 5.6) runs the same way, just with a pre-filtered "only things that aren't already in the solution" list. The goal of sync mode is: a user who added a server logic, bot, flow, env var, or page aftersetup-solutionlast ran can re-invoke the skill and get those components adopted without any fresh-setup prompts.Fresh-mode behavior (
syncMode = false): run the full discovery as documented below — every ppc, every site language, every custom table, every publisher-prefix env var becomes a candidate for inclusion.
Step 5.1 — Discover Component Types Dynamically
Do not hardcode component type numbers. Component type codes are environment-specific metadata and vary across tenants. Always resolve them at runtime using discover-component-types.js.
Run discover-component-types.js with the website record ID plus one sample powerpagecomponent ID and one site language ID (obtained from the preliminary discovery queries in Step 5.2 below — run those first if not yet available):
node "${PLUGIN_ROOT}/scripts/lib/discover-component-types.js" \
--envUrl "{envUrl}" \
--token "{token}" \
--websiteRecordId "{websiteRecordId}" \
--powerpageComponentId "{anyPowerpageComponentId}" \
--siteLanguageId "{siteLanguageId}"
Capture output as JSON; extract .websiteComponentType, .subComponentType, and .siteLanguageComponentType. Use the JSON values returned by the helper exactly as-is — do not substitute "typical" values from documentation. Observed reference values across tenants include 10426/10427/10428 and 10429/10428/10430, but the actual values vary per environment and must come from this script's runtime query. The three sibling unified entities each have their own componenttype — site language is NOT included by AddRequiredComponents: true on the website and must be added explicitly. See references/solution-api-patterns.md for the full 3-entity model.
If the script reports the website record is not yet in any solution, stop and inform the user that the site must be deployed (via /power-pages:deploy-site) before it can be solutionized. If subComponentType is absent (no sub-components indexed yet), proceed anyway — you will discover all component IDs in Step 5.2.
Step 5.2 — Discover All Components
Run six discovery queries in parallel:
A. Component type labels (for display names):
GET {envUrl}/api/data/v9.2/GlobalOptionSetDefinitions(Name='powerpagecomponenttype')
Build a typeLabel map: { [Value]: Label.UserLocalizedLabel.Label }. Fall back to the static table in ${PLUGIN_ROOT}/references/solution-api-patterns.md Section 3b if this fails.
B. All Power Pages sub-components for this site:
GET {envUrl}/api/data/v9.2/powerpagecomponents
?$filter=_powerpagesiteid_value eq '{websiteRecordId}'
&$select=powerpagecomponentid,name,powerpagecomponenttype
&$orderby=powerpagecomponenttype
Follow @odata.nextLink pagination. Group by powerpagecomponenttype using typeLabel for display names.
C. Site language records:
GET {envUrl}/api/data/v9.2/powerpagesitelanguages?$filter=_powerpagesiteid_value eq '{websiteRecordId}'&$select=powerpagesitelanguageid,languagecode,displayname
Store all language IDs.
D. Dataverse tables — discover the tables the site actually references, NOT every table sharing the publisher prefix.
Why not publisher prefix: prefix-matching over-counts catastrophically with a shared/default publisher (
new_, env default) — a 6-table site can match 22 unrelated tables — and it also misses the site's real tables when they come from a different prefix (e.g. abp_*template under anedmpublisher). The authoritative signal (SME-confirmed) is the site's table permissions: "If a table is used in the site there will be permissions for it."
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 868
- Forks
- 177
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
setup-solution- Source
- github.com/microsoft/power-platform-skills