Skip to content

Commit 59c2497

Browse files
tyaginidhiclaude
andcommitted
EDM (enhanced/standard data-model) Power Pages site support in ALM discovery
Enhanced data-model sites are downloaded with `pac pages download` (not download-code-site): they have NO `powerpages.config.json` and no SPA build output — just `.powerpages-site/` with a config tree + `website.yml`. The shared discovery helpers hard-required `powerpages.config.json`, so every ALM skill broke on EDM sites. - `findProjectRoot` (validation-helpers.js): treats a `.powerpages-site/` directory as a project-root marker, not just `powerpages.config.json`. - `detect-project-context.js`: falls back to `.powerpages-site/website.yml` (`id`→websiteRecordId, `name`→siteName) when no `powerpages.config.json`; returns a new `siteType` ("code" | "data-model"); exits 1 only when neither marker exists. - `check-activation-status.js`: same fallback (verified live against an EDM site → resolves identity + activation status instead of erroring). Backward-compatible: `powerpages.config.json` stays the primary signal for code sites. PR 2 of 4 (stacked, on plan-alm-plan-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 124c6bd commit 59c2497

7 files changed

Lines changed: 235 additions & 45 deletions

File tree

plugins/power-pages/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "power-pages",
3-
"version": "2.2.0",
3+
"version": "2.3.0",
44
"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.",
55
"author": {
66
"name": "Microsoft",

plugins/power-pages/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ Shared lib modules live at `scripts/lib/` and are imported by other scripts via
196196
#### ALM Prerequisites & Context
197197

198198
- `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`.
199-
- `scripts/lib/detect-project-context.js`: Reads Power Pages project context files from the project root`powerpages.config.json`, `.solution-manifest.json`, and `.datamodel-manifest.json`. Args: `--projectRoot` (opt, auto-discovered from cwd if omitted). Output: `{ projectRoot, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 if `powerpages.config.json` not found.
199+
- `scripts/lib/detect-project-context.js`: Reads Power Pages project context from the project root. Resolves site identity in order: (1) `powerpages.config.json` → `siteType: "code"` (SPA sites); (2) `.powerpages-site/website.yml` → `siteType: "data-model"` (standard/enhanced data-model "EDM" sites, which have **no** `powerpages.config.json` — `id`→`websiteRecordId`, `name`→`siteName`, `environmentUrl: null` since the local files carry no env URL). Also reads `.solution-manifest.json` and `.datamodel-manifest.json`. Args: `--projectRoot` (opt, auto-discovered from cwd if omitted). Output: `{ projectRoot, siteType, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 only if **neither** `powerpages.config.json` nor `.powerpages-site/website.yml` is found. Note: `findProjectRoot` (in `validation-helpers.js`) likewise treats a `.powerpages-site/` directory as a project-root marker, not just `powerpages.config.json`, so data-model sites are discoverable.
200200
- `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 `<projectRoot>/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.
201201
- `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.
202202
- `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`.

plugins/power-pages/scripts/check-activation-status.js

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const fs = require('fs');
1616
const path = require('path');
1717
const { execSync } = require('child_process');
1818
const { findPath, getPacAuthInfo, getAuthToken, makeRequest, CLOUD_TO_API } = require('./lib/validation-helpers');
19+
const { readWebsiteYml } = require('./lib/detect-project-context');
1920

2021
function output(obj) {
2122
process.stdout.write(JSON.stringify(obj));
@@ -27,26 +28,36 @@ const args = process.argv.slice(2);
2728
const rootIdx = args.indexOf('--projectRoot');
2829
const projectRoot = rootIdx !== -1 ? args[rootIdx + 1] : process.cwd();
2930

30-
// --- Read siteName from powerpages.config.json ---
31-
const configPath = findPath(projectRoot, 'powerpages.config.json');
32-
if (!configPath) {
33-
output({ error: 'powerpages.config.json not found' });
34-
}
35-
31+
// --- Read site identity from powerpages.config.json (code/SPA sites) OR
32+
// .powerpages-site/website.yml (data-model / enhanced data model "EDM" sites,
33+
// which have no powerpages.config.json). website.yml carries both the site
34+
// name and the website GUID, so EDM sites skip the pac-pages-list lookup below. ---
3635
let siteName;
37-
try {
38-
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
39-
siteName = config.siteName;
40-
} catch {
41-
output({ error: 'Failed to parse powerpages.config.json' });
36+
let websiteRecordId = null;
37+
const configPath = findPath(projectRoot, 'powerpages.config.json');
38+
if (configPath) {
39+
try {
40+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
41+
siteName = config.siteName;
42+
websiteRecordId = config.websiteRecordId || null;
43+
} catch {
44+
output({ error: 'Failed to parse powerpages.config.json' });
45+
}
46+
} else {
47+
const websiteYmlPath = findPath(projectRoot, path.join('.powerpages-site', 'website.yml'));
48+
const site = websiteYmlPath ? readWebsiteYml(websiteYmlPath) : null;
49+
if (site) {
50+
siteName = site.name;
51+
websiteRecordId = site.id || null;
52+
}
4253
}
4354
if (!siteName) {
44-
output({ error: 'siteName not found in powerpages.config.json' });
55+
output({ error: 'Site name not found — looked in powerpages.config.json and .powerpages-site/website.yml' });
4556
}
4657

47-
// --- Get websiteRecordId from pac pages list ---
48-
let websiteRecordId = null;
49-
try {
58+
// --- Get websiteRecordId from pac pages list (only when not already known,
59+
// e.g. a code site whose config omitted it). EDM sites already have it from website.yml. ---
60+
if (!websiteRecordId) try {
5061
const pacOutput = execSync('pac pages list', { encoding: 'utf8', timeout: 15000 });
5162
// pac pages list outputs a table with columns. Find the row matching siteName.
5263
// Column headers vary but Website Record ID is always a GUID column.

plugins/power-pages/scripts/lib/detect-project-context.js

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
#!/usr/bin/env node
22

33
// Reads Power Pages project context files from the project root.
4-
// Locates powerpages.config.json, .solution-manifest.json, and .datamodel-manifest.json.
4+
// Locates powerpages.config.json (code/SPA sites) OR .powerpages-site/website.yml
5+
// (data-model config sites, standard and enhanced data model), plus
6+
// .solution-manifest.json and .datamodel-manifest.json.
7+
//
8+
// Site identity resolution order (first match wins):
9+
// 1. powerpages.config.json -> siteType "code" (SPA sites; has siteName,
10+
// websiteRecordId, environmentUrl)
11+
// 2. .powerpages-site/website.yml -> siteType "data-model" (enhanced/standard
12+
// data-model sites from `pac pages download`; the
13+
// YAML carries `id` and `name`, but no environment URL —
14+
// callers re-confirm the env via `pac env who`)
515
//
616
// Usage: node detect-project-context.js [--projectRoot <path>]
717
//
@@ -11,14 +21,16 @@
1121
// Output (JSON to stdout):
1222
// {
1323
// "projectRoot": "...",
24+
// "siteType": "code" | "data-model",
1425
// "siteName": "...",
1526
// "websiteRecordId": "...",
16-
// "environmentUrl": "...",
27+
// "environmentUrl": "..." | null,
1728
// "solutionManifest": { ... } | null,
1829
// "datamodelManifest": { ... } | null
1930
// }
2031
//
21-
// Exit 0 on success, exit 1 if powerpages.config.json not found.
32+
// Exit 0 on success, exit 1 if neither powerpages.config.json nor
33+
// .powerpages-site/website.yml is found (not a Power Pages project).
2234

2335
'use strict';
2436

@@ -45,6 +57,32 @@ function readJsonFile(filePath) {
4557
}
4658
}
4759

60+
// Minimal reader for the flat `.powerpages-site/website.yml` (a simple `key: value`
61+
// per line — no nesting). Returns { id, name } or null. Zero-dependency by design
62+
// (the plugin ships no YAML library); only the two identity keys are needed here.
63+
function readWebsiteYml(filePath) {
64+
let raw;
65+
try {
66+
raw = fs.readFileSync(filePath, 'utf8');
67+
} catch {
68+
return null;
69+
}
70+
const out = {};
71+
for (const line of raw.split(/\r?\n/)) {
72+
const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
73+
if (!m) continue;
74+
const key = m[1];
75+
let value = m[2].trim();
76+
// Strip surrounding quotes a YAML writer may add.
77+
if ((value.startsWith('"') && value.endsWith('"')) ||
78+
(value.startsWith("'") && value.endsWith("'"))) {
79+
value = value.slice(1, -1);
80+
}
81+
if (key === 'id' || key === 'name') out[key] = value;
82+
}
83+
return (out.id || out.name) ? out : null;
84+
}
85+
4886
function detectProjectContext(options = {}) {
4987
const startDir = options.projectRoot || process.cwd();
5088
const projectRoot = options.projectRoot
@@ -53,31 +91,57 @@ function detectProjectContext(options = {}) {
5391

5492
if (!projectRoot) {
5593
throw new Error(
56-
'powerpages.config.json not found. Run this command from a Power Pages project directory.'
94+
'No Power Pages project found. Run this command from a site project directory ' +
95+
'(one containing powerpages.config.json for a code site, or .powerpages-site/ ' +
96+
'for a data-model site).'
5797
);
5898
}
5999

100+
const solutionManifest = readJsonFile(path.join(projectRoot, '.solution-manifest.json'));
101+
const datamodelManifest = readJsonFile(path.join(projectRoot, '.datamodel-manifest.json'));
102+
103+
// 1. Code/SPA site — powerpages.config.json is the source of truth.
60104
const configPath = path.join(projectRoot, 'powerpages.config.json');
61-
if (!fs.existsSync(configPath)) {
62-
throw new Error(`powerpages.config.json not found at: ${configPath}`);
105+
if (fs.existsSync(configPath)) {
106+
const config = readJsonFile(configPath);
107+
if (!config) {
108+
throw new Error(`Failed to parse powerpages.config.json at: ${configPath}`);
109+
}
110+
return {
111+
projectRoot,
112+
siteType: 'code',
113+
siteName: config.siteName || null,
114+
websiteRecordId: config.websiteRecordId || null,
115+
environmentUrl: config.environmentUrl || null,
116+
solutionManifest,
117+
datamodelManifest,
118+
};
63119
}
64120

65-
const config = readJsonFile(configPath);
66-
if (!config) {
67-
throw new Error(`Failed to parse powerpages.config.json at: ${configPath}`);
121+
// 2. Data-model (standard/enhanced) site — identity comes from
122+
// .powerpages-site/website.yml (`id` -> websiteRecordId, `name` -> siteName).
123+
// There is no environment URL in the local files; callers re-confirm via `pac env who`.
124+
const websiteYmlPath = path.join(projectRoot, '.powerpages-site', 'website.yml');
125+
if (fs.existsSync(websiteYmlPath)) {
126+
const site = readWebsiteYml(websiteYmlPath);
127+
if (!site) {
128+
throw new Error(`Could not read site id/name from: ${websiteYmlPath}`);
129+
}
130+
return {
131+
projectRoot,
132+
siteType: 'data-model',
133+
siteName: site.name || null,
134+
websiteRecordId: site.id || null,
135+
environmentUrl: null,
136+
solutionManifest,
137+
datamodelManifest,
138+
};
68139
}
69140

70-
const solutionManifest = readJsonFile(path.join(projectRoot, '.solution-manifest.json'));
71-
const datamodelManifest = readJsonFile(path.join(projectRoot, '.datamodel-manifest.json'));
72-
73-
return {
74-
projectRoot,
75-
siteName: config.siteName || null,
76-
websiteRecordId: config.websiteRecordId || null,
77-
environmentUrl: config.environmentUrl || null,
78-
solutionManifest,
79-
datamodelManifest,
80-
};
141+
throw new Error(
142+
`No site identity found at ${projectRoot}: neither powerpages.config.json nor ` +
143+
'.powerpages-site/website.yml is present.'
144+
);
81145
}
82146

83147
// CLI entry point
@@ -94,4 +158,4 @@ if (require.main === module) {
94158
}
95159
}
96160

97-
module.exports = { detectProjectContext };
161+
module.exports = { detectProjectContext, readWebsiteYml };

plugins/power-pages/scripts/lib/validation-helpers.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,23 @@ function findPath(dir, target) {
8989
}
9090

9191
/**
92-
* Finds the project root directory (containing powerpages.config.json).
92+
* Finds the project root directory of a Power Pages site.
93+
*
94+
* A project root is marked by EITHER:
95+
* - `powerpages.config.json` — code/SPA sites (`pac pages download-code-site`), OR
96+
* - a `.powerpages-site/` directory — data-model config sites (`pac pages download`,
97+
* standard or enhanced data model). These have NO `powerpages.config.json`.
98+
*
99+
* Code sites have both markers; data-model (e.g. enhanced data model) sites have only
100+
* `.powerpages-site/`. Checking for either makes root discovery work for both site types.
101+
*
93102
* @returns {string|null} Project root path, or null
94103
*/
95104
function findProjectRoot(dir) {
96105
let current = path.resolve(dir);
97106
while (true) {
98-
const configPath = path.join(current, 'powerpages.config.json');
99-
if (fs.existsSync(configPath)) {
107+
if (fs.existsSync(path.join(current, 'powerpages.config.json')) ||
108+
fs.existsSync(path.join(current, '.powerpages-site'))) {
100109
return current;
101110
}
102111

@@ -107,8 +116,11 @@ function findProjectRoot(dir) {
107116
current = parent;
108117
}
109118

119+
// Fallback: search subdirectories for either marker (config first, then .powerpages-site/).
110120
const fallbackConfigPath = findPath(dir, 'powerpages.config.json');
111-
return fallbackConfigPath ? path.dirname(fallbackConfigPath) : null;
121+
if (fallbackConfigPath) return path.dirname(fallbackConfigPath);
122+
const fallbackSiteDir = findPath(dir, '.powerpages-site');
123+
return fallbackSiteDir ? path.dirname(fallbackSiteDir) : null;
112124
}
113125

114126
/**

plugins/power-pages/scripts/tests/detect-project-context.test.js

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,82 @@ const fs = require('fs');
44
const os = require('os');
55
const path = require('path');
66

7-
const { detectProjectContext } = require('../lib/detect-project-context');
7+
const { detectProjectContext, readWebsiteYml } = require('../lib/detect-project-context');
88
const { createTempProject, writeProjectFile } = require('./test-utils');
99

10-
test('detectProjectContext throws when powerpages.config.json is missing', (t) => {
10+
test('detectProjectContext throws when neither config nor .powerpages-site/website.yml exists', (t) => {
1111
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-test-'));
1212
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
1313

1414
assert.throws(
1515
() => detectProjectContext({ projectRoot: dir }),
16-
/powerpages.config.json not found/
16+
/neither powerpages\.config\.json nor/
1717
);
1818
});
1919

20+
test('detectProjectContext: code site (powerpages.config.json) reports siteType "code"', (t) => {
21+
const projectRoot = createTempProject(t);
22+
writeProjectFile(projectRoot, 'powerpages.config.json', JSON.stringify({
23+
siteName: 'Code Site',
24+
websiteRecordId: 'aabbccdd-1234-5678-abcd-00000000000c',
25+
environmentUrl: 'https://org.crm.dynamics.com',
26+
}));
27+
28+
const result = detectProjectContext({ projectRoot });
29+
assert.equal(result.siteType, 'code');
30+
assert.equal(result.siteName, 'Code Site');
31+
});
32+
33+
test('detectProjectContext: enhanced data-model site resolves identity from .powerpages-site/website.yml', (t) => {
34+
const projectRoot = createTempProject(t);
35+
// No powerpages.config.json — this is an EDM / data-model config site.
36+
writeProjectFile(
37+
projectRoot,
38+
'.powerpages-site/website.yml',
39+
[
40+
'defaultlanguage: 32cc32f6-8665-f111-a826-000d3a5a7777',
41+
'id: 2ecc32f6-8665-f111-a826-000d3a5a7777',
42+
'name: Application processing EDM site - permitapplication-elyyn',
43+
'statecode: 0',
44+
'',
45+
].join('\n')
46+
);
47+
48+
const result = detectProjectContext({ projectRoot });
49+
assert.equal(result.siteType, 'data-model');
50+
assert.equal(result.websiteRecordId, '2ecc32f6-8665-f111-a826-000d3a5a7777');
51+
assert.equal(result.siteName, 'Application processing EDM site - permitapplication-elyyn');
52+
// Data-model sites carry no environment URL locally — callers re-confirm via `pac env who`.
53+
assert.equal(result.environmentUrl, null);
54+
});
55+
56+
test('detectProjectContext: config site wins over website.yml when both exist (code-site precedence)', (t) => {
57+
const projectRoot = createTempProject(t);
58+
writeProjectFile(projectRoot, 'powerpages.config.json', JSON.stringify({
59+
siteName: 'Code Wins',
60+
websiteRecordId: 'config-guid',
61+
environmentUrl: 'https://org.crm.dynamics.com',
62+
}));
63+
writeProjectFile(projectRoot, '.powerpages-site/website.yml', 'id: yml-guid\nname: YAML Name\n');
64+
65+
const result = detectProjectContext({ projectRoot });
66+
assert.equal(result.siteType, 'code');
67+
assert.equal(result.websiteRecordId, 'config-guid');
68+
});
69+
70+
test('readWebsiteYml: extracts id + name, strips quotes, ignores other keys', () => {
71+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'yml-test-'));
72+
const p = path.join(dir, 'website.yml');
73+
fs.writeFileSync(p, 'id: "abc-123"\nname: \'Quoted Site\'\nstatecode: 0\n');
74+
try {
75+
const site = readWebsiteYml(p);
76+
assert.deepEqual(site, { id: 'abc-123', name: 'Quoted Site' });
77+
assert.equal(readWebsiteYml(path.join(dir, 'missing.yml')), null);
78+
} finally {
79+
fs.rmSync(dir, { recursive: true, force: true });
80+
}
81+
});
82+
2083
test('detectProjectContext returns siteName and websiteRecordId from config', (t) => {
2184
const projectRoot = createTempProject(t);
2285
writeProjectFile(projectRoot, 'powerpages.config.json', JSON.stringify({

0 commit comments

Comments
 (0)