diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 207f7a55e..1028cf6e9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ "source": "./plugins/power-pages", "description": "Power Pages development and management plugin for Claude Code and GitHub Copilot", "category": "development", - "version": "2.5.0", + "version": "2.6.0", "license": "MIT", "tags": [ "power platform", diff --git a/marketplace.json b/marketplace.json index 207f7a55e..1028cf6e9 100644 --- a/marketplace.json +++ b/marketplace.json @@ -13,7 +13,7 @@ "source": "./plugins/power-pages", "description": "Power Pages development and management plugin for Claude Code and GitHub Copilot", "category": "development", - "version": "2.5.0", + "version": "2.6.0", "license": "MIT", "tags": [ "power platform", diff --git a/plugins/power-pages/.claude-plugin/plugin.json b/plugins/power-pages/.claude-plugin/plugin.json index f3fa3f687..5fa58e6c5 100644 --- a/plugins/power-pages/.claude-plugin/plugin.json +++ b/plugins/power-pages/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "power-pages", - "version": "2.5.0", + "version": "2.6.0", "description": "Create and deploy Power Pages sites using modern development approaches. Supports code sites (SPAs) with React, Angular, Vue, or Astro. Includes ALM orchestration (plan-alm) with a solution-splitting decision tree, per-solution pipelines, Azure Blob asset advisory, manifest schema v2 for multi-solution deployments, and force-link remediation for cross-host pipeline migrations.", "author": { "name": "Microsoft", diff --git a/plugins/power-pages/.plugin/plugin.json b/plugins/power-pages/.plugin/plugin.json index f3fa3f687..5fa58e6c5 100644 --- a/plugins/power-pages/.plugin/plugin.json +++ b/plugins/power-pages/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "power-pages", - "version": "2.5.0", + "version": "2.6.0", "description": "Create and deploy Power Pages sites using modern development approaches. Supports code sites (SPAs) with React, Angular, Vue, or Astro. Includes ALM orchestration (plan-alm) with a solution-splitting decision tree, per-solution pipelines, Azure Blob asset advisory, manifest schema v2 for multi-solution deployments, and force-link remediation for cross-host pipeline migrations.", "author": { "name": "Microsoft", diff --git a/plugins/power-pages/AGENTS.md b/plugins/power-pages/AGENTS.md index 198394c06..64776b302 100644 --- a/plugins/power-pages/AGENTS.md +++ b/plugins/power-pages/AGENTS.md @@ -197,10 +197,12 @@ Shared lib modules live at `scripts/lib/` and are imported by other scripts via #### ALM Prerequisites & Context -- `scripts/lib/verify-alm-prerequisites.js`: Verifies all prerequisites for ALM skills — PAC CLI installed + authenticated (`pac env who`), Azure CLI installed + logged in, Dataverse API reachable (`WhoAmI`). Args: `--envUrl` (opt, overrides env from PAC CLI), `--require-manifest` (fails if `.solution-manifest.json` not found). Output: `{ envUrl, token, userId, organizationId, tenantId }`. Exit 0 on success, exit 1 on any failure. Used by `setup-solution`, `export-solution`, `import-solution`, `setup-pipeline`, `deploy-pipeline`, `plan-alm`. -- `scripts/lib/detect-project-context.js`: Reads Power Pages project context from the project root. The `siteType` discriminator 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: "data-model"` is the (compat-named) declarative bucket; a future pass may rename it `"declarative"`. Resolves identity in order: (1) `powerpages.config.json` → `siteType: "code"` (code/SPA sites); (2) `.powerpages-site/` → `siteType: "data-model"` (declarative design-studio sites — standard or enhanced data model — which have **no** `powerpages.config.json`). The **authoritative declarative marker is the `.powerpages-site/.portalconfig/` directory** (only declarative sites have it); `website.yml` is the identity source (`id`→`websiteRecordId`, `name`→`siteName`) but is NOT a reliable declarative signal alone because **both** site types carry it. `environmentUrl: null` for declarative sites (no env URL in the local files — callers re-confirm via `pac env who`). Also reads `.solution-manifest.json` and `.datamodel-manifest.json`. Args: `--projectRoot` (opt). Output: `{ projectRoot, siteType, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 only if neither `powerpages.config.json` nor a `.powerpages-site/` (`.portalconfig/`/`website.yml`) marker is found. Note: `findProjectRoot` (in `validation-helpers.js`) likewise treats a `.powerpages-site/` directory as a project-root marker. +- `scripts/lib/verify-alm-prerequisites.js`: Verifies all prerequisites for ALM skills — PAC CLI installed + authenticated (`pac env who`), Azure CLI installed + logged in, Dataverse API reachable (`WhoAmI`). Args: `--envUrl` (opt, overrides env from PAC CLI), `--require-manifest` (fails if `.solution-manifest.json` not found), `--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. **`--expectedEnvUrl` is the recommended guard for any ALM skill that runs against the project's source/dev env**: since `getEnvironmentUrl()` now parses PAC 2.8.x's `Org 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.json` top-level `environmentUrl` / `powerpages.config.json` `environmentUrl` / 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 by `setup-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. The `siteType` discriminator 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 **no** `powerpages.config.json`). The **authoritative declarative marker is the `.powerpages-site/.portalconfig/` directory** (only declarative sites have it); `website.yml` is the identity source (`id`→`websiteRecordId`, `name`→`siteName`) but is NOT a reliable declarative signal alone because **both** site types carry it. `environmentUrl: null` for declarative sites (no env URL in the local files — callers re-confirm via `pac env who`). Also reads `.solution-manifest.json` and `.datamodel-manifest.json`. Args: `--projectRoot` (opt). Output: `{ projectRoot, siteType, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 only if neither `powerpages.config.json` nor a `.powerpages-site/` (`.portalconfig/`/`website.yml`) marker is found. Note: `findProjectRoot` (in `validation-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. Exports `ALM_DIR` (always `docs/alm`), `FILE_NAMES` (frozen object mapping logical key → filename for all 14 ALM artifacts), `almDir(projectRoot) → path`, `almPath(projectRoot, key) → path`, `ensureAlmDir(projectRoot) → path` (mkdir -p idempotent). Every ALM-only state file (5 plan/decision JSONs + 9 `last-*.json` skill-run markers including `last-export.json`) writes under `/docs/alm/`. **Always resolve through this helper** — never inline a raw `docs/alm/...` path in a script. Files intentionally NOT moved here (and not in `FILE_NAMES`): `.solution-manifest.json`, `.datamodel-manifest.json`, `.alm-config.json`, `.alm-deferred`, `deployment-settings.json`. Adding a new ALM marker means adding its key + filename to `FILE_NAMES` first; `almPath` throws on unknown keys to catch typos at call-site. - `scripts/lib/check-alm-plan.js`: Phase 0 gate helper used by every ALM skill to detect (a) whether an ALM plan exists for this project, (b) whether the user has explicitly deferred ALM via the `.alm-deferred` marker, and (c) whether an existing plan is stale (the source solution was modified after the plan was generated). Args: `--projectRoot`, `--envUrl` (opt — required for staleness check), `--token` (opt), `--solutionId` (opt — required for staleness check). Output: `{ exists, deferred, deferral, planPath, htmlPath, stale, staleness: { reason, detail }, generatedAt, planStatus, solution: {...} }`. Without env/solution context the helper does an existence-only check; with them it queries Dataverse for `solutions(solutionId)?$select=modifiedon` and compares against `planData.generatedAt`. Used by `setup-solution`, `setup-pipeline`, `deploy-pipeline`, `export-solution`, `import-solution`, `configure-env-variables`, `ensure-pipelines-host`, `force-link-environment` Phase 0 gates — the "fail closed when no plan" pattern. **PLAN_STATUS lifecycle — promotes `Approved` → `In Execution`:** plan-alm is plan-only and leaves the plan `Approved`; this helper performs the `Approved` → `In Execution` transition (and writes the first heartbeat) the **first time an execution skill's Phase 0 runs** — it is the only thing that sets `In Execution`, so without it the heartbeat/active-chain machinery (multi-hour-deploy `stale-heartbeat` reclassification) 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 terminal `In Execution` → `Completed` transition is owned by `refresh-alm-plan-data.js` (completion evaluator). +- `scripts/lib/set-plan-status.js`: **The single deterministic owner of the creation-time `Draft` / `Approved` write** — the one PLAN_STATUS transition that used to be done by hand-authored `Edit`s in plan-alm Phase 4 (to the HTML spans *and* the JSON), with no helper. Because the badge + `approved-by` / `approval-date` spans are re-derived from `docs/.alm-plan-data.json` on 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, since `check-alm-plan.js` only promotes from `Approved`. This helper writes `PLAN_STATUS` + `PLAN_MODE` + `APPROVED_BY` + `APPROVAL_DATE` **together** (atomic temp+rename) and optionally re-renders (reuses `refresh-alm-plan-data.js → findRendererPath`/`invokeRenderer`). Enforced invariants: only `Draft` / `Approved` are settable here (`In Execution` is owned by `check-alm-plan.js`, `Completed` by `refresh-alm-plan-data.js`); `Approved` **requires** a non-empty `--approver`; `Draft` **clears** the approver fields; a plan already `In Execution` / `Completed` is **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. The `validate-plan-alm.js` consistency 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.json` in the project root; (3) neither → throw `NoSolutionConfiguredError`. **The module NEVER auto-picks from Dataverse** — interactive prompt UX is the caller's responsibility (catch the error, present an `AskUserQuestion` list, re-invoke with `explicit` populated). Callers that need to confirm the solution still exists in Dataverse can pass `verifyExists: true`; the module then enriches the result with `{ solutionId, version, ismanaged }`. Component-creation scripts must require this helper and pass through `--solutionUniqueName` so records land in the user's solution instead of `Default`. #### Solution Splitting Decision Tree (v1.3.0+) @@ -231,6 +233,7 @@ Shared lib modules live at `scripts/lib/` and are imported by other scripts via #### PP Pipelines +- `scripts/lib/list-environments.js`: Enumerates the Dataverse environments the signed-in PAC user can access, as JSON, for `ENV_LIST` pre-fill (plan-alm Phase 1 Step 5, setup-pipeline, ensure-pipelines-host "Other (paste URL)" prompts). **Why it exists:** the skills used to run `pac env list --output json`, which is INVALID on current PAC CLI (verified 2.8.1 — `pac env list` accepts only `--filter` and errors on `--output`), so the JSON pre-fill silently never worked. This helper runs the plain `pac env list` and parses its table (anchored on the env GUID + https URL + unique-name tokens, so display names with spaces survive). `pac admin list --json` was rejected as the source — it's admin-only and tenant-wide, the wrong scope for a per-user pre-fill. Exports `parseEnvList(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 by `environmentUrl` origin. - `scripts/lib/discover-pipelines-host.js`: Discovers the tenant-level default Power Platform Pipelines host environment URL by calling `RetrieveSetting('DefaultCustomPipelinesHostEnvForTenant')` on the dev/source environment. Args: `--envUrl`, `--token`, `--userId`. Output: `{ found, hostEnvUrl }`. Exit 0 (including when not found); exit 1 on error. - `scripts/lib/create-deployment-environment.js`: Creates a `deploymentenvironments` record in the Pipelines host environment using the **unprefixed** field schema (`name`, `environmentid`, `environmenttype`), then polls `validationstatus` until Succeeded (`200000001`) or Failed (`200000002`). Args: `--hostEnvUrl`, `--token`, `--name`, `--bapEnvId`, `--environmentType` (`200000000` Dev / `200000001` Target), `--environmentUrl` (opt, only echoed in output marker). Idempotent: if a record already exists for the same `environmentid`, returns it with `reused: true`. Output: `{ deploymentEnvironmentId, name, bapEnvId, environmentUrl, environmentType, validationStatus, reused }`. - `scripts/lib/create-deployment-pipeline.js`: Creates a `deploymentpipelines` record, associates the source environment via `$ref` (relative path + `@odata.context`), and creates `deploymentstages` records for each target environment. Args: `--hostEnvUrl`, `--token`, `--pipelineName`, `--description`, `--sourceDeploymentEnvironmentId`, `--stagesJson` (JSON array of `{ name, targetDeploymentEnvironmentId, order }`). Output: `{ pipelineId, pipelineName, stages[] }`. diff --git a/plugins/power-pages/references/approval-gates.md b/plugins/power-pages/references/approval-gates.md index 5ada7883c..b63488cfa 100644 --- a/plugins/power-pages/references/approval-gates.md +++ b/plugins/power-pages/references/approval-gates.md @@ -256,14 +256,16 @@ Each section lists every `AskUserQuestion` in that skill. Catalog rows are marke --- -### 6.1 `plan-alm` (15 calls; planner) +### 6.1 `plan-alm` (17 calls; planner) > `plan-alm` is a **planner** — it produces an approved/draft HTML plan and never executes. The execution gates that used to live in Phases 5–8 (deploy-failure, post-deploy activation, manual export/import checkpoint) now belong to the individual ALM skills the user runs afterward; they are catalogued under those skills' sections, not here. | ID | Kind | Category | Phase | Trigger / question | Cancel leaves | |---|---|---|---|---|---| | `plan-alm:1.deferral` | gate | progress | 1 | `.alm-deferred` marker present — *"Continue with deferral / remove and proceed / cancel"* | `deferral-marker` | +| `plan-alm:1.approve-draft` | gate | plan | 1 (0b) | Existing **Draft** plan found — *"Approve this draft now (no re-plan) / re-plan from scratch / cancel"*. Approve writes status via `set-plan-status.js` and exits | nothing | | `plan-alm:1.completeness` | gate | progress | 1 | Completeness check found gaps — *"Sync first / plan with gaps / cancel"* | nothing | +| `plan-alm:1.env-match` | gate | progress | 1 (6b) | `pac env who` env ≠ project's (recorded-URL mismatch or `websiteRecordId` not found in connected env) — *"Switch PAC env & re-run / continue against connected env (degraded) / cancel"*. Only fires on a detected mismatch | nothing | | `plan-alm:2.q1-existing` | gate | plan | 2 (Q1) | `SOLUTION_DONE=true` — *"Use existing solution **{name}**?"* | nothing | | `plan-alm:2.q1-fresh` | gate | plan | 2 (Q1) | `SOLUTION_DONE=false` — *"Include solution setup in plan?"* | nothing | | `plan-alm:2.q1b-split` | gate | plan | 2 (Q1b) | `RECOMMEND_SPLIT=true` — *"Follow recommended {strategy} split?"* | nothing | diff --git a/plugins/power-pages/references/cicd-pipeline-patterns.md b/plugins/power-pages/references/cicd-pipeline-patterns.md index 8562857d7..6425efac6 100644 --- a/plugins/power-pages/references/cicd-pipeline-patterns.md +++ b/plugins/power-pages/references/cicd-pipeline-patterns.md @@ -513,13 +513,13 @@ Accept: application/json Returns `{ "SettingValue": "{BAP-environment-GUID}" }` or empty/null if no default is configured. -Cross-reference the GUID with `pac env list` output to find the host environment URL: +Cross-reference the GUID with the environment list to find the host environment URL: ```bash -pac env list --output json 2>/dev/null +node "${PLUGIN_ROOT}/scripts/lib/list-environments.js" ``` -Match on `EnvironmentId` field. If no match, probe each environment from `pac env list` with: +This emits a JSON array of `{ displayName, environmentId, environmentUrl, uniqueName, active }`. (It parses `pac env list`; the older `pac env list --output json` is invalid on current PAC CLI, which only accepts `--filter` on `env list`.) Match the GUID on the `environmentId` field. If no match, probe each environment from `pac env list` with: ``` GET {envUrl}/api/data/v9.1/deploymentpipelines?$top=0 diff --git a/plugins/power-pages/scripts/check-activation-status.js b/plugins/power-pages/scripts/check-activation-status.js index 1b3b2962c..f37a74434 100644 --- a/plugins/power-pages/scripts/check-activation-status.js +++ b/plugins/power-pages/scripts/check-activation-status.js @@ -27,7 +27,7 @@ function output(obj) { // // Resolution order: // 1. powerpages.config.json (code/SPA sites) — siteName + (optional) websiteRecordId. -// 2. .powerpages-site/website.yml (declarative "data-model" sites — standard or +// 2. .powerpages-site/website.yml (declarative sites — standard or // enhanced data model — which have no powerpages.config.json) — `name` -> siteName, // `id` -> websiteRecordId. // 3. `pac pages list` — ONLY when the GUID is still unknown (e.g. a code site whose diff --git a/plugins/power-pages/scripts/lib/detect-project-context.js b/plugins/power-pages/scripts/lib/detect-project-context.js index 6c4174f41..dfca7cf40 100644 --- a/plugins/power-pages/scripts/lib/detect-project-context.js +++ b/plugins/power-pages/scripts/lib/detect-project-context.js @@ -2,20 +2,22 @@ // Reads Power Pages project context files from the project root. // Locates powerpages.config.json (code/SPA sites) OR a .powerpages-site/ config tree -// (declarative "data-model" sites — Power Pages design-studio sites), plus +// (declarative sites — Power Pages design-studio sites), plus // .solution-manifest.json and .datamodel-manifest.json. // // NOTE on terminology: the discriminator here is the BUILD axis — code/SPA site vs // declarative (design-studio) site — NOT the Dataverse data-model axis. A declarative // site can be on the standard OR the enhanced data model ("EDM"); both download to a -// .powerpages-site/ tree via `pac pages download`. siteType "data-model" names that -// declarative bucket (kept for compatibility with plan-alm); a future pass may rename -// it to "declarative". +// .powerpages-site/ tree via `pac pages download`. siteType "declarative" names that +// bucket. (It was historically labeled "data-model"; that value is now the legacy +// alias. Nothing branches on the literal — it is a diagnostic label the agent reads +// and the estimator echoes — so the rename is safe, and any plan-data written before +// the rename that still carries "data-model" remains equivalent.) // // Site identity resolution order (first match wins): // 1. powerpages.config.json -> siteType "code" (code/SPA sites; has siteName, // websiteRecordId, environmentUrl) -// 2. .powerpages-site/ (.portalconfig/ + website.yml) -> siteType "data-model" +// 2. .powerpages-site/ (.portalconfig/ + website.yml) -> siteType "declarative" // (declarative design-studio sites; standard or // enhanced data model. website.yml carries `id` and // `name` but no environment URL — callers re-confirm @@ -31,7 +33,7 @@ // Output (JSON to stdout): // { // "projectRoot": "...", -// "siteType": "code" | "data-model", +// "siteType": "code" | "declarative", // "declarative" was formerly "data-model" // "siteName": "...", // "websiteRecordId": "...", // "environmentUrl": "..." | null, @@ -146,7 +148,7 @@ function detectProjectContext(options = {}) { }; } - // 2. Declarative ("data-model") site — a Power Pages design-studio site + // 2. Declarative site (siteType "declarative", formerly "data-model") — a Power Pages design-studio site // (`pac pages download`; standard or enhanced data model), as opposed to a // code/SPA site. The authoritative positive marker is the // `.powerpages-site/.portalconfig/` directory (only declarative sites have it). @@ -167,7 +169,7 @@ function detectProjectContext(options = {}) { } return { projectRoot, - siteType: 'data-model', + siteType: 'declarative', siteName: site ? (site.name || null) : null, websiteRecordId: site ? (site.id || null) : null, environmentUrl: null, diff --git a/plugins/power-pages/scripts/lib/estimate-solution-size.js b/plugins/power-pages/scripts/lib/estimate-solution-size.js index 4a29b7cfe..74e45e8ee 100644 --- a/plugins/power-pages/scripts/lib/estimate-solution-size.js +++ b/plugins/power-pages/scripts/lib/estimate-solution-size.js @@ -65,6 +65,7 @@ function parseArgs(argv) { datamodelManifest: null, solutionId: null, projectRoot: null, + siteType: null, }; for (let i = 0; i < args.length; i++) { if (args[i] === '--envUrl' && args[i + 1]) out.envUrl = args[++i]; @@ -75,10 +76,46 @@ function parseArgs(argv) { else if (args[i] === '--datamodelManifest' && args[i + 1]) out.datamodelManifest = args[++i]; else if (args[i] === '--solutionId' && args[i + 1]) out.solutionId = args[++i]; else if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + else if (args[i] === '--siteType' && args[i + 1]) out.siteType = args[++i]; } return out; } +// Resolve the build-axis site type for the estimator's diagnostic `siteType` +// output field. Prefer the caller-supplied value (plan-alm resolves this in +// Phase 1 via detect-project-context.js, the authoritative source), and fall +// back to a lightweight local probe of the same markers detect-project-context.js +// resolves on (also described in the plugin's AGENTS.md "detect-project-context.js" entry): +// - `powerpages.config.json` → code / SPA site +// - `.powerpages-site/.portalconfig/` → declarative design-studio (EDM/standard) site +// Returns the canonical values ('code' | 'declarative') to match +// detect-project-context.js — NOT the old hardcoded 'code-site', which mislabeled +// every declarative/EDM site as a code site. ('declarative' was formerly labeled +// 'data-model'; a caller still passing the legacy 'data-model' is NORMALIZED to +// 'declarative' so the output is always canonical.) Returns 'unknown' when neither +// marker is present (e.g. running outside a project root). +function resolveSiteType(explicitSiteType, projectRoot) { + // Normalize the caller-supplied label. 'data-model' is the legacy alias for + // 'declarative' (back-compat). Only canonical labels are trusted verbatim; + // anything else — notably an unsubstituted "{SITE_TYPE}" template literal an + // agent forwarded without resolving it — is IGNORED in favor of the local + // marker probe, so garbage never lands in the diagnostic output. + if (explicitSiteType === 'data-model') return 'declarative'; + if (explicitSiteType === 'code' || explicitSiteType === 'declarative' || explicitSiteType === 'unknown') { + return explicitSiteType; + } + if (!projectRoot) return 'unknown'; + const fs = require('fs'); + const path = require('path'); + try { + if (fs.existsSync(path.join(projectRoot, 'powerpages.config.json'))) return 'code'; + if (fs.existsSync(path.join(projectRoot, '.powerpages-site', '.portalconfig'))) return 'declarative'; + } catch { + // Filesystem probe is best-effort — a diagnostic label must never be fatal. + } + return 'unknown'; +} + // Page size for paginated OData queries. Dataverse caps `Prefer: odata.maxpagesize` // at 5000 — requesting more is silently downgraded. Using the cap minimizes // roundtrips for large sites. @@ -738,7 +775,7 @@ async function countSolutionMembership(envUrl, solutionId, token, sitePpcIdSet = }; } -async function estimateSolutionSize({ envUrl, websiteRecordId, token, publisherPrefix, siteName, datamodelManifest, solutionId, projectRoot }) { +async function estimateSolutionSize({ envUrl, websiteRecordId, token, publisherPrefix, siteName, datamodelManifest, solutionId, projectRoot, siteType }) { if (!envUrl || !websiteRecordId) { throw new Error('--envUrl and --websiteRecordId are required'); } @@ -1122,7 +1159,10 @@ async function estimateSolutionSize({ envUrl, websiteRecordId, token, publisherP // scope so reviewers can spot the divergence. envVarCountTenantWide, mediaRatio: Math.round(webMeasure.mediaRatio * 100) / 100, - siteType: 'code-site', + // Build-axis label: 'code' | 'declarative' | 'unknown' (was hardcoded + // 'code-site', which mislabeled every declarative/EDM site). Prefers the + // caller-supplied --siteType (plan-alm Phase 1), falls back to a local marker probe. + siteType: resolveSiteType(siteType, projectRoot), tables: tables.map((t) => ({ logicalName: t.logicalName, attributeCount: t.attributeCount || 0 })), // Dependency edges among the scoped tables ([a,b], lowercased, a prints JSON array to stdout +// +// Output (JSON array; empty [] when PAC is unauthenticated / the command fails — +// the pre-fill is best-effort and callers degrade gracefully to manual entry): +// [ { "displayName": "...", "environmentId": "...", "environmentUrl": "https://…", +// "uniqueName": "...", "active": true|false }, ... ] +// +// Exit 0 always (callers parse stdout; [] means "no pre-fill available"). + +const { execSync } = require('child_process'); + +// Parse the plain `pac env list` table. Pure + exported for unit testing. +// Example real output (PAC 2.8.1) — note the header row, the "Connected as" banner +// line, and that the active env is flagged with `*` in the leading "Active" column: +// +// Connected as admin@contoso.onmicrosoft.com +// Active Display Name Environment ID Environment URL Unique Name +// * Contoso Dev d664a1f5-5c5b-efbf-9cc9-c1923c437109 https://contosodev.crm.dynamics.com/ unq78bd16d6e4baf01189f56045bd003 +// Contoso Prod e8ccb697-db78-e2d6-b721-ef23eedbc302 https://contosoprod.crm4.dynamics.com/ unqe4574a3ea1bff01195c56045bd03c +// +// Display names contain spaces and variable padding, so we anchor on the three +// unambiguous tokens that always appear in order — the 36-char environment GUID, +// the https URL, and the trailing unique name — and treat everything before the +// GUID as `[activeMarker] + displayName`. +function parseEnvList(stdout) { + if (!stdout || typeof stdout !== 'string') return []; + const rows = []; + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.replace(/\s+$/, ''); + if (!line.trim()) continue; + // Skip the "Connected as ..." banner and the column header row. + if (/^Connected as\b/i.test(line.trim())) continue; + if (/^Active\s+Display Name\b/i.test(line.trim())) continue; + + // prefix = (optional `*` active marker) + display name; then GUID, URL, uniqueName. + const m = line.match( + /^(.*?)\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+(https:\/\/\S+)\s+(\S+)\s*$/i, + ); + if (!m) continue; + const prefix = m[1]; + // The active env is flagged with a leading `*` in the "Active" column. + const active = /^\s*\*/.test(prefix); + const displayName = prefix.replace(/^\s*\*?\s*/, '').trim(); + rows.push({ + displayName, + environmentId: m[2], + environmentUrl: m[3].replace(/\/+$/, ''), + uniqueName: m[4], + active, + }); + } + return rows; +} + +function listEnvironments() { + let stdout = ''; + try { + stdout = execSync('pac env list', { encoding: 'utf8', timeout: 20000 }); + } catch (e) { + // Best-effort: an unauthenticated / failing PAC CLI yields no pre-fill, not an + // error — callers (plan-alm Phase 1, setup-pipeline) fall back to manual entry. + // `pac` writes its table to stdout even on some non-zero exits, so try to parse + // whatever was captured before giving up. + stdout = (e && (e.stdout || '')) || ''; + } + return parseEnvList(stdout); +} + +if (require.main === module) { + // Never throw to the caller — emit [] on any failure so the consumer always + // receives parseable JSON. + let result = []; + try { result = listEnvironments(); } catch { result = []; } + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(0); +} + +module.exports = { parseEnvList, listEnvironments }; diff --git a/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js b/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js index 27d912fe8..49fdf36b0 100644 --- a/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js +++ b/plugins/power-pages/scripts/lib/refresh-alm-plan-data.js @@ -1212,4 +1212,13 @@ module.exports = { STEP_TO_SKILL, MARKER_TO_PHASE, PHASES, + // Exported so other plan-data writers (e.g. set-plan-status.js) reuse the SAME + // renderer-invocation instead of re-implementing the execFileSync call — keeps + // the "where is render-alm-plan.js / how is it invoked" knowledge in one place. + findRendererPath, + invokeRenderer, + // Single source of the "Deploy to {label}" → "{label}" normalization so every + // stage consumer (verify-env-var-values.js included) matches stage labels the + // same way — prevents the mismatch recurring in one un-normalized code path. + normalizeStageLabel, }; diff --git a/plugins/power-pages/scripts/lib/set-plan-status.js b/plugins/power-pages/scripts/lib/set-plan-status.js new file mode 100644 index 000000000..8096dd9e9 --- /dev/null +++ b/plugins/power-pages/scripts/lib/set-plan-status.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node +'use strict'; + +// set-plan-status.js — the single deterministic owner of the CREATION-TIME ALM +// plan status write (`Draft` / `Approved`). +// +// Background / why this exists: +// The plan-status badge and the "Approved by" stamp in docs/alm-plan.html are +// BOTH re-derived from docs/.alm-plan-data.json every time the plan is rendered +// (render-alm-plan.js reads PLAN_STATUS / APPROVED_BY / APPROVAL_DATE). Every +// OTHER status transition is owned by a deterministic helper: +// - Approved -> In Execution : check-alm-plan.js (first execution skill) +// - In Execution -> Completed: refresh-alm-plan-data.js (evaluatePlanCompletion) +// ...but the Draft/Approved write was historically done by HAND-AUTHORED Edits +// in plan-alm Phase 4 — to two places (the HTML spans AND the JSON), with no +// helper. That produced two real bugs: +// 1. Editing the HTML span is non-durable — the next refresh re-derives the +// badge from plan-data and reverts it if plan-data wasn't also updated. +// 2. A partial write (APPROVED_BY set in plan-data but PLAN_STATUS left at +// "Draft") leaves the plan shown-as-approved but stuck on Draft forever, +// because check-alm-plan.js only promotes from "Approved". +// This helper makes plan-data the single source of truth and writes all four +// fields together (atomically), so neither bug can recur. Phase 4 (and the +// in-place Draft->Approved fast-path) call this instead of hand-editing. +// +// Usage: +// node set-plan-status.js --projectRoot --status Approved --approver "Jane Doe" [--render] +// node set-plan-status.js --projectRoot --status Draft [--render] +// node set-plan-status.js --projectRoot --status Draft --force (re-draft a running plan) +// +// Output (JSON to stdout): +// { "ok": true, "projectRoot": "...", "previousStatus": "Draft", "status": "Approved", +// "mode": "approved", "approver": "Jane Doe", "approvalDate": "2026-…Z", "rendered": true } +// +// Exit 0 on success, exit 1 on any validation error (missing plan, bad status, +// Approved-without-approver, or a refused regression of a live plan). + +const fs = require('fs'); +const { planDataPath, planHtmlPath } = require('./alm-paths'); +// Reuse the SAME renderer-invocation as the post-run refresh, rather than +// re-implementing the execFileSync call. Requiring this module is side-effect +// free (its CLI body is guarded by `require.main === module`). +const { findRendererPath, invokeRenderer } = require('./refresh-alm-plan-data'); + +// The two statuses this helper owns. In Execution / Completed are owned by +// check-alm-plan.js and refresh-alm-plan-data.js respectively and must NOT be +// settable here — that would let a caller fabricate lifecycle state. +const CREATION_STATUSES = new Set(['Draft', 'Approved']); +// A plan in one of these states is past the creation/approval stage; re-writing +// it back to Draft/Approved would erase live execution state, so it is refused +// unless --force is passed. +const LIVE_STATUSES = new Set(['In Execution', 'Completed']); + +/** + * Atomically set the creation-time plan status in docs/.alm-plan-data.json. + * + * @param {object} opts + * @param {string} opts.projectRoot + * @param {'Draft'|'Approved'} opts.status + * @param {string} [opts.approver] required (non-empty) when status === 'Approved' + * @param {string} [opts.approvalDate] ISO string; defaults to now when status === 'Approved' + * @param {boolean} [opts.force] allow overwriting an In Execution / Completed plan + * @param {boolean} [opts.render] re-render docs/alm-plan.html after writing + * @param {string} [opts.rendererPath] override the renderer path (tests) + * @param {() => string} [opts.makeNow] injectable clock (tests); returns an ISO string + * @returns {{ ok: true, projectRoot, previousStatus, status, mode, approver, approvalDate, rendered }} + */ +function setPlanStatus(opts) { + const { + projectRoot, + status, + approver, + approvalDate, + force = false, + render = false, + rendererPath = null, + makeNow = () => new Date().toISOString(), + } = opts || {}; + + if (!projectRoot) throw new Error('--projectRoot is required'); + if (!CREATION_STATUSES.has(status)) { + throw new Error( + `--status must be one of: ${[...CREATION_STATUSES].join(', ')} ` + + `(got ${JSON.stringify(status)}). "In Execution"/"Completed" are owned by ` + + 'check-alm-plan.js / refresh-alm-plan-data.js, not this helper.', + ); + } + + const dataPath = planDataPath(projectRoot); + if (!fs.existsSync(dataPath)) { + throw new Error(`No ALM plan found at ${dataPath}. Run /power-pages:plan-alm first.`); + } + + let planData; + try { + planData = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + } catch (e) { + throw new Error(`Could not parse ${dataPath}: ${e.message}`); + } + + const previousStatus = planData.PLAN_STATUS || null; + + // Never silently erase live execution state. A plan that has started executing + // (In Execution) or finished (Completed) should not be quietly reset to a + // creation-time status — that would drop heartbeat/step state and confuse the + // downstream gates. Require an explicit --force to override. + if (LIVE_STATUSES.has(previousStatus) && !force) { + throw new Error( + `Refusing to set status to "${status}": the plan is already "${previousStatus}". ` + + 'Pass --force to override (this discards live execution state).', + ); + } + + const approverTrimmed = (approver || '').trim(); + let mode; + let finalApprover; + let finalApprovalDate; + + if (status === 'Approved') { + // Approved without an approver is exactly the half-written state the + // consistency guard flags — refuse to create it here. + if (!approverTrimmed) { + throw new Error('--approver is required (and must be non-empty) when --status is Approved.'); + } + mode = 'approved'; + finalApprover = approverTrimmed; + finalApprovalDate = (approvalDate && approvalDate.trim()) || makeNow(); + } else { + // Draft: per plan-alm Phase 4 option 2, a draft does NOT carry an approver. + // Clear any stale approver fields so we never leave "Draft + approver" behind. + mode = 'draft'; + finalApprover = ''; + finalApprovalDate = ''; + } + + planData.PLAN_STATUS = status; + planData.PLAN_MODE = mode; + planData.APPROVED_BY = finalApprover; + planData.APPROVAL_DATE = finalApprovalDate; + + // Stage the new plan-data to a temp file (don't commit it yet). Atomicity + // matters two ways: (1) a crash mid-write can't truncate the plan file every + // downstream Phase 0 gate depends on; (2) when --render is requested, a renderer + // failure must leave BOTH docs/.alm-plan-data.json AND docs/alm-plan.html + // unchanged — otherwise the status write lands, the HTML stays stale, the CLI + // exits non-zero, and a caller that commits docs/ ships a new JSON beside a + // stale HTML. So we render FROM the staged temp into a temp HTML first, and only + // swap both into place after a clean render. Without --render, just commit the JSON. + const dataTmp = dataPath + '.tmp'; + fs.writeFileSync(dataTmp, JSON.stringify(planData, null, 2)); + + let rendered = false; + if (render) { + const htmlPath = planHtmlPath(projectRoot); + const htmlTmp = htmlPath + '.tmp'; + try { + invokeRenderer(findRendererPath(rendererPath), dataTmp, htmlTmp); + } catch (e) { + // Render failed — discard both staged files so nothing changed on disk. + try { fs.unlinkSync(dataTmp); } catch {} + try { fs.unlinkSync(htmlTmp); } catch {} + throw e; + } + // Both products are ready: commit the HTML then the JSON. A crash BETWEEN these + // two same-dir renames (vanishingly unlikely in one process) would leave the new + // HTML in place with the JSON still old — "HTML ahead of JSON". That's benign and + // self-healing: the next render re-derives the HTML from whatever the JSON says, + // and no file is ever torn (each rename is atomic). It is the inverse of the + // original pre-atomic bug (new JSON + stale HTML), and harmless in the same way. + fs.renameSync(htmlTmp, htmlPath); + rendered = true; + } + // Commit the JSON. If this final rename ever fails (e.g. a transient lock on the + // plan file), unlink the staged temp so we don't leave an orphaned + // `.alm-plan-data.json.tmp` behind, then rethrow so the caller sees the failure. + try { + fs.renameSync(dataTmp, dataPath); + } catch (e) { + try { fs.unlinkSync(dataTmp); } catch {} + throw e; + } + + return { + ok: true, + projectRoot, + previousStatus, + status, + mode, + approver: finalApprover, + approvalDate: finalApprovalDate, + rendered, + }; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const out = { + projectRoot: null, status: null, approver: null, approvalDate: null, + force: false, render: false, rendererPath: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i]; + else if (args[i] === '--status' && args[i + 1]) out.status = args[++i]; + else if (args[i] === '--approver' && args[i + 1]) out.approver = args[++i]; + else if (args[i] === '--approvalDate' && args[i + 1]) out.approvalDate = args[++i]; + else if (args[i] === '--force') out.force = true; + else if (args[i] === '--render') out.render = true; + else if (args[i] === '--rendererPath' && args[i + 1]) out.rendererPath = args[++i]; + } + return out; +} + +if (require.main === module) { + try { + const result = setPlanStatus(parseArgs(process.argv)); + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(0); + } catch (err) { + process.stderr.write(`set-plan-status: ${err.message}\n`); + process.exit(1); + } +} + +module.exports = { setPlanStatus, parseArgs, CREATION_STATUSES, LIVE_STATUSES }; diff --git a/plugins/power-pages/scripts/lib/validation-helpers.js b/plugins/power-pages/scripts/lib/validation-helpers.js index b9a097ee5..059b6b8b8 100644 --- a/plugins/power-pages/scripts/lib/validation-helpers.js +++ b/plugins/power-pages/scripts/lib/validation-helpers.js @@ -93,7 +93,7 @@ function findPath(dir, target) { * * A project root is marked by EITHER: * - `powerpages.config.json` — code/SPA sites (`pac pages download-code-site`), OR - * - a `.powerpages-site/` directory — declarative ("data-model") design-studio sites + * - a `.powerpages-site/` directory — declarative design-studio sites * (`pac pages download`; standard or enhanced data model). These have NO * `powerpages.config.json`. * @@ -160,11 +160,25 @@ function getAuthToken(resourceUrl) { * Gets the environment URL from `pac env who`. * @returns {string|null} Environment URL, or null */ +// Pure parser (exported for unit testing — getEnvironmentUrl() shells out, so the +// regex itself is tested here against raw banner text rather than through execSync). +// PAC CLI labels the environment URL differently across versions / commands: +// `pac env who` on 2.8.x prints it under "Org URL:" (inside "Organization +// Information"); older/other builds emit "Environment URL:". Match EITHER — with +// only the "Environment URL:" form this returned null on 2.8.x and every caller +// relying on the pac-env-who fallback (verify-alm-prerequisites when --envUrl is +// omitted, the datamodel / solution / permissions validators) silently failed. +// Example 2.8.1 line: ` Org URL: https://org4a2942d9.crm17.dynamics.com/` +function parseEnvironmentUrl(whoOutput) { + if (!whoOutput) return null; + const match = whoOutput.match(/(?:Org URL|Environment URL):\s*(https:\/\/[^\s]+)/i); + return match ? match[1].replace(/\/+$/, '') : null; +} + function getEnvironmentUrl() { try { const output = execSync('pac env who', { encoding: 'utf8', timeout: 15000 }); - const match = output.match(/Environment URL:\s*(https:\/\/[^\s]+)/i); - return match ? match[1].replace(/\/+$/, '') : null; + return parseEnvironmentUrl(output); } catch { return null; } @@ -326,6 +340,7 @@ module.exports = { odataGet, odataGetAll, getEnvironmentUrl, + parseEnvironmentUrl, getPacAuthInfo, CLOUD_TO_API, CLOUD_TO_SITE_DOMAIN, diff --git a/plugins/power-pages/scripts/lib/verify-alm-prerequisites.js b/plugins/power-pages/scripts/lib/verify-alm-prerequisites.js index 60a60cd1f..bbdfb82a5 100644 --- a/plugins/power-pages/scripts/lib/verify-alm-prerequisites.js +++ b/plugins/power-pages/scripts/lib/verify-alm-prerequisites.js @@ -5,11 +5,14 @@ // 2. Azure CLI is installed and logged in (az account get-access-token) // 3. Dataverse API is reachable (WhoAmI) // -// Usage: node verify-alm-prerequisites.js [--envUrl ] [--require-manifest] +// Usage: node verify-alm-prerequisites.js [--envUrl ] [--require-manifest] [--expectedEnvUrl ] // // Options: // --envUrl Override environment URL (default: read from pac env who) // --require-manifest Fail if .solution-manifest.json is not found in project root +// --expectedEnvUrl Assert the resolved env matches this origin; HARD-STOP on +// mismatch (guards against an ambient PAC context drifting to +// the wrong environment). Compared origin-only. No-op if unset. // // Output (JSON to stdout): // { "envUrl": "...", "token": "...", "userId": "...", "organizationId": "...", "tenantId": "..." } @@ -28,16 +31,54 @@ function parseArgs(argv) { const args = argv.slice(2); let envUrl = null; let requireManifest = false; + let expectedEnvUrl = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--envUrl' && args[i + 1]) envUrl = args[++i]; else if (args[i] === '--require-manifest') requireManifest = true; + else if (args[i] === '--expectedEnvUrl' && args[i + 1]) expectedEnvUrl = args[++i]; } - return { envUrl, requireManifest }; + return { envUrl, requireManifest, expectedEnvUrl }; } -async function verifyAlmPrerequisites({ envUrl, requireManifest } = {}) { +// Normalize a Dataverse env reference to its origin (scheme+host), lowercased. +// Tolerates a missing scheme (`org.crm.dynamics.com` → `https://org.crm.dynamics.com`) +// since a hand-authored manifest may omit it. Returns null when the value is empty +// or not a parseable host — so the caller can treat "can't compare" distinctly from +// "definitely different" and avoid a false mismatch on garbage input. +function envOrigin(u) { + const s = String(u || '').trim(); + if (!s) return null; + // Try as-is, then with an https:// prefix (covers a bare host like + // `org.crm.dynamics.com`). The WHATWG URL parser is lenient and will happily + // accept `https://{CONFIGURED_ENV_URL}` as a "host", so after parsing we ALSO + // require a plausible DNS hostname (dot-separated alnum/hyphen labels). That + // rejects an unsubstituted `{PLACEHOLDER}`, `null`, or other junk (→ null) so it + // can never be compared as if it were a real environment. + for (const candidate of [s, 'https://' + s.replace(/^\/+/, '')]) { + let parsed; + try { parsed = new URL(candidate); } catch { continue; } + const host = parsed.hostname.toLowerCase(); + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) continue; + return parsed.origin.toLowerCase(); + } + return null; +} + +// Compare two env references by origin only — path/query/trailing-slash/case ignored. +// Returns true (same), false (definitely different), or null (indeterminate: one side +// isn't a parseable env URL). Callers must hard-stop ONLY on an explicit `false`, never +// on null, so a missing/placeholder/garbage value disables the assertion instead of +// blocking a legitimate run. +function sameEnvOrigin(a, b) { + const oa = envOrigin(a); + const ob = envOrigin(b); + if (oa === null || ob === null) return null; + return oa === ob; +} + +async function verifyAlmPrerequisites({ envUrl, requireManifest, expectedEnvUrl } = {}) { // Step 1: PAC CLI check let resolvedEnvUrl = envUrl; if (!resolvedEnvUrl) { @@ -50,6 +91,30 @@ async function verifyAlmPrerequisites({ envUrl, requireManifest } = {}) { } resolvedEnvUrl = resolvedEnvUrl.replace(/\/+$/, ''); + // Step 1b: Environment-match assertion (opt-in via --expectedEnvUrl). When the + // env is resolved from the ambient PAC context (no explicit --envUrl), it is NOT + // guaranteed to be the project's environment — and since getEnvironmentUrl() now + // parses PAC 2.8.x's "Org URL:" successfully, a DRIFTED PAC context resolves and + // proceeds SILENTLY instead of failing loudly the way the old parse-miss did + // (which had been an accidental safety net). A caller that knows the project's + // env (from .solution-manifest.json / powerpages.config.json / the approved plan) + // passes it here; a mismatch HARD-STOPS before any token acquisition or write, so + // an ALM operation can never silently target the wrong environment (e.g. PROD). + // HARD-STOP only on a DEFINITE mismatch (sameEnvOrigin === false). A null result + // means expectedEnvUrl wasn't a parseable env URL (empty, an unsubstituted + // `{PLACEHOLDER}`, junk) — in that case skip the assertion rather than block a + // legitimate run on bad input; the SKILL.md guidance is to omit the flag entirely + // when no env URL is recorded. + if (expectedEnvUrl && sameEnvOrigin(resolvedEnvUrl, expectedEnvUrl) === false) { + throw new Error( + `Environment mismatch: PAC CLI is connected to ${resolvedEnvUrl} but this project targets ` + + `${expectedEnvUrl.replace(/\/+$/, '')}. Run \`pac env select --environment ${expectedEnvUrl.replace(/\/+$/, '')}\` ` + + '(or `pac auth select` to the right profile) and retry. If `pac env who` keeps reverting to a ' + + 'different environment, an external process is changing the active env — resolve that before ' + + 'running ALM skills, or pass --envUrl to pin this run explicitly.' + ); + } + // Step 2: Azure CLI token const token = helpers.getAuthToken(resolvedEnvUrl); if (!token) { @@ -109,9 +174,9 @@ async function verifyAlmPrerequisites({ envUrl, requireManifest } = {}) { // CLI entry point if (require.main === module) { - const { envUrl, requireManifest } = parseArgs(process.argv); + const { envUrl, requireManifest, expectedEnvUrl } = parseArgs(process.argv); - verifyAlmPrerequisites({ envUrl, requireManifest }) + verifyAlmPrerequisites({ envUrl, requireManifest, expectedEnvUrl }) .then((result) => { console.log(JSON.stringify(result)); process.exit(0); @@ -122,4 +187,4 @@ if (require.main === module) { }); } -module.exports = { verifyAlmPrerequisites }; +module.exports = { verifyAlmPrerequisites, parseArgs, sameEnvOrigin, envOrigin }; diff --git a/plugins/power-pages/scripts/lib/verify-env-var-values.js b/plugins/power-pages/scripts/lib/verify-env-var-values.js index d597ff684..7d1e8bf06 100644 --- a/plugins/power-pages/scripts/lib/verify-env-var-values.js +++ b/plugins/power-pages/scripts/lib/verify-env-var-values.js @@ -64,6 +64,14 @@ const fs = require('fs'); const helpers = require('./validation-helpers'); const { getAuthToken } = helpers; +// Reuse the single "Deploy to {label}" → "{label}" normalizer so this helper +// matches the caller's --stageLabel against deployment-settings.json stage keys +// the SAME way the rest of the ALM stage consumers do. Without it, deploy-pipeline +// passing `--stageLabel "Deploy to Staging"` (the pipeline stage name) failed to +// match a settings file keyed by "Staging" → readSchemaNamesFromSettings returned +// [] → the whole verify reported total:0 (a silent no-op that gave false +// reassurance the overrides had landed). +const { normalizeStageLabel } = require('./refresh-alm-plan-data'); function parseArgs(argv) { const args = argv.slice(2); @@ -125,7 +133,9 @@ function readSettingsFile(filePath, stageLabel, options = {}) { throw new Error(`--settingsFile ${filePath} is not valid JSON: ${err.message}`); } - const lowerLabel = stageLabel ? stageLabel.toLowerCase() : null; + // Normalize the requested label ("Deploy to Staging" → "staging") so it lines up + // with however the settings file names its stages — see the require comment above. + const lowerLabel = stageLabel ? normalizeStageLabel(stageLabel).toLowerCase() : null; // Shape 2: per-stage array (`Stages: []`) if (Array.isArray(parsed.Stages)) { @@ -145,7 +155,7 @@ function readSettingsFile(filePath, stageLabel, options = {}) { return preserveAllStages ? all : dedupeBySchemaName(all); } const stage = parsed.Stages.find( - (s) => (s.Name || '').toLowerCase() === lowerLabel + (s) => normalizeStageLabel(s.Name || '').toLowerCase() === lowerLabel ); if (!stage || !Array.isArray(stage.EnvironmentVariables)) return []; return stage.EnvironmentVariables.map((ev) => ({ @@ -184,9 +194,9 @@ function readSettingsFile(filePath, stageLabel, options = {}) { } return preserveAllStages ? all : dedupeBySchemaName(all); } - // Case-insensitive key match + // Case-insensitive key match (normalized so "Deploy to Staging" ↔ "Staging") const matchKey = Object.keys(stagesObj).find( - (k) => k.toLowerCase() === lowerLabel + (k) => normalizeStageLabel(k).toLowerCase() === lowerLabel ); if (!matchKey) return []; const stage = stagesObj[matchKey]; diff --git a/plugins/power-pages/scripts/tests/check-alm-plan.test.js b/plugins/power-pages/scripts/tests/check-alm-plan.test.js index 4c7f6e052..c9598afc1 100644 --- a/plugins/power-pages/scripts/tests/check-alm-plan.test.js +++ b/plugins/power-pages/scripts/tests/check-alm-plan.test.js @@ -593,6 +593,7 @@ test('Approved + stale source solution: stale gate is NOT masked by the promotio } }); + test('does NOT promote Approved when writeHeartbeat is false (read-only callers: plan-alm, audits, tests)', async () => { const dir = tempProject({ SITE_NAME: 'T', PLAN_STATUS: 'Approved' }); try { diff --git a/plugins/power-pages/scripts/tests/compute-split-plan.test.js b/plugins/power-pages/scripts/tests/compute-split-plan.test.js index 9000e4486..0ebfefdfa 100644 --- a/plugins/power-pages/scripts/tests/compute-split-plan.test.js +++ b/plugins/power-pages/scripts/tests/compute-split-plan.test.js @@ -39,7 +39,7 @@ function baseEstimate(overrides = {}) { botCount: 0, envVarCount: 5, mediaRatio: 0.3, - siteType: 'code-site', + siteType: 'code', tables: [], ...overrides, }; diff --git a/plugins/power-pages/scripts/tests/detect-project-context.test.js b/plugins/power-pages/scripts/tests/detect-project-context.test.js index 2a682cb36..e7b3ce132 100644 --- a/plugins/power-pages/scripts/tests/detect-project-context.test.js +++ b/plugins/power-pages/scripts/tests/detect-project-context.test.js @@ -46,10 +46,10 @@ test('detectProjectContext: declarative (data-model) site resolves identity from ); const result = detectProjectContext({ projectRoot }); - assert.equal(result.siteType, 'data-model'); + assert.equal(result.siteType, 'declarative'); assert.equal(result.websiteRecordId, '2ecc32f6-8665-f111-a826-000d3a5a7777'); assert.equal(result.siteName, 'Application processing EDM site - permitapplication-elyyn'); - // Data-model sites carry no environment URL locally — callers re-confirm via `pac env who`. + // Declarative sites carry no environment URL locally — callers re-confirm via `pac env who`. assert.equal(result.environmentUrl, null); }); @@ -60,7 +60,7 @@ test('detectProjectContext: .powerpages-site/.portalconfig/ is the positive decl writeProjectFile(projectRoot, '.powerpages-site/.portalconfig/manifest.yml', 'foo: bar\n'); const result = detectProjectContext({ projectRoot }); - assert.equal(result.siteType, 'data-model', '.portalconfig/ marks a declarative site'); + assert.equal(result.siteType, 'declarative', '.portalconfig/ marks a declarative site'); assert.equal(result.siteName, null); assert.equal(result.websiteRecordId, null); assert.equal(result.environmentUrl, null); diff --git a/plugins/power-pages/scripts/tests/estimate-solution-size.test.js b/plugins/power-pages/scripts/tests/estimate-solution-size.test.js index 78055b750..ff022d985 100644 --- a/plugins/power-pages/scripts/tests/estimate-solution-size.test.js +++ b/plugins/power-pages/scripts/tests/estimate-solution-size.test.js @@ -789,3 +789,64 @@ test('estimateSolutionSize tableCountScope is "unavailable" with no local signal assert.equal(result.tableCountScope, 'unavailable'); assert.deepEqual(result.tableRelationships, []); }); + +// --- resolveSiteType: correct build-axis label (was hardcoded 'code-site') ----- + +test('resolveSiteType prefers the explicit caller value (plan-alm Phase 1 detection)', () => { + const { resolveSiteType } = require('../lib/estimate-solution-size'); + assert.equal(resolveSiteType('declarative', '/whatever'), 'declarative'); + assert.equal(resolveSiteType('code', null), 'code'); +}); + +test('resolveSiteType normalizes the legacy "data-model" alias to "declarative"', () => { + const { resolveSiteType } = require('../lib/estimate-solution-size'); + assert.equal(resolveSiteType('data-model', '/whatever'), 'declarative'); +}); + +test('resolveSiteType ignores a non-canonical value (e.g. unsubstituted "{SITE_TYPE}") and probes instead', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { resolveSiteType } = require('../lib/estimate-solution-size'); + const edmRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'est-stype-lit-')); + try { + fs.mkdirSync(path.join(edmRoot, '.powerpages-site', '.portalconfig'), { recursive: true }); + // Garbage label must NOT pass through; the marker probe wins. + assert.equal(resolveSiteType('{SITE_TYPE}', edmRoot), 'declarative'); + // No markers + garbage label → 'unknown', never the garbage. + assert.equal(resolveSiteType('{SITE_TYPE}', null), 'unknown'); + } finally { + fs.rmSync(edmRoot, { recursive: true, force: true }); + } +}); + +test('resolveSiteType falls back to local markers: powerpages.config.json => code, .portalconfig => declarative', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { resolveSiteType } = require('../lib/estimate-solution-size'); + + const codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'est-stype-code-')); + const edmRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'est-stype-edm-')); + const bareRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'est-stype-bare-')); + try { + fs.writeFileSync(path.join(codeRoot, 'powerpages.config.json'), '{}'); + assert.equal(resolveSiteType(null, codeRoot), 'code'); + + fs.mkdirSync(path.join(edmRoot, '.powerpages-site', '.portalconfig'), { recursive: true }); + assert.equal(resolveSiteType(null, edmRoot), 'declarative', 'EDM/declarative site must NOT be mislabeled code'); + + // No markers and no projectRoot → 'unknown', never a wrong guess. + assert.equal(resolveSiteType(null, bareRoot), 'unknown'); + assert.equal(resolveSiteType(null, null), 'unknown'); + } finally { + for (const d of [codeRoot, edmRoot, bareRoot]) fs.rmSync(d, { recursive: true, force: true }); + } +}); + +test('parseArgs captures --siteType', () => { + const { parseArgs } = require('../lib/estimate-solution-size'); + const a = parseArgs(['node', 'x', '--siteType', 'declarative', '--envUrl', 'https://x']); + assert.equal(a.siteType, 'declarative'); + assert.equal(parseArgs(['node', 'x']).siteType, null); +}); diff --git a/plugins/power-pages/scripts/tests/list-environments.test.js b/plugins/power-pages/scripts/tests/list-environments.test.js new file mode 100644 index 000000000..25ec12821 --- /dev/null +++ b/plugins/power-pages/scripts/tests/list-environments.test.js @@ -0,0 +1,54 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseEnvList } = require('../lib/list-environments'); + +// Real `pac env list` table shape on PAC 2.8.1 (the command that replaced the +// invalid `pac env list --output json`). Header row + "Connected as" banner must +// be skipped; the active env carries a leading `*`. +const SAMPLE = [ + 'Connected as admin@contoso.onmicrosoft.com', + 'Active Display Name Environment ID Environment URL Unique Name', + ' 1841 Community V2 Fresh d664a1f5-5c5b-efbf-9cc9-c1923c437109 https://1841communityv2fresh.crm.dynamics.com/ unq78bd16d6e4baf01189f56045bd003', + '* Contoso Dev e8ccb697-db78-e2d6-b721-ef23eedbc302 https://contosodev.crm4.dynamics.com/ unqe4574a3ea1bff01195c56045bd03c', + ' 281025 a33797f3-ca8b-e81b-97f9-01dec883d806 https://org9cf0ed45.crm.dynamics.com/ unqb66c441afcb3f01195c56045bd021', +].join('\n'); + +test('parseEnvList extracts each env row with display name, GUID, URL (trailing slash stripped), unique name', () => { + const rows = parseEnvList(SAMPLE); + assert.equal(rows.length, 3, 'header + banner lines must be skipped, 3 data rows kept'); + + assert.deepEqual(rows[0], { + displayName: '1841 Community V2 Fresh', + environmentId: 'd664a1f5-5c5b-efbf-9cc9-c1923c437109', + environmentUrl: 'https://1841communityv2fresh.crm.dynamics.com', // trailing / stripped + uniqueName: 'unq78bd16d6e4baf01189f56045bd003', + active: false, + }); + + // Display names with spaces are preserved; the active `*` marker is parsed, not + // leaked into the display name. + assert.equal(rows[1].displayName, 'Contoso Dev'); + assert.equal(rows[1].active, true); + assert.equal(rows[1].environmentUrl, 'https://contosodev.crm4.dynamics.com'); + + // Numeric-leading display names are fine (anchored on the GUID, not the name). + assert.equal(rows[2].displayName, '281025'); + assert.equal(rows[2].active, false); +}); + +test('parseEnvList returns [] for empty / banner-only / malformed input', () => { + assert.deepEqual(parseEnvList(''), []); + assert.deepEqual(parseEnvList(null), []); + assert.deepEqual(parseEnvList('Connected as x@y.com\nActive Display Name Environment ID Environment URL Unique Name'), []); + // A pac error banner with no table rows. + assert.deepEqual(parseEnvList('Error: An unknown argument --output was passed.'), []); +}); + +test('parseEnvList ignores rows without all three anchor tokens (GUID + URL + uniqueName)', () => { + // A wrapped/partial line missing the URL must not produce a half-populated row. + const partial = 'Connected as a@b.com\n Half Row d664a1f5-5c5b-efbf-9cc9-c1923c437109 unqonly'; + assert.deepEqual(parseEnvList(partial), []); +}); diff --git a/plugins/power-pages/scripts/tests/set-plan-status.test.js b/plugins/power-pages/scripts/tests/set-plan-status.test.js new file mode 100644 index 000000000..9baf7bec2 --- /dev/null +++ b/plugins/power-pages/scripts/tests/set-plan-status.test.js @@ -0,0 +1,165 @@ +'use strict'; + +// Tests for set-plan-status.js — the deterministic Draft/Approved writer that +// replaces plan-alm Phase 4's hand-authored Edits. The key invariants: +// - Approved writes all four fields together (no partial "approver but Draft"). +// - Draft clears the approver (a draft has no approver). +// - Approved without an approver is refused (can't create the broken state). +// - A live plan (In Execution / Completed) is not silently re-drafted. +// - --render regenerates docs/alm-plan.html with the matching badge. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { setPlanStatus } = require('../lib/set-plan-status'); + +function makeProject(t, planData) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'set-plan-status-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); + if (planData !== undefined) { + fs.writeFileSync(path.join(root, 'docs', '.alm-plan-data.json'), JSON.stringify(planData, null, 2)); + } + return root; +} + +function readPlan(root) { + return JSON.parse(fs.readFileSync(path.join(root, 'docs', '.alm-plan-data.json'), 'utf8')); +} + +test('Approved writes PLAN_STATUS + PLAN_MODE + APPROVED_BY + APPROVAL_DATE atomically', (t) => { + const root = makeProject(t, { PLAN_STATUS: 'Draft', SITE_NAME: 'T' }); + const res = setPlanStatus({ + projectRoot: root, status: 'Approved', approver: 'Jane Doe', + makeNow: () => '2026-06-22T00:00:00.000Z', + }); + assert.equal(res.ok, true); + assert.equal(res.previousStatus, 'Draft'); + assert.equal(res.status, 'Approved'); + assert.equal(res.mode, 'approved'); + + const plan = readPlan(root); + assert.equal(plan.PLAN_STATUS, 'Approved'); + assert.equal(plan.PLAN_MODE, 'approved'); + assert.equal(plan.APPROVED_BY, 'Jane Doe'); + assert.equal(plan.APPROVAL_DATE, '2026-06-22T00:00:00.000Z'); + // The whole point: no half-written state — all four agree. +}); + +test('Approved trims the approver and defaults APPROVAL_DATE to now', (t) => { + const root = makeProject(t, { PLAN_STATUS: 'Draft' }); + const res = setPlanStatus({ + projectRoot: root, status: 'Approved', approver: ' Spaced Name ', + makeNow: () => '2026-01-02T03:04:05.000Z', + }); + assert.equal(res.approver, 'Spaced Name'); + assert.equal(res.approvalDate, '2026-01-02T03:04:05.000Z'); + assert.equal(readPlan(root).APPROVED_BY, 'Spaced Name'); +}); + +test('Draft clears a stale approver (a draft carries no approver)', (t) => { + // This is exactly the broken state to recover from: approver set but Draft. + const root = makeProject(t, { PLAN_STATUS: 'Draft', APPROVED_BY: 'Stale', APPROVAL_DATE: '2026-01-01T00:00:00Z' }); + const res = setPlanStatus({ projectRoot: root, status: 'Draft' }); + assert.equal(res.mode, 'draft'); + const plan = readPlan(root); + assert.equal(plan.PLAN_STATUS, 'Draft'); + assert.equal(plan.PLAN_MODE, 'draft'); + assert.equal(plan.APPROVED_BY, ''); + assert.equal(plan.APPROVAL_DATE, ''); +}); + +test('Approved without an approver is refused (cannot create the broken state)', (t) => { + const root = makeProject(t, { PLAN_STATUS: 'Draft' }); + assert.throws(() => setPlanStatus({ projectRoot: root, status: 'Approved' }), /--approver is required/); + assert.throws(() => setPlanStatus({ projectRoot: root, status: 'Approved', approver: ' ' }), /--approver is required/); + // Plan must be untouched after a refused write. + assert.equal(readPlan(root).PLAN_STATUS, 'Draft'); +}); + +test('rejects a status this helper does not own', (t) => { + const root = makeProject(t, { PLAN_STATUS: 'Draft' }); + assert.throws(() => setPlanStatus({ projectRoot: root, status: 'In Execution' }), /--status must be one of/); + assert.throws(() => setPlanStatus({ projectRoot: root, status: 'Completed' }), /--status must be one of/); +}); + +test('refuses to regress a live plan without --force, allows with --force', (t) => { + for (const live of ['In Execution', 'Completed']) { + const root = makeProject(t, { PLAN_STATUS: live, APPROVED_BY: 'X' }); + assert.throws( + () => setPlanStatus({ projectRoot: root, status: 'Draft' }), + new RegExp(`already "${live}"`), + `should refuse to re-draft a ${live} plan`, + ); + // With --force it proceeds. + const res = setPlanStatus({ projectRoot: root, status: 'Draft', force: true }); + assert.equal(res.status, 'Draft'); + assert.equal(readPlan(root).PLAN_STATUS, 'Draft'); + } +}); + +test('throws when there is no plan file', (t) => { + const root = makeProject(t); // no plan-data written + assert.throws(() => setPlanStatus({ projectRoot: root, status: 'Draft' }), /No ALM plan found/); +}); + +test('throws on unparseable plan file', (t) => { + const root = makeProject(t); + fs.writeFileSync(path.join(root, 'docs', '.alm-plan-data.json'), 'not json {{{'); + assert.throws(() => setPlanStatus({ projectRoot: root, status: 'Draft' }), /Could not parse/); +}); + +test('--render regenerates docs/alm-plan.html with the matching badge', (t) => { + const root = makeProject(t, { + PLAN_STATUS: 'Draft', SITE_NAME: 'DemoSite', GENERATED_AT: '2026-06-22', + STRATEGY: 'Pipelines', stages: [], steps: [], risks: [], + }); + const res = setPlanStatus({ + projectRoot: root, status: 'Approved', approver: 'Jane', render: true, + makeNow: () => '2026-06-22T00:00:00.000Z', + }); + assert.equal(res.rendered, true); + const html = fs.readFileSync(path.join(root, 'docs', 'alm-plan.html'), 'utf8'); + // Badge derived from plan-data — text "Approved" and the status class applied. + assert.match(html, /Approved<\/span>/); + // Approver surfaces in the Execution tab footer. + assert.match(html, /Jane/); +}); + +test('--render failure leaves BOTH plan-data and alm-plan.html unchanged (atomic)', (t) => { + const root = makeProject(t, { + PLAN_STATUS: 'Draft', SITE_NAME: 'DemoSite', GENERATED_AT: '2026-06-22', + }); + const dataPath = path.join(root, 'docs', '.alm-plan-data.json'); + const htmlPath = path.join(root, 'docs', 'alm-plan.html'); + const before = fs.readFileSync(dataPath, 'utf8'); + + // A renderer that always fails — simulates a missing/broken render script or an + // unexpected render error. setPlanStatus must NOT leave a half-applied state. + const badRenderer = path.join(root, 'bad-renderer.js'); + fs.writeFileSync(badRenderer, 'process.stderr.write("boom\\n"); process.exit(1);\n'); + + assert.throws(() => setPlanStatus({ + projectRoot: root, status: 'Approved', approver: 'Jane', render: true, + rendererPath: badRenderer, makeNow: () => '2026-06-22T00:00:00.000Z', + })); + + // plan-data must be byte-for-byte unchanged (still Draft, no approver) — the + // status write must not "land" while the HTML stays stale. + assert.equal(fs.readFileSync(dataPath, 'utf8'), before, 'plan-data must be untouched on render failure'); + // No HTML written, and no leftover temp files. + assert.equal(fs.existsSync(htmlPath), false, 'no alm-plan.html on render failure'); + assert.equal(fs.existsSync(dataPath + '.tmp'), false, 'no stale .alm-plan-data.json.tmp'); + assert.equal(fs.existsSync(htmlPath + '.tmp'), false, 'no stale alm-plan.html.tmp'); +}); + +test('idempotent: re-writing the same status yields the same plan-data', (t) => { + const root = makeProject(t, { PLAN_STATUS: 'Draft', SITE_NAME: 'T' }); + setPlanStatus({ projectRoot: root, status: 'Approved', approver: 'A', makeNow: () => '2026-06-22T00:00:00.000Z' }); + const first = readPlan(root); + setPlanStatus({ projectRoot: root, status: 'Approved', approver: 'A', makeNow: () => '2026-06-22T00:00:00.000Z' }); + assert.deepEqual(readPlan(root), first); +}); diff --git a/plugins/power-pages/scripts/tests/validate-plan-alm.test.js b/plugins/power-pages/scripts/tests/validate-plan-alm.test.js index bff23dba6..817be6ee3 100644 --- a/plugins/power-pages/scripts/tests/validate-plan-alm.test.js +++ b/plugins/power-pages/scripts/tests/validate-plan-alm.test.js @@ -108,3 +108,99 @@ test('validate-plan-alm: approves gracefully when stdin is missing or malformed' }); assert.equal(result.status, 0, 'Expected exit 0 on malformed stdin'); }); + +// --- consistency guard: PLAN_STATUS vs APPROVED_BY in docs/.alm-plan-data.json --- +// +// The badge + approver in the HTML are derived from plan-data, so the JSON is the +// source of truth. These cover the two half-written states the old hand-Edit Phase 4 +// could leave behind (and that set-plan-status.js now prevents at the source). + +// A valid rendered plan (> 500 bytes, has the plan-status marker) so the guard is +// reached, plus an optional .alm-plan-data.json with the given status fields. +function makeProjectWithPlan(planData) { + const dir = makeTempProject(); + const docsDir = path.join(dir, 'docs'); + fs.mkdirSync(docsDir); + fs.writeFileSync( + path.join(docsDir, 'alm-plan.html'), + 'X' + 'x'.repeat(500) + '', + ); + if (planData !== undefined) { + fs.writeFileSync(path.join(docsDir, '.alm-plan-data.json'), JSON.stringify(planData, null, 2)); + } + return dir; +} + +test('validate-plan-alm: blocks when APPROVED_BY is set but PLAN_STATUS is Draft (stuck state)', () => { + const dir = makeProjectWithPlan({ PLAN_STATUS: 'Draft', APPROVED_BY: 'Jane Doe' }); + try { + const { status, stderr } = runValidator(dir); + assert.equal(status, 2, 'Expected exit 2 for approver-set-but-Draft'); + assert.match(stderr, /inconsistent/i); + assert.match(stderr, /Jane Doe/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validate-plan-alm: blocks when PLAN_STATUS is Approved but APPROVED_BY is empty', () => { + const dir = makeProjectWithPlan({ PLAN_STATUS: 'Approved', APPROVED_BY: '' }); + try { + const { status, stderr } = runValidator(dir); + assert.equal(status, 2, 'Expected exit 2 for Approved-without-approver'); + assert.match(stderr, /APPROVED_BY is empty/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validate-plan-alm: still blocks the stuck state when APPROVED_BY is a non-string (hand-edited)', () => { + // Regression: a hand-edited plan-data could set APPROVED_BY to a truthy non-string + // (a number/object). Before the String() coercion, `.trim()` threw, runValidation + // swallowed the error and silently APPROVED — bypassing the guard. The coercion + // keeps the Draft+approver stuck state caught (exit 2) instead of leaking through. + const dir = makeProjectWithPlan({ PLAN_STATUS: 'Draft', APPROVED_BY: 123 }); + try { + const { status, stderr } = runValidator(dir); + assert.equal(status, 2, 'non-string approver must not bypass the guard via a thrown .trim()'); + assert.match(stderr, /inconsistent/i); + assert.match(stderr, /123/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validate-plan-alm: approves consistent Approved (status + approver) and Draft (no approver)', () => { + for (const planData of [ + { PLAN_STATUS: 'Approved', APPROVED_BY: 'Jane' }, + { PLAN_STATUS: 'Draft', APPROVED_BY: '' }, + ]) { + const dir = makeProjectWithPlan(planData); + try { + assert.equal(runValidator(dir).status, 0, `Expected exit 0 for ${planData.PLAN_STATUS}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test('validate-plan-alm: approves a live plan (In Execution / Completed) regardless of approver', () => { + for (const PLAN_STATUS of ['In Execution', 'Completed']) { + const dir = makeProjectWithPlan({ PLAN_STATUS, APPROVED_BY: 'Jane' }); + try { + assert.equal(runValidator(dir).status, 0, `Expected exit 0 for ${PLAN_STATUS}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test('validate-plan-alm: approves (gracefully) when plan-data is malformed JSON', () => { + const dir = makeProjectWithPlan(); + try { + fs.writeFileSync(path.join(dir, 'docs', '.alm-plan-data.json'), 'not json {{{'); + assert.equal(runValidator(dir).status, 0, 'malformed plan-data is the renderer\'s concern, not this guard'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/plugins/power-pages/scripts/tests/validation-helpers.test.js b/plugins/power-pages/scripts/tests/validation-helpers.test.js index 0e739fe9a..d8fcc1ba6 100644 --- a/plugins/power-pages/scripts/tests/validation-helpers.test.js +++ b/plugins/power-pages/scripts/tests/validation-helpers.test.js @@ -112,3 +112,45 @@ test('odataGet throws on transport error', async () => { await assert.rejects(() => odataGet('https://x/y', 'tok', fakeRequest), /OData request failed/); }); + +// --- parseEnvironmentUrl: PAC `pac env who` label compatibility (2.8.x "Org URL:") --- + +test('parseEnvironmentUrl extracts the URL from the 2.8.x "Org URL:" banner', () => { + const { parseEnvironmentUrl } = require(helpersPath); + // Real `pac env who` shape on PAC 2.8.1 — the URL is under "Org URL:", + // and there is an "Environment ID:" line but NO "Environment URL:" line. + const who = [ + 'Connected as admin@contoso.onmicrosoft.com', + 'Connected to... CitizenServicesDev', + 'Organization Information', + ' Org ID: 00e3facc-644f-f111-b31f-6045bd29e553', + ' Friendly Name: CitizenServicesDev', + ' Org URL: https://org4a2942d9.crm17.dynamics.com/', + ' Environment ID: d3b0c5e9-6fd9-e4f0-9bdc-eaf672fb6c5d', + ].join('\n'); + assert.equal(parseEnvironmentUrl(who), 'https://org4a2942d9.crm17.dynamics.com'); +}); + +test('parseEnvironmentUrl still extracts the URL from the legacy "Environment URL:" banner', () => { + const { parseEnvironmentUrl } = require(helpersPath); + const who = 'Environment URL: https://legacy.crm.dynamics.com/\nUser: x@y.com'; + assert.equal(parseEnvironmentUrl(who), 'https://legacy.crm.dynamics.com'); +}); + +test('parseEnvironmentUrl returns null when no URL label is present (and on empty input)', () => { + const { parseEnvironmentUrl } = require(helpersPath); + assert.equal(parseEnvironmentUrl('Connected as x@y.com\nNo URL here'), null); + assert.equal(parseEnvironmentUrl(''), null); + assert.equal(parseEnvironmentUrl(null), null); +}); + +test('getEnvironmentUrl parses the 2.8.x "Org URL:" output via mocked execSync', (t) => { + const originalExecSync = childProcess.execSync; + childProcess.execSync = () => ' Org URL: https://orgABC.crm.dynamics.com/\n'; + t.after(() => { childProcess.execSync = originalExecSync; }); + // Re-require fresh so the module binds the mocked execSync. + delete require.cache[require.resolve(helpersPath)]; + const { getEnvironmentUrl } = require(helpersPath); + assert.equal(getEnvironmentUrl(), 'https://orgABC.crm.dynamics.com'); + delete require.cache[require.resolve(helpersPath)]; +}); diff --git a/plugins/power-pages/scripts/tests/verify-alm-prerequisites.test.js b/plugins/power-pages/scripts/tests/verify-alm-prerequisites.test.js index ab88dced4..93c406c87 100644 --- a/plugins/power-pages/scripts/tests/verify-alm-prerequisites.test.js +++ b/plugins/power-pages/scripts/tests/verify-alm-prerequisites.test.js @@ -79,3 +79,78 @@ test('verifyAlmPrerequisites returns envUrl, userId, organizationId on success', assert.equal(result.organizationId, 'org-1'); assert.ok(result.token); }); + +// --- --expectedEnvUrl: hard-stop on ambient PAC env drift (Gap C) --- + +test('verifyAlmPrerequisites HARD-STOPS when resolved PAC env != expectedEnvUrl', async (t) => { + const helpers = require('../lib/validation-helpers'); + const origEnv = helpers.getEnvironmentUrl; + // PAC drifted to prod; the project targets dev. + helpers.getEnvironmentUrl = () => 'https://org-prod.crm.dynamics.com'; + t.after(() => { helpers.getEnvironmentUrl = origEnv; }); + + await assert.rejects( + () => verifyAlmPrerequisites({ expectedEnvUrl: 'https://org-dev.crm.dynamics.com' }), + /Environment mismatch.*org-prod.*targets.*org-dev/s, + ); +}); + +test('verifyAlmPrerequisites passes the env assertion when origins match (slash/case/path ignored)', async (t) => { + const helpers = require('../lib/validation-helpers'); + const origEnv = helpers.getEnvironmentUrl; + const origToken = helpers.getAuthToken; + const origReq = helpers.makeRequest; + helpers.getEnvironmentUrl = () => 'https://Org-Dev.crm.dynamics.com'; // mixed case, no trailing slash + helpers.getAuthToken = () => 'tok'; + helpers.makeRequest = async () => ({ statusCode: 200, body: JSON.stringify({ UserId: 'u', OrganizationId: 'o' }) }); + t.after(() => { + helpers.getEnvironmentUrl = origEnv; + helpers.getAuthToken = origToken; + helpers.makeRequest = origReq; + }); + + // Expected URL differs only by case + trailing slash → same origin → no throw. + const res = await verifyAlmPrerequisites({ expectedEnvUrl: 'https://org-dev.crm.dynamics.com/' }); + assert.equal(res.envUrl, 'https://Org-Dev.crm.dynamics.com'); +}); + +test('parseArgs captures --expectedEnvUrl', () => { + const { parseArgs } = require('../lib/verify-alm-prerequisites'); + const a = parseArgs(['node', 'x', '--expectedEnvUrl', 'https://dev.crm.dynamics.com', '--require-manifest']); + assert.equal(a.expectedEnvUrl, 'https://dev.crm.dynamics.com'); + assert.equal(a.requireManifest, true); + assert.equal(parseArgs(['node', 'x']).expectedEnvUrl, null); +}); + +// --- sameEnvOrigin / envOrigin robustness (no false hard-stop on garbage/bare host) --- + +test('sameEnvOrigin: true/false for parseable URLs; null (indeterminate) for unparseable', () => { + const { sameEnvOrigin } = require('../lib/verify-alm-prerequisites'); + assert.equal(sameEnvOrigin('https://a.crm.dynamics.com', 'https://A.crm.dynamics.com/api/data/v9.2'), true); + assert.equal(sameEnvOrigin('https://a.crm.dynamics.com', 'https://b.crm.dynamics.com'), false); + // Bare host (no scheme) on the expected side must still match a scheme-prefixed resolved URL. + assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', 'dev.crm.dynamics.com'), true); + // Unparseable / empty / unsubstituted placeholder → null (caller must NOT hard-stop). + assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', ''), null); + assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', '{CONFIGURED_ENV_URL}'), null); + assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', null), null); +}); + +test('verifyAlmPrerequisites does NOT hard-stop when expectedEnvUrl is an unsubstituted placeholder', async (t) => { + const helpers = require('../lib/validation-helpers'); + const origEnv = helpers.getEnvironmentUrl; + const origToken = helpers.getAuthToken; + const origReq = helpers.makeRequest; + helpers.getEnvironmentUrl = () => 'https://dev.crm.dynamics.com'; + helpers.getAuthToken = () => 'tok'; + helpers.makeRequest = async () => ({ statusCode: 200, body: JSON.stringify({ UserId: 'u', OrganizationId: 'o' }) }); + t.after(() => { + helpers.getEnvironmentUrl = origEnv; + helpers.getAuthToken = origToken; + helpers.makeRequest = origReq; + }); + // A SKILL that fails to resolve {CONFIGURED_ENV_URL} would pass the literal — must + // NOT block the run (the assertion is skipped on an unparseable expected value). + const res = await verifyAlmPrerequisites({ expectedEnvUrl: '{CONFIGURED_ENV_URL}' }); + assert.equal(res.envUrl, 'https://dev.crm.dynamics.com'); +}); diff --git a/plugins/power-pages/scripts/tests/verify-env-var-values.test.js b/plugins/power-pages/scripts/tests/verify-env-var-values.test.js index a4330473a..00ef64c3d 100644 --- a/plugins/power-pages/scripts/tests/verify-env-var-values.test.js +++ b/plugins/power-pages/scripts/tests/verify-env-var-values.test.js @@ -133,6 +133,31 @@ test('readSettingsFile filters Stages[] by stageLabel (case-insensitive)', async assert.equal(prodEntries[1].value, 'prod-b'); }); +test('readSettingsFile matches a "Deploy to {label}" pipeline stage name against a "{label}"-keyed file (Gap E: total:0 bug)', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-env-norm-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + // Stages[] array shape, keyed "Staging"; deploy-pipeline passes the pipeline + // stage NAME "Deploy to Staging" — must still resolve (was returning [] → total:0, + // a silent no-op that falsely reassured the override had landed). + const arrFile = path.join(dir, 'arr.json'); + fs.writeFileSync(arrFile, JSON.stringify({ + Stages: [{ Name: 'Staging', EnvironmentVariables: [{ SchemaName: 'c311_feature_label', Value: 'staging-val' }] }], + })); + assert.deepEqual(readSettingsFile(arrFile, 'Deploy to Staging'), [ + { schemaName: 'c311_feature_label', value: 'staging-val', stageLabel: 'Staging' }, + ]); + + // Keyed-object shape (configure-env-variables / what Pipelines accepts) — same fix. + const objFile = path.join(dir, 'obj.json'); + fs.writeFileSync(objFile, JSON.stringify({ + stages: { Staging: { EnvironmentVariables: [{ SchemaName: 'c311_feature_label', Value: 'staging-val' }] } }, + })); + const entries = readSettingsFile(objFile, 'Deploy to Staging'); + assert.equal(entries.length, 1, 'String-type override must be counted, not dropped to total:0'); + assert.equal(entries[0].schemaName, 'c311_feature_label'); +}); + test('readSettingsFile with no stageLabel flattens Stages[]', async (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-env-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); diff --git a/plugins/power-pages/skills/deploy-pipeline/SKILL.md b/plugins/power-pages/skills/deploy-pipeline/SKILL.md index ba80f76a8..dd7603e43 100644 --- a/plugins/power-pages/skills/deploy-pipeline/SKILL.md +++ b/plugins/power-pages/skills/deploy-pipeline/SKILL.md @@ -121,11 +121,21 @@ Tasks to create: Steps: -1. Run `verify-alm-prerequisites.js` to confirm PAC CLI auth, acquire a token, and verify API access: +1. **Resolve the project's configured environment URL first, then assert PAC is actually connected to it.** `verify-alm-prerequisites.js` resolves the env from PAC's *ambient org context* (`pac env who`), which is **not** guaranteed to match the project. If PAC has drifted to another environment (duplicate active `pac auth` profiles, or an external process flipping the active env), the skill would silently run discovery — and later `pac pipeline deploy` — against the **wrong** environment (potentially PROD). Assert the match and **hard-stop** on mismatch rather than pinning `--envUrl`: pinning would correct only the Dataverse-API calls while later PAC-CLI operations still follow the drifted context, so asserting that PAC itself is on the right env is the safer gate. + + Read the project's recorded env URL (first match wins): `.solution-manifest.json` → top-level `environmentUrl`, else `powerpages.config.json` → `environmentUrl`. Store as `CONFIGURED_ENV_URL`. (Both fields are top-level `environmentUrl` strings; declarative/EDM sites have no `powerpages.config.json`, so the manifest is the source there.) + + **Pass `--expectedEnvUrl` only when `CONFIGURED_ENV_URL` actually resolved to a URL.** Use the first form when a recorded env URL exists, the second when neither file records one — do **not** pass an empty or unresolved `--expectedEnvUrl "{CONFIGURED_ENV_URL}"`: + ```bash + # CONFIGURED_ENV_URL resolved (recorded in manifest/config) — assert PAC is on it: + node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --require-manifest --expectedEnvUrl "{CONFIGURED_ENV_URL}" + + # Neither file records an env URL — omit the flag, fall back to the pac-context default: node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --require-manifest ``` - Capture output as JSON; extract `.envUrl` (store as `devEnvUrl`) and `.token` (store as `DEV_TOKEN`). If the script exits non-zero, stop and surface the error — it will indicate whether `az login`, `pac auth`, or WhoAmI failed. + + `--expectedEnvUrl` makes the helper compare PAC's resolved env (origin-only) against `CONFIGURED_ENV_URL` and exit non-zero with an *"Environment mismatch: PAC CLI is connected to {X} but this project targets {Y} — run `pac env select …`"* error on mismatch. (As a safety net the helper skips the assertion if the value isn't a parseable env URL — an empty string or an unsubstituted placeholder won't hard-stop — but prefer omitting the flag outright when there's no recorded URL.) Capture output as JSON; extract `.envUrl` (store as `devEnvUrl`) and `.token` (store as `DEV_TOKEN`). If the script exits non-zero, stop and surface the error — it indicates an env mismatch, or that `az login` / `pac auth` / WhoAmI failed. 2. Run `detect-project-context.js` to read project config and solution manifest: ```bash @@ -288,6 +298,8 @@ Use `solutionId` from `.solution-manifest.json` as `ARTIFACT_SOLUTION_ID` and `u > GET {hostEnvUrl}/api/data/v9.1/deploymentpipelines({pipelineId})/deploymentpipeline_deploymentenvironment?$select=deploymentenvironmentid,name,environmenttype > ``` > Filter for `environmenttype = 200000000` to get the source record. Use `deploymentenvironmentid` as the `sourceDeploymentEnvironmentId`. For the artifact/solution list, use `sourceDeploymentEnvironmentId` from `docs/alm/last-pipeline.json` and `solutionName` from `.solution-manifest.json` as fallbacks. Set a flag `VALIDATE_PACKAGE_UNAVAILABLE = true` to skip Phase 4.2–4.3 and use the PAC CLI path in Phase 6. +> +> **If `RetrieveDeploymentPipelineInfo` returns a NON-404 error (e.g. 400/4xx/5xx)** — observed: some Pipelines packages return **400** for this call even though `ValidatePackageAsync` works fine — do **NOT** set `VALIDATE_PACKAGE_UNAVAILABLE`. The 404 branch above is specifically for older packages that lack the OData validation API; a 400 is just this metadata call failing, not the validation API being absent. Instead, fall back to `sourceDeploymentEnvironmentId` from `docs/alm/last-pipeline.json` (and `solutionName` from `.solution-manifest.json`) and **continue the normal `ValidatePackageAsync` flow** (Phase 4 onward). If `docs/alm/last-pipeline.json` is somehow missing `sourceDeploymentEnvironmentId`, use the same `deploymentpipeline_deploymentenvironment` navigation-property GET shown in the 404 branch above to recover it (still **without** setting `VALIDATE_PACKAGE_UNAVAILABLE`). Only a genuine 404 — or a later `ValidatePackageAsync` 404 (Phase 4.2) — routes to the PAC-CLI path. ### Phase 3.5 — Pre-deploy Completeness Check @@ -1189,6 +1201,17 @@ Evaluate the result and take action based on the outcome. In all cases, **after - `"activationStatus": "{ACTIVATION_OUTCOME.status}"` (or keep `null` if `ACTIVATION_OUTCOME` is null) - `"siteUrl": "{ACTIVATION_OUTCOME.siteUrl}"` (or keep `null`) +**Re-refresh the ALM plan so the activation outcome reaches it.** The Phase 7.5b refresh ran *before* activation was resolved, so the plan's "Activate site in {stage}" step is still pending even though `last-deploy.json` now records `activationStatus`. Re-run the refresh now that the marker is patched — the `deploy-pipeline` phase auto-completes the activate step when `activationStatus` is `"Activated"` (a deferred `"Pending"` correctly leaves it pending): + +```bash +node "${PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \ + --projectRoot "." \ + --phase deploy-pipeline \ + --render +``` + +(Soft no-op when no ALM plan exists — `refresh-alm-plan-data.js` returns `ok:false` when `docs/.alm-plan-data.json` is absent.) + Then update the deploy history HTML file (in-place `Edit`) — replace `__ACTIVATION_SECTION__` with the appropriate HTML: - **`status: "Activated"`**: @@ -1284,7 +1307,7 @@ Authorization: Bearer {HOST_TOKEN} | Task subject | activeForm | Description | |---|---|---| -| Verify prerequisites | Verifying prerequisites | Run verify-alm-prerequisites.js (--require-manifest) for PAC/az/WhoAmI; run detect-project-context.js for solutionManifest/siteName; read docs/alm/last-pipeline.json for pipelineId/stages; acquire host env token | +| Verify prerequisites | Verifying prerequisites | Run verify-alm-prerequisites.js (--require-manifest --expectedEnvUrl from project config; hard-stops on PAC env drift) for PAC/az/WhoAmI; run detect-project-context.js for solutionManifest/siteName; read docs/alm/last-pipeline.json for pipelineId/stages; acquire host env token | | Select target stage | Selecting target stage | Show available stages from docs/alm/last-pipeline.json; ask user to select target; warn if last deploy to this stage failed | | Resolve pipeline info | Resolving pipeline info | Call RetrieveDeploymentPipelineInfo (v9.1) to get SourceDeploymentEnvironmentId and DeployableArtifacts; match solution | | Validate package | Validating package | **`MULTI_RUN_MODE`**: run Phase 3.6 once (parallel batch) — `validate-stage-runs-batch.js` fans out create-stage-run + ValidatePackageAsync + poll-validation-status for all non-skipped solutions concurrently; halts the deploy on any failure or pending-approval batch; persists per-solution stageRunIds for the serial deploy loop to reuse. **Single-solution / legacy v2**: Phase 4 inline — POST deploymentstageruns (→ 201 or 204+header); POST ValidatePackageAsync top-level action (204); poll stagerunstatus until not 200000006; JSON.parse validationresults twice; fetch aigenerateddeploymentnotes; PATCH artifactversion + deploymentnotes + deploymentsettingsjson (from deployment-settings.json) | diff --git a/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md b/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md index f639b77ff..fcb0de0e6 100644 --- a/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md +++ b/plugins/power-pages/skills/ensure-pipelines-host/SKILL.md @@ -524,7 +524,7 @@ Track the total eligible count separately — when `eligible.length > 5`, surfac - `eligible.length <= 5` → empty string (no suffix; all envs visible). - `eligible.length > 5` → ` Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.` (leading space). -When the user picks "Other (paste URL)", **pre-fill** the URL input with `pac env list --output json` results so they can paste-or-pick from the full tenant inventory rather than typing a URL by hand. +When the user picks "Other (paste URL)", **pre-fill** the URL input with the environment list from `node "${PLUGIN_ROOT}/scripts/lib/list-environments.js"` (parses `pac env list` into JSON `{ displayName, environmentId, environmentUrl, uniqueName, active }`; the old `pac env list --output json` is invalid on current PAC CLI) so they can paste-or-pick from the inventory rather than typing a URL by hand. **Test scenarios to verify when changing this prompt:** - 0 eligible → sub-option `a` dropped (sub-prompt shows only `b` / `c`). diff --git a/plugins/power-pages/skills/plan-alm/SKILL.md b/plugins/power-pages/skills/plan-alm/SKILL.md index e3122ebb4..507186afc 100644 --- a/plugins/power-pages/skills/plan-alm/SKILL.md +++ b/plugins/power-pages/skills/plan-alm/SKILL.md @@ -63,7 +63,31 @@ Steps: - **Continue and keep marker** → set `DEFERRAL_PRESERVED = true` and `DEFERRAL_REASON = {reason}`. Proceed to step 1. Surface a one-line note in the Phase 1 step 9 user report (e.g. *"Note: `.alm-deferred` is preserved — other ALM skills will continue to skip plan-completeness checks for this project."*) so the user remembers the marker remains in effect after planning. - **Cancel** → exit cleanly (don't touch the marker). - If `deferred === false`, skip this step silently and proceed to step 1. + If `deferred === false`, skip this step silently and proceed to step 0b. + +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.js` and 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: + + ```bash + node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Approved --approver "{APPROVER}" --render + ``` + + Commit (`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. 1. **Resolve the site identity from the local project.** `.powerpages-site/website.yml` is the source of truth for `websiteRecordId` and `siteName`, and it is present for **both** Power Pages site types: - **Code / SPA sites** — scaffolded by `/power-pages:create-site` and downloaded with `pac pages download-code-site`. These also have a `powerpages.config.json` and SPA source (`src/`, build output in `dist/`/`build/`). @@ -75,8 +99,8 @@ Steps: - `name` field → `siteName` (the file uses short keys; it is `name:`, not `adx_name:`) 2. **`powerpages.config.json`** (fallback — code/SPA sites only; used during plugin development from this repo root or for sites scaffolded but not yet deployed) — read `siteName` and `websiteRecordId`. - **Determine `SITE_TYPE`** (recorded in planData as `siteType`, surfaced in the plan, and used to skip SPA-only assumptions below): - - `data-model` when `.powerpages-site/.portalconfig/` exists, **or** `.powerpages-site/website.yml` resolved while no `powerpages.config.json` is present. + **Determine `SITE_TYPE`** (recorded in planData as `siteType` and used to skip SPA-only assumptions below; it is a data field in `docs/.alm-plan-data.json`, not rendered in the HTML): + - `declarative` when `.powerpages-site/.portalconfig/` exists, **or** `.powerpages-site/website.yml` resolved while no `powerpages.config.json` is present. (This value was formerly `data-model`; plans written before the rename may still carry `data-model`, which is equivalent.) - `code` when `powerpages.config.json` is present. If neither marker is found, stop with: @@ -96,13 +120,13 @@ Steps: ```bash pac env who ``` - Capture the `Environment URL` and display name. Store as `DEV_ENV_URL` and `DEV_ENV_NAME`. + Capture the environment URL and display name. Store as `DEV_ENV_URL` and `DEV_ENV_NAME`. **The URL label varies by PAC version**: current PAC (2.8.x) prints it under `Org URL:`; older builds used `Environment URL:` — read whichever is present (there is no `Environment URL:` line on 2.8.x, so do not look only for that label). The display name is the `Friendly Name:` / `Connected to...` value. If you can't parse it reliably, leave `DEV_ENV_URL` empty — Step 6's `verify-alm-prerequisites.js` resolves the authoritative URL from `pac env who` via the shared `getEnvironmentUrl()` helper (which matches both labels) and returns it as `.envUrl`. 5. Run silently: ```bash - pac env list --output json 2>/dev/null + node "${PLUGIN_ROOT}/scripts/lib/list-environments.js" ``` - Store output as `ENV_LIST` for pre-filling environment URLs in Phase 2. + Store the JSON array as `ENV_LIST` for pre-filling environment URLs in Phase 2. (This helper parses `pac env list`; the old `pac env list --output json` is invalid on current PAC CLI — `pac env list` only 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 }`. 6. Acquire dev environment token (silently): ```bash @@ -112,6 +136,33 @@ Steps: **Track plan quality.** Initialize a `PLAN_QUALITY` accumulator to `"complete"` at the start of Phase 1. If this token acquisition fails (auth error), set `DEV_TOKEN = null`, set `PLAN_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-alm` only 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 (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 ` or `pac org select --environment `) 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: @@ -179,13 +230,13 @@ Steps: --envUrl "{DEV_ENV_URL}" --websiteRecordId "{websiteRecordId}" \ --publisherPrefix "{publisherPrefix}" --siteName "{siteName}" \ {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} \ - --projectRoot "." \ + --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.json ``` When `SOLUTION_DONE = false`, omit `--solutionId`; the estimator's output will include `envVarCountScope: "publisher-prefix"` to signal the wider scope, and the renderer surfaces this caveat in the Env Variables tab so reviewers know the number reflects the tenant view, not a specific solution. `--projectRoot "."` enables the disk cross-check — the estimator walks the local build output (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces `webFilesDiskMeasuredMB`. When that number is much larger than the Dataverse-measured `webFilesAggregateMB`, the estimator flips `truncationSuspected: true` with a warning — file-typed columns whose bytes aren't returned by `$select=content` are the usual cause and the plan should trust the disk number. -> **`SITE_TYPE = "data-model"` (EDM/standard) sites have no build output**, so the disk cross-check finds no `dist/`/`build/` directory and `webFilesDiskMeasuredMB` stays `null` — this is expected, not a problem. Web files for data-model 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. +> **`SITE_TYPE = "declarative"` (EDM/standard data-model) sites have no build output**, so the disk cross-check finds no `dist/`/`build/` directory and `webFilesDiskMeasuredMB` stays `null` — 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" \ @@ -471,7 +522,10 @@ If option 4: accept free-text description (via "Other") and build a stage list f Store stages as `PP_STAGES` (array of `{ label, envUrl, envName, type }`). Dev is always the source. -For each stage, populate `envName` from `ENV_LIST` (gathered in Phase 1 Step 5 via `pac env list --output json`). Match by URL origin (lowercase, trailing slash stripped, path/query ignored) and copy the entry's `DisplayName` (or `displayName`) into `envName`. When no match is found — usually because the user pasted a custom URL via "Other" — leave `envName` unset; the renderer falls back to showing the URL alone in the stage card. The renderer puts `envName` between the stage label and the URL (e.g. *Staging / **Supplier Portal Staging** / https://orgd6a9894f.crm5.dynamics.com/*) so reviewers recognize the env at a glance and the URL stays available as a one-click jump-to-env. Set `type: "source"` for the dev/source stage and `type: "target"` for every downstream stage so the renderer applies the active-stage styling correctly. +For each stage, populate `envName` from `ENV_LIST` (gathered in Phase 1 Step 5 via `list-environments.js`). Match by URL origin (lowercase, trailing slash stripped, path/query ignored) against each entry's `environmentUrl` and copy the entry's `displayName` into `envName`. When no match is found — usually because the user pasted a custom URL via "Other" — leave `envName` unset; the renderer falls back to showing the URL alone in the stage card. The renderer puts `envName` between the stage label and the URL (e.g. *Staging / **Supplier Portal Staging** / https://orgd6a9894f.crm5.dynamics.com/*) so reviewers recognize the env at a glance and the URL stays available as a one-click jump-to-env. Set `type: "source"` for the dev/source stage and `type: "target"` for every downstream stage so the renderer applies the active-stage styling correctly. + + +**Reconcile the chosen stages against an existing pipeline (soft warning, PP path).** When `PIPELINE_DONE = true` (a `docs/alm/last-pipeline.json` exists), compare each chosen **target** stage in `PP_STAGES` against that file's `stages[]` (match by `targetEnvironmentUrl` origin, then by stage `name`). For every chosen target that has **no** matching stage on the existing pipeline, record a `{ type: "warning" }` entry for the plan's Risks section: *"Chosen target '{label}' ({envUrl}) has no matching stage on the existing pipeline '{pipelineName}' — setup-pipeline will need to add it."* This catches the real mismatch where a saved plan says *Dev → Production directly* but the live pipeline only has a single *Deploy to Staging* stage. It is **informational only** — do not block or re-prompt; the user can still approve the plan, and `setup-pipeline` reconciles the actual stages at execution time. @@ -505,7 +559,7 @@ Store the resulting `HOST_ENV_URL` for use by the rest of plan-alm. The auxiliar 2. **Fill remaining slots up to 5** from the rest of the eligible list, in the order returned by `list-tenant-envs.js` (name-hint pattern `pipeline|deploy|host|alm|cicd|govern` → admin-perms → recency). 3. **Append "Other (paste URL)"** as the last per-env entry inside option 1's nested list — escape hatch for envs that didn't make the cap. 4. When `eligible.length > 5`, suffix option 1's headline with: ` Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.` When `eligible.length <= 5`, no suffix (all envs visible inline). -5. When the user picks "Other (paste URL)", pre-fill the URL input with `ENV_LIST` (the `pac env list --output json` output gathered in Phase 1) so they can paste-or-pick from the full inventory rather than typing a URL by hand. +5. When the user picks "Other (paste URL)", pre-fill the URL input with `ENV_LIST` (the `list-environments.js` output gathered in Phase 1 — each entry's `environmentUrl`) so they can paste-or-pick from the full inventory rather than typing a URL by hand. The same cap policy applies to `ensure-pipelines-host` Phase 3.C — see that skill's Step 3a for the same rules. Keep the two implementations consistent so users see the same prompt shape regardless of whether they enter via plan-alm or directly via setup-pipeline → ensure-pipelines-host. @@ -601,7 +655,7 @@ Build a `planData` object with all gathered strategy inputs: ```json { "SITE_NAME": "{siteName}", - "siteType": "code | data-model", // from Phase 1 Step 1 — "data-model" for enhanced/standard data-model (EDM) sites (no SPA build output), "code" for SPA sites + "siteType": "code | declarative", // from Phase 1 Step 1 — "declarative" (formerly "data-model") for enhanced/standard data-model (EDM) design-studio sites (no SPA build output), "code" for SPA sites "GENERATED_AT": "{ISO timestamp}", "STRATEGY": "pp-pipelines | manual", "EXPORT_TYPE": "managed | unmanaged", // PP Pipelines path: always "managed" @@ -861,7 +915,7 @@ Populate `risks` based on gathered data: - If `HOST_RESOLUTION.status === "PlatformHostExistsUnbound"`: `{ type: "info", message: "Tenant has a Platform Host. Reusing it is the lowest-friction option; creating a Custom Host instead provides better governance for separate-tenant or governed scenarios." }` - If `HOST_RESOLUTION.status === "CannotRedirect"`: `{ type: "warning", message: "CannotRedirect: source env ProjectHostEnvironmentId points at PE but tenant default custom host is set elsewhere. Resolution requires Power Platform admin." }` (Note: Phase 2 Q4 normally blocks plan generation in this state; this is a defensive entry in case the plan is somehow generated.) - **Manual path** (always, when `STRATEGY = "manual"`): `{ type: "info", message: "Recommended sequence: run /power-pages:export-solution, review the produced zip, then run /power-pages:import-solution for each target. plan-alm does not perform the export/import itself." }` -- **Raw-discovery gaps (#9)** — for each of `rawDiscovery.estimate`, `rawDiscovery.splitPlan`, and (PP path) `rawDiscovery.hostResolution` that is `null` at planData-build time: `{ type: "warning", message: "Discovery for {X} did not run; the related size/split/host decisions in this plan are unverified." }` (substitute `{X}` = "solution size estimate" / "split analysis" / "pipeline host resolution"). +- **Raw-discovery gaps (#9)** — for each of `rawDiscovery.estimate`, `rawDiscovery.splitPlan`, and (PP path) `rawDiscovery.hostResolution` that is `null` at planData-build time: `{ type: "warning", message: "Discovery for {X} did not run; the related size/split/host decisions in this plan are unverified." }` (substitute `{X}` = "solution size estimate" / "split analysis" / "pipeline host resolution"). **Carve-out for `rawDiscovery.hostResolution`:** when `PIPELINE_DONE = true`, host resolution is *intentionally* skipped (Phase 1 Step 12's skip rule — the host comes from `docs/alm/last-pipeline.json`, not a fresh probe), so a `null` `hostResolution` is expected, not a gap. **Do NOT emit the "pipeline host resolution" warning when `PIPELINE_DONE = true`** — it would be a spurious "host resolution did not run" on every project that already has a pipeline. The `estimate` and `splitPlan` gap warnings still apply regardless of `PIPELINE_DONE`. **Plan completeness check (#10).** Before writing planData, verify the plan rests on real discovery: - `sizeAnalysis.totalSizeMB` is non-null, @@ -988,8 +1042,20 @@ Options: 3. **I want to change something** — go back to questions - **If option 3:** Re-run Phase 2 (ask which section to change, then re-gather those answers). Regenerate the plan (repeat Phase 3). Re-present for approval. -- **If option 1 (approved):** Capture the approver (see below). Stamp `` / `` in the HTML and set `` text to `Approved` via `Edit`. **Update `docs/.alm-plan-data.json`**: set `PLAN_STATUS: "Approved"` and `PLAN_MODE: "approved"`. Then run the finalize steps below (skill tracking + commit), print the next-steps guidance, mark task 2 `completed`, and **exit**. -- **If option 2 (draft):** Do **not** capture an approver. Set `` text to `Draft` via `Edit`. Update `docs/.alm-plan-data.json`: `PLAN_STATUS: "Draft"`, `PLAN_MODE: "draft"`. Run the finalize steps below (commit only — skip skill tracking or run it, your choice; commit message `"Add ALM plan for {siteName} (draft)"`), tell the user to re-run `/power-pages:plan-alm` when ready to approve, mark task 2 `completed`, and **exit**. +- **If option 1 (approved):** Capture the approver (see below), then write the status **and** approver atomically with the deterministic helper. **Do not hand-edit the HTML spans** — the badge, `approved-by`, and `approval-date` are all derived from `docs/.alm-plan-data.json` on render, so a manual span Edit is non-durable (the next execution-skill refresh re-derives it). The helper updates plan-data (`PLAN_STATUS`, `PLAN_MODE`, `APPROVED_BY`, `APPROVAL_DATE` — all four together) and re-renders `docs/alm-plan.html`: + + ```bash + node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Approved --approver "{APPROVER}" --render + ``` + + Then run the finalize steps below (skill tracking + commit), print the next-steps guidance, mark task 2 `completed`, and **exit**. +- **If option 2 (draft):** Do **not** capture an approver. Save as Draft with the same helper (writes plan-data + re-renders; clears any stale approver fields so the plan never ends up "Draft + approver"): + + ```bash + node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Draft --render + ``` + + Run the finalize steps below (commit only — skip skill tracking or run it, your choice; commit message `"Add ALM plan for {siteName} (draft)"`), tell the user to re-run `/power-pages:plan-alm` when ready to approve, mark task 2 `completed`, and **exit**. **Capturing the approver (option 1 only) — always interactive (#1):** @@ -1007,12 +1073,7 @@ Then **always** ask via `AskUserQuestion` (even when the suggestion is non-empty > > Options: 1. *{suggested name from git/OS, if any}* · 2. Other (enter name) -If the command returned an empty string, present only option 2 (free-text). Store the confirmed result as `APPROVER`, then use `Edit` to replace the spans in `docs/alm-plan.html`: - -- Find `` (or `` / `__APPROVED_BY__`) and replace its inner text with `APPROVER`. -- Find `` and replace its inner text with the current ISO timestamp. - -Both spans are guaranteed to exist in the template — there is exactly one of each in the "Execution Checklist" tab footer. +If the command returned an empty string, present only option 2 (free-text). Store the confirmed result as `APPROVER` and pass it to `set-plan-status.js` (option 1 above) — **do not** hand-edit the `approved-by` / `approval-date` spans. The helper writes `APPROVED_BY` + `APPROVAL_DATE` (current ISO timestamp) into `docs/.alm-plan-data.json` and re-renders, and the template fills both spans from plan-data. This keeps the audit trail and the status in lockstep — the half-written "approver recorded but status still Draft" state (which `validate-plan-alm.js` now blocks) cannot happen. **Finalize (both save options):** diff --git a/plugins/power-pages/skills/plan-alm/scripts/validate-plan-alm.js b/plugins/power-pages/skills/plan-alm/scripts/validate-plan-alm.js index ad92b5cc0..775a59e2b 100644 --- a/plugins/power-pages/skills/plan-alm/scripts/validate-plan-alm.js +++ b/plugins/power-pages/skills/plan-alm/scripts/validate-plan-alm.js @@ -10,6 +10,7 @@ const path = require('path'); const fs = require('fs'); const { runValidation, findProjectRoot, block, approve, readDeferralMarker } = require('../../../scripts/lib/validation-helpers'); +const { planDataPath } = require('../../../scripts/lib/alm-paths'); runValidation((cwd) => { if (readDeferralMarker(findProjectRoot(cwd) || cwd)) return approve(); // ALM deferred — silent-approve. @@ -64,5 +65,54 @@ runValidation((cwd) => { return; } + // Consistency guard for the creation-time status fields. The badge + approver + // in the HTML are derived from docs/.alm-plan-data.json, so the JSON is the + // source of truth — catch the two half-written states that the old hand-Edit + // Phase 4 could produce (and that set-plan-status.js now prevents). Read-only; + // graceful-approve when there is no plan-data (not every session has one). + const dataPath = planDataPath(projectRoot); + if (fs.existsSync(dataPath)) { + let planData; + try { + planData = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + } catch { + // A malformed plan-data file is the render path's concern, not this guard's + // — don't block the plan-alm session over it here. + planData = null; + } + if (planData) { + const status = planData.PLAN_STATUS || null; + // Coerce before trimming: APPROVED_BY is normally a string, but a hand-edited + // plan-data could set it to a truthy non-string (number/object), and calling + // .trim() on that throws — which would escape runValidation and silently + // approve, bypassing this guard. String(...) keeps the guard robust to any + // malformed-but-parseable JSON. + const approver = String(planData.APPROVED_BY || '').trim(); + // Draft must NOT carry an approver — "approver set + Draft" is the stuck + // state where the plan shows as approved but never advances (check-alm-plan + // only promotes from "Approved"). + if (status === 'Draft' && approver) { + block( + `validate-plan-alm: docs/.alm-plan-data.json is inconsistent — APPROVED_BY is "${approver}" ` + + `but PLAN_STATUS is "Draft". An approver was recorded but the plan was never moved to ` + + `"Approved", so downstream skills will treat it as unapproved. Re-run the approve step so ` + + `the status matches:\n` + + ` node "\${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "${projectRoot}" --status Approved --approver "${approver}" --render` + ); + return; + } + // Approved must HAVE an approver — an empty audit trail on an approved plan. + if (status === 'Approved' && !approver) { + block( + `validate-plan-alm: docs/.alm-plan-data.json is inconsistent — PLAN_STATUS is "Approved" ` + + `but APPROVED_BY is empty. An approved plan must record who approved it. Re-run the approve ` + + `step with the approver's name:\n` + + ` node "\${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "${projectRoot}" --status Approved --approver "" --render` + ); + return; + } + } + } + approve(); }); diff --git a/plugins/power-pages/skills/setup-pipeline/SKILL.md b/plugins/power-pages/skills/setup-pipeline/SKILL.md index 26a2559d6..99a010db1 100644 --- a/plugins/power-pages/skills/setup-pipeline/SKILL.md +++ b/plugins/power-pages/skills/setup-pipeline/SKILL.md @@ -144,9 +144,9 @@ Steps: 3. Run silently: ```bash - pac env list --output json 2>/dev/null + node "${PLUGIN_ROOT}/scripts/lib/list-environments.js" ``` - Store output as `ENV_LIST`. + Store the JSON array as `ENV_LIST` (entries: `{ displayName, environmentId, environmentUrl, uniqueName, active }`). This helper parses `pac env list` — the old `pac env list --output json` is invalid on current PAC CLI (`pac env list` only accepts `--filter`). It prints `[]` and exits 0 when PAC is unauthenticated, so this step degrades gracefully. 4. **Resolve the Pipelines host via `ensure-pipelines-host-detect.js`** (the same flow `/power-pages:ensure-pipelines-host` runs internally — it reads any cached `docs/alm/last-host-check.json`, then walks the resolution order: org-setting binding → BAP env GET → tenant default custom host → tenant-wide enumeration. Read-only; never prompts the user):