Skip to content

Commit df4265b

Browse files
tyaginidhiclaude
andcommitted
Final-review fixes: harden env-match assertion + SKILL.md coherence
From a 4-angle final review of #202: - verify-alm-prerequisites.js: sameEnvOrigin/envOrigin hardened so a bare host (no scheme) matches a scheme-prefixed URL, and an empty / unsubstituted "{CONFIGURED_ENV_URL}" / junk value returns null (indeterminate) instead of a bogus origin. The --expectedEnvUrl assertion now hard-stops ONLY on a definite mismatch (=== false), so a misrendered or schemeless value can no longer FALSE- hard-stop a legitimate deploy. Tests added (bare host, placeholder, null). - deploy-pipeline Phase 1: show both invocation forms (with / without --expectedEnvUrl) so the agent omits the flag when no env URL is recorded instead of passing an empty/placeholder value. - deploy-pipeline Phase 3: the 400-branch now documents the nav-property fallback for the rare missing-sourceDeploymentEnvironmentId case (still without setting VALIDATE_PACKAGE_UNAVAILABLE). - plan-alm env-match gate: replace the two confusing "Cancel"-prefixed options with two distinct ones ("Switch PAC env & re-run" / "Continue anyway"), both documented. - plan-alm step 6b: fix the self-referential "environmentUrl/environmentUrl- equivalent" typo → top-level `environmentUrl`. - set-plan-status.js: unlink the staged temp if the final JSON rename fails (no orphaned .alm-plan-data.json.tmp). - compute-split-plan.test.js: stale siteType fixture 'code-site' → 'code'. Pre-existing issues NOT in this PR's scope (noted for follow-up): tableCountScope 'manifest-only' mislabel when an empty table-permissions dir + no manifest; verifyOne String() value-mismatch vs missing-value categorization; parseEnvList forward-compat edges on best-effort pre-fill. 1288 tests pass (+2). alm-lint 0, legacy-compat in sync, version-check pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 25a35b6 commit df4265b

6 files changed

Lines changed: 96 additions & 18 deletions

File tree

plugins/power-pages/scripts/lib/set-plan-status.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,15 @@ function setPlanStatus(opts) {
170170
fs.renameSync(htmlTmp, htmlPath);
171171
rendered = true;
172172
}
173-
fs.renameSync(dataTmp, dataPath);
173+
// Commit the JSON. If this final rename ever fails (e.g. a transient lock on the
174+
// plan file), unlink the staged temp so we don't leave an orphaned
175+
// `.alm-plan-data.json.tmp` behind, then rethrow so the caller sees the failure.
176+
try {
177+
fs.renameSync(dataTmp, dataPath);
178+
} catch (e) {
179+
try { fs.unlinkSync(dataTmp); } catch {}
180+
throw e;
181+
}
174182

175183
return {
176184
ok: true,

plugins/power-pages/scripts/lib/verify-alm-prerequisites.js

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,40 @@ function parseArgs(argv) {
4242
return { envUrl, requireManifest, expectedEnvUrl };
4343
}
4444

45-
// Compare two Dataverse env URLs by origin only (scheme+host), case-insensitive,
46-
// path/query/trailing-slash ignored — so `https://Org.crm.dynamics.com/` and
47-
// `https://org.crm.dynamics.com/api/data/v9.2` count as the same environment.
45+
// Normalize a Dataverse env reference to its origin (scheme+host), lowercased.
46+
// Tolerates a missing scheme (`org.crm.dynamics.com` → `https://org.crm.dynamics.com`)
47+
// since a hand-authored manifest may omit it. Returns null when the value is empty
48+
// or not a parseable host — so the caller can treat "can't compare" distinctly from
49+
// "definitely different" and avoid a false mismatch on garbage input.
50+
function envOrigin(u) {
51+
const s = String(u || '').trim();
52+
if (!s) return null;
53+
// Try as-is, then with an https:// prefix (covers a bare host like
54+
// `org.crm.dynamics.com`). The WHATWG URL parser is lenient and will happily
55+
// accept `https://{CONFIGURED_ENV_URL}` as a "host", so after parsing we ALSO
56+
// require a plausible DNS hostname (dot-separated alnum/hyphen labels). That
57+
// rejects an unsubstituted `{PLACEHOLDER}`, `null`, or other junk (→ null) so it
58+
// can never be compared as if it were a real environment.
59+
for (const candidate of [s, 'https://' + s.replace(/^\/+/, '')]) {
60+
let parsed;
61+
try { parsed = new URL(candidate); } catch { continue; }
62+
const host = parsed.hostname.toLowerCase();
63+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) continue;
64+
return parsed.origin.toLowerCase();
65+
}
66+
return null;
67+
}
68+
69+
// Compare two env references by origin only — path/query/trailing-slash/case ignored.
70+
// Returns true (same), false (definitely different), or null (indeterminate: one side
71+
// isn't a parseable env URL). Callers must hard-stop ONLY on an explicit `false`, never
72+
// on null, so a missing/placeholder/garbage value disables the assertion instead of
73+
// blocking a legitimate run.
4874
function sameEnvOrigin(a, b) {
49-
const origin = (u) => {
50-
try { return new URL(u).origin.toLowerCase(); }
51-
catch { return String(u || '').replace(/\/+$/, '').toLowerCase(); }
52-
};
53-
return origin(a) === origin(b);
75+
const oa = envOrigin(a);
76+
const ob = envOrigin(b);
77+
if (oa === null || ob === null) return null;
78+
return oa === ob;
5479
}
5580

