Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion plugins/power-pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<table>/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 code 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
- Uses bounded, resumable review batches and complete CSV appendices for 100,000+ configurations
- Verifies all configuration scopes and deployment profiles contain zero wildcards

### Support

#### `/report-issue`
Expand Down
13 changes: 13 additions & 0 deletions plugins/power-pages/references/approval-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,19 @@ 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 `<category>` fixes to `<N>` 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 `<MIGRATED_FOLDER>` to `<ENV_NAME>`?"* — publishes the Bootstrap 5 site and auto-enables the runtime flag. | nothing |

---

### 6.32 `migrate-webapi-selectall` (4 calls / 3 gates + 1 data prompt)

Reviews traditional and code/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:2.confirm-scope` | gate | plan | 2 | Confirms all configuration scopes, wildcard and explicit counts, and source batches 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 to the displayed environment and profile. Repeat for another target. | local migration |

---
### Cross-plugin shared skills — out of catalog scope

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
'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 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('binds checkpoints to tables and environment', () => {
const first = schema.schemaRequestFingerprint(
'<environment-url-1>',
[TABLE_LOGICAL_NAME_2, TABLE_LOGICAL_NAME_1]
);
const reordered = schema.schemaRequestFingerprint(
'<environment-url-1>',
[TABLE_LOGICAL_NAME_1, TABLE_LOGICAL_NAME_2]
);
const otherEnvironment = schema.schemaRequestFingerprint(
'<environment-url-2>',
[TABLE_LOGICAL_NAME_1, TABLE_LOGICAL_NAME_2]
);

assert.equal(first, reordered);
assert.notEqual(first, otherEnvironment);
});

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', () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'webapi-schema-link-'));
const projectRoot = path.join(workspace, 'project');
const outside = path.join(workspace, 'outside');
fs.mkdirSync(projectRoot);
fs.mkdirSync(outside);
fs.writeFileSync(
path.join(outside, 'tables.txt'),
`${TABLE_LOGICAL_NAME_1}\n`,
'utf8'
);
fs.symlinkSync(outside, path.join(projectRoot, 'linked'), 'junction');

try {
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/
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

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]);
});
Loading
Loading