diff --git a/plugins/power-pages/.claude-plugin/plugin.json b/plugins/power-pages/.claude-plugin/plugin.json
index ba73e795f..6e91e7418 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.6.4",
+ "version": "2.6.5",
"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 ba73e795f..6e91e7418 100644
--- a/plugins/power-pages/.plugin/plugin.json
+++ b/plugins/power-pages/.plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "power-pages",
- "version": "2.6.4",
+ "version": "2.6.5",
"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/README.md b/plugins/power-pages/README.md
index b111f9af9..1a1682f5a 100644
--- a/plugins/power-pages/README.md
+++ b/plugins/power-pages/README.md
@@ -38,7 +38,7 @@ This keeps hook behavior in one place and avoids relying on skill-frontmatter ho
## Skills
-The plugin provides 33 skills that cover the full lifecycle of a Power Pages site — scaffolding, deployment, data modeling, backend integration, authentication, ALM and CI/CD, security review, testing, auditing, and Bootstrap 3→5 migration. Each skill is invoked conversationally — just describe what you want to do.
+The plugin provides 34 skills that cover the full lifecycle of a Power Pages site — scaffolding, deployment, data modeling, backend integration, authentication, ALM and CI/CD, security review, testing, auditing, and platform migrations. Each skill is invoked conversationally — just describe what you want to do.
### Site scaffolding and deployment
@@ -400,6 +400,17 @@ Migrates a traditional Power Pages site (Liquid web templates, not code sites) f
- AI-assisted per-category fixes for grid, navbar, panel/card, and page-header changes
- Uploads (auto-enabling the Bootstrap 5 runtime flag) and verifies the flip via `pac-log.txt`
+#### `/migrate-webapi-selectall`
+
+> "Replace every wildcard Web API fields setting with the columns my site actually uses"
+
+Reviews every authored Power Pages Web API source call and response consumer, maps entity sets through Dataverse metadata, and replaces deprecated `Webapi/
/fields = *` values with evidence-backed explicit columns. Compiled and generated output is excluded. Works with both traditional/Liquid sites and React, Vue, Angular, or Astro SPA sites.
+
+- Reports every wildcard with its exact proposed fix and every already-explicit configuration
+- Adds missing `$select` projections where normal record reads relied on implicit selection
+- Traces every call site reaching a table, including duplicated wrappers and differing query shapes
+- Verifies all configuration scopes and deployment profiles contain zero wildcards
+
### Support
#### `/report-issue`
diff --git a/plugins/power-pages/references/approval-gates.md b/plugins/power-pages/references/approval-gates.md
index dcd663860..91e721fc3 100644
--- a/plugins/power-pages/references/approval-gates.md
+++ b/plugins/power-pages/references/approval-gates.md
@@ -697,6 +697,21 @@ New skill (migrates a traditional Power Pages site from Bootstrap 3 to Bootstrap
| `migrate-bootstrap:6.residual-fixes` | gate | progress | 6 | Per-category consent — *"Apply the `` fixes to `` file(s)?"* — repeated per residual category; changes are local to the V5 copy. | nothing |
| `migrate-bootstrap:7.2.upload` | gate | final | 7.2 | First outward-facing change — *"Upload `` to ``?"* — publishes the Bootstrap 5 site and auto-enables the runtime flag. | nothing |
+---
+
+### 6.32 `migrate-webapi-selectall` (6 calls / 5 gates + 1 sub-prompt)
+
+Reviews traditional and SPA sites for deprecated Web API wildcard fields settings, derives least-privilege columns from every call chain, and applies only a complete approved migration.
+
+| ID | Kind | Category | Phase | Trigger / question | Cancel leaves |
+|---|---|---|---|---|---|
+| `migrate-webapi-selectall:1.download-site` | gate | consent | 1 | Approves an optional site download after confirming environment, website name and ID, site type, data model, and target path. | nothing |
+| `migrate-webapi-selectall:2.confirm-scope` | gate | plan | 2 | Confirms all configuration scopes, wildcard and explicit counts, and the source call inventory before schema retrieval. | draft migration report |
+| Phase 3 environment URL | sub-prompt | — | 3.1 | Collects the environment URL only when project and PAC context cannot resolve it. This is read-only metadata input and grants no write consent. | draft migration report |
+| `migrate-webapi-selectall:4.apply-plan` | gate | consent | 4 | Approves every wildcard replacement, required source projection, selected explicit hardening, and local edits. No partial wildcard option is offered. | reviewed migration report |
+| `migrate-webapi-selectall:7.deploy` | gate | final | 7 | Approves one independently verified deployment after re-confirming environment, website, site type, data model, and profile. Repeat for another target. | local migration |
+| `migrate-webapi-selectall:7.smoke-test` | gate | progress | 7 | Approves the listed read-path smoke test against the deployed site. Write, file, and image paths are never issued. | deployed migration unverified |
+
---
### Cross-plugin shared skills — out of catalog scope
diff --git a/plugins/power-pages/references/skill-tracking-reference.md b/plugins/power-pages/references/skill-tracking-reference.md
index 223b50746..8c7bc1e84 100644
--- a/plugins/power-pages/references/skill-tracking-reference.md
+++ b/plugins/power-pages/references/skill-tracking-reference.md
@@ -57,6 +57,7 @@ If the tracking script creates or updates site setting YAML files, include those
| ensure-pipelines-host | EnsurePipelinesHost | Site/AI/Skills/EnsurePipelinesHost |
| force-link-environment | ForceLinkEnvironment | Site/AI/Skills/ForceLinkEnvironment |
| migrate-bootstrap | MigrateBootstrap | Site/AI/Skills/MigrateBootstrap |
+| migrate-webapi-selectall | MigrateWebapiSelectall | Site/AI/Skills/MigrateWebapiSelectall |
## YAML Format
diff --git a/plugins/power-pages/scripts/tests/query-webapi-selectall-schema.test.js b/plugins/power-pages/scripts/tests/query-webapi-selectall-schema.test.js
new file mode 100644
index 000000000..a1e554ad8
--- /dev/null
+++ b/plugins/power-pages/scripts/tests/query-webapi-selectall-schema.test.js
@@ -0,0 +1,236 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+const { spawnSync } = require('node:child_process');
+
+const schema = require(
+ '../../skills/migrate-webapi-selectall/scripts/query-table-schema'
+);
+
+const ENVIRONMENT_URL_1 = 'https://placeholder.crm.dynamics.com';
+const TABLE_LOGICAL_NAME_1 = 'table_1';
+const TABLE_ENTITY_SET_NAME_1 = 'table_1_set';
+const TABLE_PRIMARY_ID_1 = 'column_name_1_id';
+const TABLE_LOGICAL_NAME_2 = 'table_2';
+const TABLE_ENTITY_SET_NAME_2 = 'table_2_set';
+const TABLE_PRIMARY_ID_2 = 'column_name_2_id';
+const COLUMN_NAME_1 = 'column_name_1';
+const LOOKUP_COLUMN_NAME_1 = 'lookup_column_name_1';
+const RELATIONSHIP_NAME_1 = 'relationship_1';
+
+test('builds bounded metadata URLs for one table', () => {
+ const urls = schema.buildMetadataUrls(
+ ENVIRONMENT_URL_1,
+ TABLE_LOGICAL_NAME_1
+ );
+
+ assert.ok(urls.attributes.includes(
+ `EntityDefinitions(LogicalName='${TABLE_LOGICAL_NAME_1}')/Attributes`
+ ));
+ assert.match(urls.attributes, /%24select=/);
+ assert.match(urls.manyToMany, /ManyToManyRelationships/);
+});
+
+test('resolves both logical names and entity sets', () => {
+ const definitions = [{
+ LogicalName: TABLE_LOGICAL_NAME_1,
+ EntitySetName: TABLE_ENTITY_SET_NAME_1,
+ PrimaryIdAttribute: TABLE_PRIMARY_ID_1,
+ }];
+
+ assert.deepEqual(
+ schema.resolveRequestedTables(
+ definitions,
+ [TABLE_LOGICAL_NAME_1, TABLE_ENTITY_SET_NAME_1]
+ ),
+ definitions
+ );
+});
+
+test('rejects project root as schema output', () => {
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webapi-schema-root-'));
+ try {
+ assert.throws(
+ () => schema.validateOptions({
+ environmentUrl: ENVIRONMENT_URL_1,
+ projectRoot,
+ output: projectRoot,
+ tables: [TABLE_LOGICAL_NAME_1],
+ }),
+ /file inside the project root/
+ );
+ } finally {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('rejects output through an escaping junction', (t) => {
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'webapi-schema-link-'));
+ const projectRoot = path.join(workspace, 'project');
+ const outside = path.join(workspace, 'outside');
+ t.after(() => fs.rmSync(workspace, { recursive: true, force: true }));
+
+ fs.mkdirSync(projectRoot);
+ fs.mkdirSync(outside);
+ fs.writeFileSync(
+ path.join(outside, 'tables.txt'),
+ `${TABLE_LOGICAL_NAME_1}\n`,
+ 'utf8'
+ );
+
+ try {
+ fs.symlinkSync(
+ outside,
+ path.join(projectRoot, 'linked'),
+ process.platform === 'win32' ? 'junction' : 'dir'
+ );
+ } catch (error) {
+ if (error.code === 'EPERM' || error.code === 'EACCES') {
+ t.skip(`symlinks are unavailable: ${error.code}`);
+ return;
+ }
+ throw error;
+ }
+
+ assert.throws(
+ () => schema.validateOptions({
+ environmentUrl: ENVIRONMENT_URL_1,
+ projectRoot,
+ output: path.join(projectRoot, 'linked', 'schema.json'),
+ tables: [TABLE_LOGICAL_NAME_1],
+ }),
+ /inside the project root/
+ );
+ assert.throws(
+ () => schema.validateOptions({
+ environmentUrl: ENVIRONMENT_URL_1,
+ projectRoot,
+ output: path.join(projectRoot, 'schema.json'),
+ tables: [],
+ tablesFile: path.join(projectRoot, 'linked', 'tables.txt'),
+ }),
+ /Tables file must exist inside the project root/
+ );
+});
+
+test('normalizes attributes and navigation metadata', () => {
+ const normalized = schema.normalizeTableMetadata(
+ {
+ LogicalName: TABLE_LOGICAL_NAME_1,
+ EntitySetName: TABLE_ENTITY_SET_NAME_1,
+ PrimaryIdAttribute: TABLE_PRIMARY_ID_1,
+ },
+ [{
+ LogicalName: LOOKUP_COLUMN_NAME_1,
+ AttributeType: 'Lookup',
+ IsValidForRead: true,
+ IsValidForCreate: { Value: true },
+ IsValidForUpdate: false,
+ }],
+ [{
+ SchemaName: RELATIONSHIP_NAME_1,
+ ReferencingEntity: TABLE_LOGICAL_NAME_1,
+ ReferencedEntity: TABLE_LOGICAL_NAME_2,
+ ReferencingAttribute: LOOKUP_COLUMN_NAME_1,
+ ReferencingEntityNavigationPropertyName: LOOKUP_COLUMN_NAME_1,
+ ReferencedEntityNavigationPropertyName: RELATIONSHIP_NAME_1,
+ }],
+ []
+ );
+
+ assert.deepEqual(
+ normalized.lookupReadProperties,
+ [`_${LOOKUP_COLUMN_NAME_1}_value`]
+ );
+ assert.deepEqual(normalized.navigationProperties, [{
+ name: LOOKUP_COLUMN_NAME_1,
+ targetLogicalName: TABLE_LOGICAL_NAME_2,
+ lookupAttribute: LOOKUP_COLUMN_NAME_1,
+ relationship: RELATIONSHIP_NAME_1,
+ }]);
+ assert.equal(normalized.attributes[0].isValidForCreate, true);
+});
+
+test('queries only resolved requested tables', async () => {
+ const calls = [];
+ const getAll = async (url) => {
+ calls.push(url);
+ if (/EntityDefinitions\?/.test(url)) {
+ return [{
+ LogicalName: TABLE_LOGICAL_NAME_1,
+ EntitySetName: TABLE_ENTITY_SET_NAME_1,
+ PrimaryIdAttribute: TABLE_PRIMARY_ID_1,
+ }, {
+ LogicalName: TABLE_LOGICAL_NAME_2,
+ EntitySetName: TABLE_ENTITY_SET_NAME_2,
+ PrimaryIdAttribute: TABLE_PRIMARY_ID_2,
+ }];
+ }
+ if (/\/Attributes\?/.test(url)) {
+ return [{
+ LogicalName: COLUMN_NAME_1,
+ AttributeType: 'String',
+ IsValidForRead: true,
+ IsValidForCreate: true,
+ IsValidForUpdate: true,
+ }];
+ }
+ return [];
+ };
+
+ const result = await schema.queryTableSchemas(
+ ENVIRONMENT_URL_1,
+ [TABLE_ENTITY_SET_NAME_1],
+ {
+ getAuthToken: () => 'token',
+ odataGetAll: getAll,
+ }
+ );
+
+ assert.equal(result.tables.length, 1);
+ assert.equal(result.tables[0].logicalName, TABLE_LOGICAL_NAME_1);
+ assert.equal(calls.filter(url => /\/Attributes\?/.test(url)).length, 1);
+});
+
+test('retries transient metadata throttling sequentially', async () => {
+ let attempts = 0;
+ const delays = [];
+ const getAll = async (url) => {
+ attempts += 1;
+ if (attempts === 1) throw new Error('HTTP 429 throttled');
+ if (/EntityDefinitions\?/.test(url)) {
+ return [{
+ LogicalName: TABLE_LOGICAL_NAME_1,
+ EntitySetName: TABLE_ENTITY_SET_NAME_1,
+ PrimaryIdAttribute: TABLE_PRIMARY_ID_1,
+ }];
+ }
+ return [];
+ };
+
+ const result = await schema.queryTableSchemas(
+ ENVIRONMENT_URL_1,
+ [TABLE_LOGICAL_NAME_1],
+ {
+ getAuthToken: () => 'token',
+ odataGetAll: getAll,
+ sleep: async delay => delays.push(delay),
+ }
+ );
+
+ assert.equal(result.tables.length, 1);
+ assert.deepEqual(delays, [1000]);
+});
+
+test('query-table-schema answers --help before parsing arguments', () => {
+ const scriptPath = require.resolve(
+ '../../skills/migrate-webapi-selectall/scripts/query-table-schema'
+ );
+ const result = spawnSync(process.execPath, [scriptPath, '--help'], { encoding: 'utf8' });
+ assert.equal(result.status, 0);
+ assert.ok(result.stdout);
+});
diff --git a/plugins/power-pages/scripts/tests/render-migration-report.test.js b/plugins/power-pages/scripts/tests/render-migration-report.test.js
new file mode 100644
index 000000000..2b7b3a70e
--- /dev/null
+++ b/plugins/power-pages/scripts/tests/render-migration-report.test.js
@@ -0,0 +1,195 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const vm = require('node:vm');
+const { spawnSync } = require('node:child_process');
+
+const scriptPath = path.join(
+ __dirname, '..', '..', 'skills', 'migrate-webapi-selectall', 'scripts', 'render-migration-report.js'
+);
+
+const attack = '
\' "';
+
+function baseData(overrides = {}) {
+ return Object.assign({
+ REPORT_STATUS: 'Complete',
+ SCOPE_NOTE: 'Reviewed 1 configuration scope and 4 source call sites.',
+ WILDCARD_DATA: [],
+ EXPLICIT_DATA: [],
+ }, overrides);
+}
+
+function render(data) {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migration-report-'));
+ const dataPath = path.join(tempDir, 'data.json');
+ const outputPath = path.join(tempDir, 'migration-report.html');
+ fs.writeFileSync(dataPath, JSON.stringify(data), 'utf8');
+
+ const result = spawnSync(process.execPath, [scriptPath, '--output', outputPath, '--data', dataPath], {
+ encoding: 'utf8',
+ });
+ assert.equal(result.status, 0, result.stderr || result.stdout);
+ return { html: fs.readFileSync(outputPath, 'utf8'), outputPath, dataPath };
+}
+
+class FakeElement {
+ constructor() {
+ this.innerHTML = '';
+ this.textContent = '';
+ }
+}
+
+// Runs the inline renderer against a stub DOM.
+function executeInlineRenderer(html) {
+ const inline = html.match(/
+
+