5681
async function verifyAlmPrerequisites({ envUrl, requireManifest, expectedEnvUrl } = {}) {
@@ -75,7 +100,12 @@ async function verifyAlmPrerequisites({ envUrl, requireManifest, expectedEnvUrl
75100
// env (from .solution-manifest.json / powerpages.config.json / the approved plan)
76101
// passes it here; a mismatch HARD-STOPS before any token acquisition or write, so
77102
// an ALM operation can never silently target the wrong environment (e.g. PROD).
78-
if (expectedEnvUrl && !sameEnvOrigin(resolvedEnvUrl, expectedEnvUrl)) {
103+
// HARD-STOP only on a DEFINITE mismatch (sameEnvOrigin === false). A null result
104+
// means expectedEnvUrl wasn't a parseable env URL (empty, an unsubstituted
105+
// `{PLACEHOLDER}`, junk) — in that case skip the assertion rather than block a
106+
// legitimate run on bad input; the SKILL.md guidance is to omit the flag entirely
107+
// when no env URL is recorded.
108+
if (expectedEnvUrl && sameEnvOrigin(resolvedEnvUrl, expectedEnvUrl) === false) {
79109
throw new Error(
80110
`Environment mismatch: PAC CLI is connected to ${resolvedEnvUrl} but this project targets ` +
81111
`${expectedEnvUrl.replace(/\/+$/, '')}. Run \`pac env select --environment ${expectedEnvUrl.replace(/\/+$/, '')}\` ` +
@@ -157,4 +187,4 @@ if (require.main === module) {
157187
});
158188
}
159189

160-
module.exports = { verifyAlmPrerequisites, parseArgs, sameEnvOrigin };
190+
module.exports = { verifyAlmPrerequisites, parseArgs, sameEnvOrigin, envOrigin };

plugins/power-pages/scripts/tests/compute-split-plan.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function baseEstimate(overrides = {}) {
3939
botCount: 0,
4040
envVarCount: 5,
4141
mediaRatio: 0.3,
42-
siteType: 'code-site',
42+
siteType: 'code',
4343
tables: [],
4444
...overrides,
4545
};

plugins/power-pages/scripts/tests/verify-alm-prerequisites.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,36 @@ test('parseArgs captures --expectedEnvUrl', () => {
121121
assert.equal(a.requireManifest, true);
122122
assert.equal(parseArgs(['node', 'x']).expectedEnvUrl, null);
123123
});
124+
125+
// --- sameEnvOrigin / envOrigin robustness (no false hard-stop on garbage/bare host) ---
126+
127+
test('sameEnvOrigin: true/false for parseable URLs; null (indeterminate) for unparseable', () => {
128+
const { sameEnvOrigin } = require('../lib/verify-alm-prerequisites');
129+
assert.equal(sameEnvOrigin('https://a.crm.dynamics.com', 'https://A.crm.dynamics.com/api/data/v9.2'), true);
130+
assert.equal(sameEnvOrigin('https://a.crm.dynamics.com', 'https://b.crm.dynamics.com'), false);
131+
// Bare host (no scheme) on the expected side must still match a scheme-prefixed resolved URL.
132+
assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', 'dev.crm.dynamics.com'), true);
133+
// Unparseable / empty / unsubstituted placeholder → null (caller must NOT hard-stop).
134+
assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', ''), null);
135+
assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', '{CONFIGURED_ENV_URL}'), null);
136+
assert.equal(sameEnvOrigin('https://dev.crm.dynamics.com', null), null);
137+
});
138+
139+
test('verifyAlmPrerequisites does NOT hard-stop when expectedEnvUrl is an unsubstituted placeholder', async (t) => {
140+
const helpers = require('../lib/validation-helpers');
141+
const origEnv = helpers.getEnvironmentUrl;
142+
const origToken = helpers.getAuthToken;
143+
const origReq = helpers.makeRequest;
144+
helpers.getEnvironmentUrl = () => 'https://dev.crm.dynamics.com';
145+
helpers.getAuthToken = () => 'tok';
146+
helpers.makeRequest = async () => ({ statusCode: 200, body: JSON.stringify({ UserId: 'u', OrganizationId: 'o' }) });
147+
t.after(() => {
148+
helpers.getEnvironmentUrl = origEnv;
149+
helpers.getAuthToken = origToken;
150+
helpers.makeRequest = origReq;
151+
});
152+
// A SKILL that fails to resolve {CONFIGURED_ENV_URL} would pass the literal — must
153+
// NOT block the run (the assertion is skipped on an unparseable expected value).
154+
const res = await verifyAlmPrerequisites({ expectedEnvUrl: '{CONFIGURED_ENV_URL}' });
155+
assert.equal(res.envUrl, 'https://dev.crm.dynamics.com');
156+
});

plugins/power-pages/skills/deploy-pipeline/SKILL.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,17 @@ Steps:
125125

126126
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.)
127127

128+
**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}"`:
129+
128130
```bash
131+
# CONFIGURED_ENV_URL resolved (recorded in manifest/config) — assert PAC is on it:
129132
node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --require-manifest --expectedEnvUrl "{CONFIGURED_ENV_URL}"
133+
134+
# Neither file records an env URL — omit the flag, fall back to the pac-context default:
135+
node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --require-manifest
130136
```
131137

132-
`--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. (If neither file records an env URL, omit `--expectedEnvUrl`; the helper falls back to the pac-context default with no assertion.) 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.
138+
`--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.
133139

134140
2. Run `detect-project-context.js` to read project config and solution manifest:
135141
```bash
@@ -293,7 +299,7 @@ Use `solutionId` from `.solution-manifest.json` as `ARTIFACT_SOLUTION_ID` and `u
293299
> ```
294300
> 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.
295301
>
296-
> **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). Only a genuine 404 — or a later `ValidatePackageAsync` 404 (Phase 4.2) — routes to the PAC-CLI path.
302+
> **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.
297303
298304
### Phase 3.5 — Pre-deploy Completeness Check
299305

plugins/power-pages/skills/plan-alm/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Steps:
138138
139139
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:
140140
141-
1. **Recorded-URL comparison** (no token needed): collect any environment URL the project already records — `powerpages.config.json``environmentUrl` (code/SPA sites; absent for declarative/EDM sites) and `.solution-manifest.json`its `environmentUrl`/`environmentUrl`-equivalent field 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**.
141+
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**.
142142
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:
143143
```
144144
GET {DEV_ENV_URL}/api/data/v9.2/powerpagesites({websiteRecordId})?$select=powerpagesiteid
@@ -155,10 +155,11 @@ Steps:
155155
156156
| Question | Header | Options |
157157
|---|---|---|
158-
| 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 | Cancel — switch PAC env, then re-run (Recommended), Continue against {DEV_ENV_NAME} anyway, Cancel |
158+
| 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 |
159159
160-
- **Cancel — switch PAC env (Recommended)**: stop the skill. Tell the user to point PAC at the right environment (`pac auth select --name <profile>` or `pac org select --environment <url>`) and re-run `/power-pages:plan-alm`. Nothing has been written.
161-
- **Continue 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.
160+
Exactly two outcomes (both halt-or-proceed; no separate "cancel" — "Switch & re-run" already stops the skill):
161+
- **Switch PAC env & re-run (Recommended)**: stop the skill. Tell the user to point PAC at the right environment (`pac auth select --name <profile>` or `pac org select --environment <url>`) and re-run `/power-pages:plan-alm`. Nothing has been written.
162+
- **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.
162163
163164
> **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.
164165

0 commit comments

Comments
 (0)