Skip to content

Added Migrate SDM to EDM skill for migration - #109

Open
Ashwani Kumar (ashwani123p) wants to merge 32 commits into
mainfrom
users/ashwanikumar/migrate-sdm-to-edm-skill
Open

Added Migrate SDM to EDM skill for migration#109
Ashwani Kumar (ashwani123p) wants to merge 32 commits into
mainfrom
users/ashwanikumar/migrate-sdm-to-edm-skill

Conversation

@ashwani123p

Copy link
Copy Markdown

No description provided.

@ashwani123p
Ashwani Kumar (ashwani123p) requested a review from a team as a code owner April 20, 2026 10:01
Copilot AI lite review requested due to automatic review settings April 20, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Power Pages skill (migrate-sdm-to-edm) to guide SDM→EDM migrations via PAC CLI, including HTML report templates and a Node utility to generate those reports.

Changes:

  • Introduces the migrate-sdm-to-edm skill workflow (SKILL.md) and accompanying design/integration docs.
  • Adds HTML templates for customization and execution reporting.
  • Adds a Node script to generate the HTML reports and registers the skill in skill-tracking mapping.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
plugins/power-pages/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js New CLI utility that parses PAC CSV output and renders HTML reports from templates
plugins/power-pages/skills/migrate-sdm-to-edm/assets/skill-execution-report.html New execution report HTML template with placeholders
plugins/power-pages/skills/migrate-sdm-to-edm/assets/customization-report.html New customization report HTML template with placeholders
plugins/power-pages/skills/migrate-sdm-to-edm/assets/README.md Documents template placeholders and intended usage
plugins/power-pages/skills/migrate-sdm-to-edm/SKILL.md New skill definition and phased migration guidance
plugins/power-pages/skills/migrate-sdm-to-edm/REPORTS_INTEGRATION.md Integration guidance for generating and sharing reports in the workflow
plugins/power-pages/skills/migrate-sdm-to-edm/DESIGN.md Design/spec for the migration skill
plugins/power-pages/references/skill-tracking-reference.md Adds the new skill to the skill-name mapping table

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +18 to +21
const fs = require('fs');
const path = require('path');
const { parse: parseCSV } = require('csv-parse/sync');

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

csv-parse/sync is required here, but there’s no csv-parse dependency anywhere in this repo (no package.json / lockfile references). As-is, running this script will fail with "Cannot find module 'csv-parse/sync'". Either add the dependency in the repo’s Node dependency manifest (and ensure install in the workflow) or replace this with a zero-dependency CSV parser appropriate for the PAC output.

Copilot uses AI. Check for mistakes.
.replace('{{WEBSITE_ID}}', escapeHtml(args['website-id'] || 'N/A'))
.replace('{{PORTAL_ID}}', escapeHtml(args['portal-id'] || 'N/A'))
.replace('{{MIGRATION_STATUS_TEXT}}', 'Completed Successfully')
.replace('{{REPORT_DATE}}', dateStr)

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: {{REPORT_DATE}} occurs twice in skill-execution-report.html, but this replacement only updates the first instance. Use replaceAll / global replacement for repeated placeholders.

Suggested change
.replace('{{REPORT_DATE}}', dateStr)
.replaceAll('{{REPORT_DATE}}', dateStr)

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +103
<tr>
<td>${item.location || 'N/A'}</td>
<td><div class="snippet">${escapeHtml(item.snippet || '')}</div></td>
<td><a href="${item.guidance}" target="_blank">View Guidance</a></td>
</tr>

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTML injection risk: item.location and item.guidance are inserted into the report without escaping/validation. A crafted CSV could inject markup/JS into the generated report. Escape item.location and either strictly validate item.guidance as an allowed URL (e.g., https://learn.microsoft.com/… / go.microsoft.com/fwlink/…) or escape it and render as plain text. Also add rel="noopener noreferrer" when using target="_blank".

Copilot uses AI. Check for mistakes.
> 1. Open the **Data workspace** in Power Pages
> 2. Create a new table (e.g., `contoso_webpage`)
> 3. Add the custom column (e.g., `contoso_pagetype`) to the new table
> 4. Add a lookup column associated with `powerpagescomponent`

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step references powerpagescomponent, but the rest of the skill (and examples below) use powerpagecomponent (no “s”). The inconsistent table name will mislead users during remediation; please correct it to the intended logical name.

Suggested change
> 4. Add a lookup column associated with `powerpagescomponent`
> 4. Add a lookup column associated with `powerpagecomponent`

Copilot uses AI. Check for mistakes.
For each custom column found on an `adx_*` table, instruct the user to:
1. Create a new custom table (e.g., `contoso_webpage`) in the Data workspace
2. Add the custom column (e.g., `contoso_pagetype`) to the new table
3. Add a lookup column associated with `powerpagescomponent`

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This remediation instruction uses powerpagescomponent, but elsewhere in this doc and in SKILL.md the table is referred to as powerpagecomponent. Please fix the inconsistency to avoid pointing users at a non-existent/incorrect table name.

Suggested change
3. Add a lookup column associated with `powerpagescomponent`
3. Add a lookup column associated with `powerpagecomponent`

Copilot uses AI. Check for mistakes.
.replace('{{SITE_NAME}}', escapeHtml(args['site-name'] || 'Unknown'))
.replace('{{WEBSITE_ID}}', escapeHtml(args['website-id'] || 'N/A'))
.replace('{{TEMPLATE_NAME}}', escapeHtml(args['template-name'] || 'Unknown'))
.replace('{{REPORT_DATE}}', new Date().toISOString().split('T')[0])

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The placeholder replacement uses String.prototype.replace, which only replaces the first occurrence. {{REPORT_DATE}} appears twice in the customization report template (metadata + footer), so the footer will keep the raw {{REPORT_DATE}}. Use replaceAll (or a global regex) for placeholders that can appear multiple times.

Suggested change
.replace('{{REPORT_DATE}}', new Date().toISOString().split('T')[0])
.replaceAll('{{REPORT_DATE}}', new Date().toISOString().split('T')[0])

Copilot uses AI. Check for mistakes.
Comment on lines +219 to +223
<div class="phase">
<div class="command-label">Step 3: Download Customization Report</div>
<div class="command-block">pac pages migrate-datamodel --webSiteId "{{WEBSITE_ID}}" --siteCustomizationReportPath "./migration-report"</div>
<div class="result-item success">
<div class="result-title">✓ Success</div>

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pacCommandsHtml hardcodes --webSiteId "{{WEBSITE_ID}}" inside the generated HTML string. Because this placeholder is not part of the outer HTML template, it will never be replaced and will render literally in the final report. Interpolate the actual website id into this string (escaped) or run placeholder substitution on generated sections too.

Copilot uses AI. Check for mistakes.
html += `
<tr>
<td>${item.location || 'N/A'}</td>
<td><div class="snippet">${escapeHtml(item.snippet || '')}</div></td>

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snippet is computed here but never used, and the generated HTML renders the full item.snippet anyway. This can bloat the report significantly for large snippets and makes the truncation logic dead code. Either use the truncated value in the HTML output or remove the unused variable.

Suggested change
<td><div class="snippet">${escapeHtml(item.snippet || '')}</div></td>
<td><div class="snippet">${escapeHtml(snippet)}</div></td>

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +36
// Parse command line arguments
function parseArgs(args) {
const result = {};
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].replace('--', '');
const value = args[i + 1];
if (value && !value.startsWith('--')) {
result[key] = value;
i++;
}
}
}
return result;
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new Node script, but there’s no corresponding node:test coverage. plugins/power-pages/AGENTS.md states that script additions/changes must ship with tests under plugins/power-pages/scripts/tests/. Add a test file that covers CSV parsing + placeholder replacement (including multi-occurrence placeholders) so regressions are caught.

Copilot uses AI. Check for mistakes.
Comment on lines +271 to +279
### CSV Parsing

The script uses `csv-parse` (npm package). Ensure it's available:

```bash
npm install csv-parse
```

Or modify the script to use a different CSV parser if needed.

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc instructs running npm install csv-parse, but the repo doesn’t currently have a Node dependency manifest where this would be captured/installed as part of the skill workflow. Given the script now requires csv-parse/sync, either document the exact supported installation mechanism for this repo (so CI/users get the dependency) or remove the external dependency and update this section accordingly.

Copilot uses AI. Check for mistakes.
Adds a pre-flight migration status check so the skill can detect
in-flight, completed, failed, or reverted migrations and route
accordingly — wait/reset/exit for running migrations, short-circuit
to Phase 11 for completed-but-not-flipped, retry for failed. Same
wait/reset/exit prompt now also fires on the 30-min polling timeout
in Phase 10, making long migrations resumable across sessions.

Key changes:
- Phase 4 (new): Check Existing Migration Status — uses
  `--checkMigrationStatus --verbose` for elapsed time and step history
- Phase 9 (was 10): Customization remediation moved before migration
  so automated fixes land on SDM tables before metadata is migrated
- Phase 10: 30-min polling ceiling escalates to wait/reset/exit
  instead of just bailing
- Phase 11: Portal Id auto-captured from `pac pages list -v` (newer
  PAC builds); manual `_services/about` lookup retained as fallback
- ALM branching removed from Phase 7; env type still captured for
  future ALM integration. Migration mode recommendation: Dev → `all`,
  Test/UAT/Prod → `configurationData`
- DESIGN.md and REPORTS_INTEGRATION.md updated to match 12-phase
  numbering; fixed stale `csv-parse` reference (script uses built-in
  parser, no npm deps)
Copilot AI review requested due to automatic review settings May 12, 2026 07:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 10 comments.

Comment on lines +48 to +50
```powershell
pac --version
```
Comment on lines +101 to +106
Look for `website.yml` in current directory:

```powershell
Test-Path .\website.yml
```

Comment on lines +408 to +414
```bash
node scripts/generate-migration-reports.js \
--customization-report "./migration-report/SiteCustomization.csv" \
--site-name "<SITE_NAME>" \
--website-id "<WEBSITE_ID>" \
--output-dir "./migration-reports"
```

Validate the pasted value is a GUID. Store it — it will also be needed for rollback in Phase 12.

> **Do not run the update command in step 2 until Portal Id is available.** Both `--updateDataModelVersion` and `--revertToStandardDataModel` reject empty Portal Id ([PAPortalMigrateDataModelVerb.cs:214](C:/Users/ashwanikumar/source/repos/PowerPlatform-Scale-AdminTools/src/cli/bolt.module.paportal/verbs/PAPortalMigrateDataModelVerb.cs#L214)).
Comment on lines +164 to +169
html += `
<tr>
<td>${item.location || 'N/A'}</td>
<td><div class="snippet">${escapeHtml(item.snippet || '')}</div></td>
<td><a href="${item.guidance}" target="_blank">View Guidance</a></td>
</tr>
Comment on lines +573 to +582
async function makeRequestWithRetry(options, maxRetries = 3, retryAfterMs = 10000) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const result = await makeRequest(options);
if (result.statusCode !== 429) return result;
if (attempt < maxRetries) {
const retryAfterHeader = result.headers && result.headers['retry-after'];
const waitMs = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1000 : retryAfterMs;
console.warn(`Rate limited (429). Waiting ${waitMs / 1000}s before retry ${attempt + 1}/${maxRetries}...`);
await new Promise(resolve => setTimeout(resolve, waitMs));
}
Comment on lines +340 to +352
// Generate migration phases section (all 11 phases from SKILL.md with detailed logs)
const allPhases = [
{
number: 1,
title: 'Verify Prerequisites',
description: 'PAC CLI, Dataverse, and Power Pages packages are at required versions',
details: [
'✓ PAC CLI version 1.32.1 detected (required: ≥1.31.6)',
'✓ Dataverse base portal package 9.3.2307.1 detected (required: ≥9.3.2307.x)',
'✓ Power Pages Core package 1.0.2309.63 detected (required: ≥1.0.2309.63)',
'✓ User has System Administrator role confirmed',
'✓ Environment connectivity verified'
]
Comment on lines +688 to +724
// Format: "Table name : annotation Column name : iscompressedName"
const snippet = extension.snippet || '';
const tableMatch = snippet.match(/Table name\s*:\s*(\w+)/);
const columnMatch = snippet.match(/Column name\s*:\s*(\w+)/);

if (!tableMatch || !columnMatch) {
remediationResults.manual.push({
type: 'Data Model Extension',
description: 'Could not parse table/column from snippet',
snippet: snippet,
location: extension.location,
reason: 'Unparseable format'
});
continue;
}

const tableLogicalName = tableMatch[1];
const columnLogicalName = columnMatch[1];

// Check if column already exists
const exists = await checkColumnExists(envUrl, tableLogicalName, columnLogicalName);

if (exists) {
remediationResults.manual.push({
type: 'Data Model Extension',
description: `Column ${columnLogicalName} already exists on ${tableLogicalName}`,
snippet: snippet,
location: extension.location,
reason: 'Column already exists'
});
continue;
}

// Create the column
const displayName = columnLogicalName.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim();
await createStringAttribute(envUrl, tableLogicalName, columnLogicalName, displayName);

Comment on lines +636 to +651
const attributeMetadata = {
'@odata.type': 'Microsoft.Dynamics.CRM.StringAttributeMetadata',
LogicalName: columnLogicalName,
DisplayName: {
'@odata.type': 'Microsoft.Dynamics.CRM.Label',
LocalizedLabels: [{
'@odata.type': 'Microsoft.Dynamics.CRM.LocalizedLabel',
Label: displayName,
LanguageCode: 1033
}]
},
MaxLength: 100,
IsNullable: true,
IsRetrievable: true,
IsSearchable: true
};
Comment on lines +1 to +5
#!/usr/bin/env node

/**
* generate-migration-reports.js
*
sparrow1303 and others added 2 commits May 12, 2026 15:22
Latest PAC CLI allows migration of all Power Pages and D365 portal
templates. Updates the skill to reflect this and adds a concrete
template-to-V2-solution mapping so Phase 6 can verify the right
package via `pac solution list` instead of asking the user.

- Phase 3: template options expanded with Event Registration and the
  four D365 portals; "Other/Unknown → stop if D365" rejection branch
  removed; reads `adx_templatename` from Phase 4 tracker when
  available to skip the prompt
- Phase 6: replaces the generic "do you have V2?" question with a
  15-row template → V2 UniqueName lookup table (sourced from
  powerportals.templates and crm.solutions.serviceportals repos);
  automated check via `pac solution list`
- Intro and DESIGN.md: drop the "Not migratable" section. Add a PAC
  CLI version note since D365 portal migration requires a recent
  build. Known Limitations rewritten to reflect universal V2
  requirement + PAC version dependency for D365

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
what the official migration doc describes as mechanical rewrites, and
gives accurate per-finding guidance for everything else. Closes the gap
where Phase 9 previously presented one generic blurb per category and
claimed an "automated" Data Model Extension fix that was effectively a
no-op
Copilot AI review requested due to automatic review settings May 13, 2026 08:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (4)

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js:1481

  • executeFetchXmlRewrites only runs the rewrite pass when the file contains <entity name="adx_*">, but the rewriter also supports <link-entity name="adx_*">. A file that only has link-entity references will be skipped and left unmodified. Update the pre-check to also trigger on <link-entity ... name='adx_...'> (or remove the pre-check and rely on changes.length).
  for (const file of files) {
    results.filesScanned++;
    const content = fs.readFileSync(file, 'utf-8');
    if (!/<entity\b[^>]*\bname\s*=\s*['"]adx_/.test(content)) continue;

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js:1340

  • The placeholder used to hide nested <link-entity> blocks uses literal NUL characters (\0) in the marker string. Writing these markers into intermediate strings is risky (can corrupt output if restoration fails, and can cause tooling/editors to treat content as binary). Use a safe ASCII sentinel token (e.g., __LINK_ENTITY_0__) instead.
    if (tagName === 'entity') {
      scanBody = body.replace(/<link-entity\b[\s\S]*?<\/link-entity>/g, (linkBlock) => {
        const placeholder = `�LINK_ENTITY_${linkEntityBlocks.length}�`;
        linkEntityBlocks.push(linkBlock);
        return placeholder;
      });

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js:66

  • LIQUID_OBJECT_MAP uses the key adx_weblinks (plural), but the standard portal entity and this file’s COMPONENT_TYPE_MAP use adx_weblink (singular). This mismatch will prevent the script from suggesting the correct dedicated Liquid object for entities['adx_weblink'] usages. Align the key with the actual entity logical name(s) you expect from the customization report.
// Source: https://learn.microsoft.com/en-us/power-pages/configure/liquid/liquid-objects
const LIQUID_OBJECT_MAP = {
  'adx_weblinkset': { object: 'weblinks', usage: "weblinks['<Web Link Set name>']" },
  'adx_weblinks': { object: 'weblinks', usage: "weblinks['<Web Link Set name>']" },
  'adx_contentsnippet': { object: 'snippets', usage: "snippets['<snippet name>']" },
  'adx_sitesetting': { object: 'settings', usage: "settings['<setting name>']" },
  'adx_sitemarker': { object: 'sitemarkers', usage: "sitemarkers['<marker name>']" },
  'adx_ad': { object: 'ads', usage: "ads['<ad name>']" },

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js:270

  • Template placeholder substitution uses String.prototype.replace, which only replaces the first occurrence. Several placeholders (e.g., {{REPORT_DATE}}) appear multiple times in the templates (header + footer), so generated reports will retain unreplaced tokens (as visible in the checked-in sample reports). Use replaceAll (Node 16+) or global regex replacements for each placeholder.
  // Replace placeholders
  template = template
    .replace('{{SITE_NAME}}', escapeHtml(args['site-name'] || 'Unknown'))
    .replace('{{WEBSITE_ID}}', escapeHtml(args['website-id'] || 'N/A'))
    .replace('{{TEMPLATE_NAME}}', escapeHtml(args['template-name'] || 'Unknown'))
    .replace('{{REPORT_DATE}}', new Date().toISOString().split('T')[0])
    .replace('{{TOTAL_CUSTOMIZATIONS}}', totalCustomizations.toString())
    .replace('{{SUMMARY_TEXT}}', summaryText)
    .replace('{{CUSTOMIZATIONS_SECTIONS}}', customizationSections);

Comment on lines +432 to +440
3. **Generate HTML Report**

```bash
node scripts/generate-migration-reports.js \
--customization-report "./migration-report/SiteCustomization.csv" \
--site-name "<SITE_NAME>" \
--website-id "<WEBSITE_ID>" \
--output-dir "./migration-reports"
```

Validate the pasted value is a GUID. Store it — it will also be needed for rollback in Phase 12.

> **Do not run the update command in step 2 until Portal Id is available.** Both `--updateDataModelVersion` and `--revertToStandardDataModel` reject empty Portal Id ([PAPortalMigrateDataModelVerb.cs:214](C:/Users/ashwanikumar/source/repos/PowerPlatform-Scale-AdminTools/src/cli/bolt.module.paportal/verbs/PAPortalMigrateDataModelVerb.cs#L214)).
Comment on lines +943 to +947
<li>Files modified: <strong>4</strong></li>
<li>Entity / link-entity rewrites: <strong>4</strong></li>
<li>Skipped (manual review needed): <strong>0</strong></li>
<li>Diff file: <code>C:\Users\ashwanikumar\AppData\Local\Temp\sample-build\output\fetchxml-rewrites.diff</code></li>
</ul>
Comment on lines +215 to +219
<tr>
<td>${item.location || 'N/A'}</td>
<td><div class="snippet">${escapeHtml(item.snippet || '')}</div></td>
<td><a href="${item.guidance}" target="_blank">View Guidance</a></td>
</tr>
Comment on lines +1 to +24
#!/usr/bin/env node

/**
* generate-migration-reports.js
*
* Generates HTML reports from migration data and customization CSV.
*
* Usage:
* node generate-migration-reports.js \
* --siteCustomizationReportPath "path/to/SiteCustomization.csv" \
* --site-name "Contoso Portal" \
* --website-id "076bf556-9ae6-ee11-a203-6045bdf0328e" \
* --portal-id "07f35d71-c45a-4a05-9702-8f127559e48e" \
* --output-dir "./reports" \
* [--execution-data "phase1,phase2,phase3"] \
* [--env-url "https://org.crm.dynamics.com"] \
* [--automate]
*/

const fs = require('fs');
const path = require('path');
// const { parse: parseCSV } = require('csv-parse/sync');
const { getAuthToken, makeRequest, getEnvironmentUrl } = require('../../../scripts/lib/validation-helpers');

Comment on lines +51 to +67
### Phase 9: Automated Remediation (subset)

For automatable fixes (Data Model Extensions only), the same script is invoked with `--automate` and `--env-url`:

```bash
node "${CLAUDE_PLUGIN_ROOT}/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js" \
--site-name "<SITE_NAME>" \
--website-id "<WEBSITE_ID>" \
--siteCustomizationReportPath "./migration-report/SiteCustomization.csv" \
--env-url "https://org.crm.dynamics.com" \
--automate \
--environment-type "<ENV_TYPE>" \
--output-dir "./migration-reports"
```

The script creates missing string attributes via Dataverse Web API and logs results into the execution report. All other customization types (Liquid, FetchXML, plugins, workflows) are flagged as manual.

Comment on lines +547 to +551
<!-- Remediation Required Section -->
<div class="section" id="remediation-section" style="display: {{REMEDIATION_DISPLAY}};">
<h2>
<span class="section-icon">⚠️</span>
Post-Migration Remediation Required
'adx_columnpermission': 29,
'adx_redirect': 30,
'adx_publishingstatetransitionrule': 31,
'adx_shortcut': 32,
sparrow1303 and others added 2 commits May 22, 2026 13:01
Consolidates the previous 12-phase skill into 4 high-level phases for a cleaner
mental model, while preserving every existing sub-step as a numbered section
within its phase. Also fixes several path-handling and PAC CLI quirks
encountered while testing the skill end-to-end against a real site.

Phase consolidation:

- Phase 1 Pre-flight Setup (sub-steps 1.1-1.7): CLI context, site identification
  and discovery, prior migration state, dependency and template-package
  validation, env type + migration mode selection
- Phase 2 Customization Remediation (sub-steps 2.1-2.2): generate customization
  report, then remediate via download/rewrite/review/upload with final
  readiness gate
- Phase 3 Migration Execution (sub-steps 3.1-3.2): run migrate-datamodel with
  selected mode and polling, then flip data model version
- Phase 4 Post-Migration Validation (sub-step 4.1): validation checklist,
  optional rollback, final execution report

Agent now creates 4 todos instead of 12; sub-steps are internal execution
detail. Progress Tracking table reduced from 12 rows to 4. All cross-references
updated to use precise sub-step numbers (e.g., 'step 1.4' for migration-status
check, 'step 3.2' for data model flip).

Path and directory handling:

- Unified output folder name to ./migration-reports/ throughout (was a
  confusing mix of singular 'migration-report' for CSV and plural for HTML).
  PAC's CSV and the script's HTML reports now land in the same directory
- Step 1.2 now runs site-detection logic and resolves <SITE_ROOT> and
  <OUTPUT_DIR> as durable values. Handles three scenarios: cwd IS the site,
  site sits in a subdirectory, or site needs to be downloaded
- Documented the pac pages download nested-folder quirk (creates
  ./mysite/<slug>/, not ./mysite/) so upload commands use the correct path
- Extended normalizeLocationPath() to strip both Windows extended-path
  prefix (\?\) AND the temp-dir prefix (...\Temp\<site-slug>\) from CSV
  Location values, returning clean relative paths usable in HTML reports

PAC CLI quirks captured:

- Step 1.5/1.6 now uses 'pac solution list --includeSystemSolutions' (first-
  party solutions like CDSBasePortal and template V2 packages are otherwise
  omitted)
- Corrected solution UniqueName from MicrosoftCRMPortalBase to CDSBasePortal
  (verified against PAC source PAPortalCommon.cs:627)
- Documented pac pages upload not accepting --webSiteId (infers from path)
  and the --modelVersion 1 requirement when uploading to SDM site
- Added AskUserQuestion ≥2-options requirement note (single-option call
  errors with 'expected array to have >=2 items')
- Phase 8/2.1 now Globs for SiteCustomization*.csv across cwd and output dir
  (PAC sometimes writes to varying locations on auto-numbered re-runs)

DESIGN.md additions:

- New 'Directory Layout' section documents Scenario A (working dir) and
  Scenario B (cwd IS the site) with the resolution algorithm and full output
  artifacts table
- Sub-step to PAC command mapping table replaces the old 12-row breakdown
- Future Work entry added for automated post-migration validation test cases
  (Playwright-driven smoke tests) — to be picked up in a follow-up session

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 22, 2026 07:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 10 comments.


| Question | Header | Options |
|----------|--------|---------|
| `pac pages list -v` did not return a usable Portal Id for this site. Open `<SITE_URL>/_services/about` and paste the `portalId` value from the JSON response | Portal ID | I'll paste the Portal ID |

Validate the pasted value is a GUID. Store it — it will also be needed for rollback in Phase 4.

> **Do not run the update command in step 2 until Portal Id is available.** Both `--updateDataModelVersion` and `--revertToStandardDataModel` reject empty Portal Id ([PAPortalMigrateDataModelVerb.cs:214](C:/Users/ashwanikumar/source/repos/PowerPlatform-Scale-AdminTools/src/cli/bolt.module.paportal/verbs/PAPortalMigrateDataModelVerb.cs#L214)).
Comment on lines +1363 to +1366
scanBody = body.replace(/<link-entity\b[\s\S]*?<\/link-entity>/g, (linkBlock) => {
const placeholder = `LINK_ENTITY_${linkEntityBlocks.length}`;
linkEntityBlocks.push(linkBlock);
return placeholder;
}

html += `
<p style="margin-top: 12px;"><strong>Next step:</strong> review the diff files, then run <code>pac pages upload --path &lt;site-path&gt; --webSiteId &lt;GUID&gt;</code> to push the rewritten source back to Dataverse.</p>
Comment on lines +447 to +513
function generateExecutionReportHtml(args, remediationResults = null, customizations = {}, autoRewriteResults = {}) {
const templatePath = path.join(__dirname, '../assets/skill-execution-report.html');
let template = fs.readFileSync(templatePath, 'utf-8');

const now = new Date();
const dateStr = now.toISOString().split('T')[0];

// Generate prerequisites items (example structure)
const prerequisitesHtml = `
<div class="prerequisite-item">
<div class="check-icon success">✓</div>
<div class="prerequisite-content">
<div class="prerequisite-title">PAC CLI Version</div>
<div class="prerequisite-description">v1.31.6 or higher is installed</div>
</div>
</div>
<div class="prerequisite-item">
<div class="check-icon success">✓</div>
<div class="prerequisite-content">
<div class="prerequisite-title">Dataverse Package Version</div>
<div class="prerequisite-description">Dataverse base portal package 9.3.2307.x or higher is installed</div>
</div>
</div>
<div class="prerequisite-item">
<div class="check-icon success">✓</div>
<div class="prerequisite-content">
<div class="prerequisite-title">Power Pages Core Package</div>
<div class="prerequisite-description">Power Pages Core 1.0.2309.63 or higher is installed</div>
</div>
</div>
<div class="prerequisite-item">
<div class="check-icon success">✓</div>
<div class="prerequisite-content">
<div class="prerequisite-title">User Role</div>
<div class="prerequisite-description">User has System Administrator role</div>
</div>
</div>
`;

// Generate PAC commands section (placeholder)
const pacCommandsHtml = `
<div class="phase">
<div class="command-label">Step 1: Verify Authentication</div>
<div class="command-block">pac auth who</div>
<div class="result-item success">
<div class="result-title">✓ Success</div>
<div class="result-description">Authenticated to environment successfully</div>
</div>
</div>
<div class="phase">
<div class="command-label">Step 2: List Available Sites</div>
<div class="command-block">pac pages list</div>
<div class="result-item success">
<div class="result-title">✓ Success</div>
<div class="result-description">Found target site for migration</div>
</div>
</div>
<div class="phase">
<div class="command-label">Step 3: Download Customization Report</div>
<div class="command-block">pac pages migrate-datamodel --webSiteId "{{WEBSITE_ID}}" --siteCustomizationReportPath "./migration-reports"</div>
<div class="result-item success">
<div class="result-title">✓ Success</div>
<div class="result-description">Customization report downloaded and analyzed</div>
</div>
</div>
`;

<tr>
<td>${item.location || 'N/A'}</td>
<td><div class="snippet">${escapeHtml(item.snippet || '')}</div></td>
<td><a href="${item.guidance}" target="_blank">View Guidance</a></td>
Comment on lines +1661 to +1663
console.log('\nReports generated successfully!');
console.log(`Open in browser: file://${path.resolve(customizationPath)}`);
} catch (error) {
Comment on lines +51 to +67
### Phase 9: Automated Remediation (subset)

For automatable fixes (Data Model Extensions only), the same script is invoked with `--automate` and `--env-url`:

```bash
node "${CLAUDE_PLUGIN_ROOT}/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js" \
--site-name "<SITE_NAME>" \
--website-id "<WEBSITE_ID>" \
--siteCustomizationReportPath "./migration-report/SiteCustomization.csv" \
--env-url "https://org.crm.dynamics.com" \
--automate \
--environment-type "<ENV_TYPE>" \
--output-dir "./migration-reports"
```

The script creates missing string attributes via Dataverse Web API and logs results into the execution report. All other customization types (Liquid, FetchXML, plugins, workflows) are flagged as manual.

- Dependency and template-package validation
- Environment-aware migration mode recommendation (Dev → `all`, Test/UAT/Prod → `configurationData`)
- Customization report generation (always, for any environment)
- Pre-migration customization remediation (manual guidance + automated fixes for Data Model Extensions)
Comment on lines +1 to +24
#!/usr/bin/env node

/**
* generate-migration-reports.js
*
* Generates HTML reports from migration data and customization CSV.
*
* Usage:
* node generate-migration-reports.js \
* --siteCustomizationReportPath "path/to/SiteCustomization.csv" \
* --site-name "Contoso Portal" \
* --website-id "076bf556-9ae6-ee11-a203-6045bdf0328e" \
* --portal-id "07f35d71-c45a-4a05-9702-8f127559e48e" \
* --output-dir "./reports" \
* [--execution-data "phase1,phase2,phase3"] \
* [--env-url "https://org.crm.dynamics.com"] \
* [--automate]
*/

const fs = require('fs');
const path = require('path');
// const { parse: parseCSV } = require('csv-parse/sync');
const { getAuthToken, makeRequest, getEnvironmentUrl } = require('../../../scripts/lib/validation-helpers');

sparrow1303 and others added 8 commits May 22, 2026 13:50
The skill should not modify customer-owned code directly. Plugins live in the
customer's plugin source repo, and Dataverse schema changes should go through
a reviewable solution package — not direct API calls. For both, the script
now generates paste-ready augmented prompts that the user takes to a fresh
Claude Code session, which then does the work and surfaces a diff or artifact
for review before applying.

Plugin remediation prompt:

- Static template at scripts/prompts/plugin-remediation.template.txt with a
  {{PLUGIN_FINDINGS_BLOCK}} placeholder
- Filled at runtime with the customer's actual plugin findings (name, target
  entity, step name) — categorized as Microsoft (no action) / Adxstudio
  (verify V2) / custom (refactor)
- Instructs the receiving session to locate plugin source, refactor entity
  references to powerpagecomponent + inject powerpagecomponenttype filter on
  queries, preserve adx_* attribute references (still valid logical names on
  EDM), update step registration metadata, and show a diff before saving
- Explicit constraints: no production push, no guessing file locations, no
  rewriting GetAttributeValue<T>('adx_name') calls

DME remediation prompt (solution-package approach):

- Static template at scripts/prompts/dme-remediation.template.txt with a
  {{DME_TABLE_GROUPS_BLOCK}} placeholder
- Filled with per-table groupings already produced by
  buildDataModelExtensionChecklists()
- Instructs the receiving session to ask for a publisher prefix, build a
  Dataverse solution package with new custom tables and lookups to
  powerpagecomponent, and produce a .zip ready for pac solution import
- Documents the data-migration step (Power Automate flow OR C# console app
  with batched ExecuteMultipleRequest) — outside solution scope, user runs
  separately
- Explicit constraints: no direct Dataverse API calls, all schema decisions
  reviewed before pack, default to Unmanaged solutions, ask before using
  existing publishers

Surfacing to user (three places):

- Standalone .txt files in <OUTPUT_DIR>/ (plugin-remediation-prompt.txt and
  dme-remediation-prompt.txt) so offline users can access
- Embedded in skill-execution-report.html under collapsible <details> blocks
  with copy-to-clipboard buttons (self-contained navigator.clipboard.writeText
  — no external JS)
- Terminal banner at end of script run with file paths and copy-paste
  instructions

Bug fix in categorizePlugin():

- Old regex (Step name\s*:\s*([^\n]*?)(?:\s\s+Entity Name|$)) mis-handled
  the empty-step case (e.g., Microsoft.Crm.* plugins with no step name),
  greedily capturing 'Entity Name : <entity>' as the step
- Replaced with split-by-2+-whitespace parser + per-field label matching,
  which correctly returns null for empty fields

SKILL.md step 2.2 section 7:

- Replaced the generic 'user must, in the Data workspace...' manual
  remediation with paste-ready augmented-prompt callouts pointing to the
  .txt files and explaining how to use each

DESIGN.md additions:

- New 'Augmented Prompts for Customer-Owned Code' section documenting the
  design rationale (why prompts beat direct execution), template storage
  (scripts/prompts/), runtime substitution, three surfacing locations, and
  coverage table (plugin/DME yes; custom workflows still doc-text-only
  until per-workflow Dataverse queries are added)

Sample reports regenerated against comprehensive-sample.csv (51 findings)
to demonstrate the new augmented-prompt sections end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eport

Three Stage samples showing the same section structure end-to-end:
header (light blue->blue) with progress bar/stats, site card,
4 phase cards, approval banner, augmented prompts section, footer.

- stage0-initialized.html: skill launched, awaiting Phase 1 approval
- stage1-migration-plan-pending-approval.html: Phase 1 done, awaiting Phase 2 approval
- stage2-mid-execution.html: Phase 2 in progress, in-phase upload approval gate,
  plugin + DME prompts populated

Approval banner appears at every phase boundary (including before Phase 1)
plus any in-phase user-action gates (e.g., diff review before upload).
Augmented prompts section is always present; placeholder cards in early
stages, populated once step 2.1 generates them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three new files, no SKILL.md wiring yet — that lands in a follow-up so it can
be reviewed separately.

scripts/lib/migration-state-schema.js
- buildInitialState({webSiteId, outputDir}) — skeleton matching stage0 sample
- Constants: PHASE_STATUS, SUB_STEP_STATUS, APPROVAL_KIND, PROMPT_STATUS
- PHASE_BLUEPRINT: canonical 4-phase / 12-sub-step structure

scripts/lib/render-live-report.js
- Pure render(state) -> HTML, matches the 3 committed reference samples
- Status pill / subtitle derive from in-progress phase + approval gate state
- Approval-banner copy keyed by (phaseId, kind); ships with copy for all 4
  phase-start gates plus the in-phase 2.2 upload gate
- Completed phases auto-collapse into <details> once a later phase is active

scripts/update-state.js (CLI)
- --init / --set-site / --set-step / --set-phase / --set-approval /
  --clear-approval / --set-prompt / --set-activity / --clear-activity /
  --render-only
- Every mutation bumps lastUpdatedAt and re-renders skill-execution-report.html

Validated end-to-end against the 3 sample stages (init -> Phase 1 done w/
gate -> Phase 2 in-progress w/ prompts ready + in-phase gate). DOM tag
balance verified (102 opens / 102 closes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a "Live Execution Report" convention section at the top of SKILL.md
plus 12 inline "→ Update report" callouts at each natural checkpoint:

- 1.2 end: --init (if WebSiteId known) + batched 1.1/1.2 completion
- 1.3 end: --set-site (name/slug/template/etc) + 1.3 completion
- 1.4-1.6 end: sub-step completion lines
- 1.7 end: phase 1 boundary — site env/mode, phase 1 completed,
           approval gate for phase 2
- 2.1 end: sub-step completion with finding counts
- 2.2 mid: --set-prompt plugin + dme, --set-approval 2 in-phase (diff review)
- 2.2 upload done: --clear-approval
- 2.2 end: phase 2 boundary — phase 2 completed, approval gate for phase 3
- 3.1 polling: --set-activity per attempt, --clear-activity on completion
- 3.2 end: phase 3 boundary — portalId/EDM site update, approval gate for phase 4
- 4.1 end: final completion (header pill flips to "Migration Complete")

The convention section documents all available --commands as a single table,
plus the best-effort policy: if update-state.js fails (e.g., node missing),
log a warning and continue — never block migration work on the live report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two pure-Node scripts and SKILL.md wiring for Phase 4 validation.

scripts/snapshot-site.js
- Walks a `pac pages download` site tree and writes a deterministic JSON
  catalog: counts + identity-only inventory per artifact category
- 24 categories covered (web pages incl. parent/content split, snippets,
  weblink sets w/ child link counts, templates, files, page templates,
  table permissions, forms, lists, polls, settings, roles, markers,
  websiteaccess/binding/language, ads, tags, urlhistory, …)
- Handles three real on-disk layouts seen in PAC output:
    * per-name folder w/ localized yml (snippets, weblink sets)
    * flat folder of <Name>.<kind>.yml (web files, page templates,
      table permissions)
    * top-level singular collection file (sitesetting.yml, webrole.yml,
      sitemarker.yml, …) — array-of-objects yml
- Identity keys are Dataverse-side (adx_name, partialUrl, language); slug
  is folder-name-only and kept as a convenience field, not a diff key
- `--with-content-hash` opt-in for SHA-256 of companion content files
  (webpage copy HTML, snippet value HTML, web template source)
- Validated against a real downloaded site: 276 records, zero parse errors

scripts/diff-snapshots.js
- Pairs SDM and EDM snapshots by identity key, classifies per category
- Per-category status: pass | warn | fail
    * fail: missing-in-edm, extra-in-edm, or count mismatch
    * warn: identity match but stateCode / value / contentHash drift
    * pass: identical
- Normalizes statecode (null and 0 both = active) — PAC omits the field
  on active records
- Exit code 1 on overall fail so SKILL.md can branch on $?
- Console summary shows the first 5 offenders per failing category for
  fast eyeballing; full detail in migration-data-diff.json

SKILL.md
- New step 2.5 (after pac pages download, before any rewrites): capture
  SDM baseline. Snapshot has to happen before rewrites because the auto
  rewriters mutate on-disk YAML.
- Phase 4.1 rewritten as a 9-step flow:
    1. pac pages download --modelVersion 2 to grab the migrated EDM site
    2. snapshot EDM
    3. diff SDM vs EDM
    4. surface the diff; user picks continue / rollback / pause
    5. print /test-site hand-off (not invoked from inside this skill —
       just a printed recommendation, separate user-invoked skill)
    6. final validation status question
    7. optional rollback via existing --revertToStandardDataModel path
    8. success summary now includes data-diff status
    9. skill-tracking record

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "Pre-flight Setup" name was too vague — it didn't convey what the
phase actually does (site identification, dependency checks, V2 package
validation, env/mode selection).

Renamed across all artifacts: SKILL.md, DESIGN.md, state schema, renderer
approval-banner copy, and the three sample HTMLs. Verified by re-running
update-state.js --init and grepping the rendered report — the new title
appears in the subtitle, phase card heading, and approval banner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits the Phase 2 + Phase 3 flow into two tracks selected at end of 1.7
based on the chosen migration mode:

  Track A — mode = configurationData or all
            (Dev / Test/UAT / Single env)
    P2: Configuration Migration & Customization Remediation
      2.1 Migrate Metadata  (auto-emits SiteCustomization.csv in newer PAC)
      2.2 Locate Customization Report (fallback if PAC didn't emit)
      2.3 Remediate Customizations  (always snapshots SDM source for P4
                                     diff; rewrite+upload only if findings)
    P3: Activation
      3.1 Activate EDM (--updateDataModelVersion --portalId)
      3.2 Restart Site (manual via PP admin center)
    Total: 13 sub-steps

  Track B — mode = configurationDataReferences (Prod, ALM assumed)
    P2: Setting Up Metadata
      2.1 Verify Site in Target Environment  (pac pages list -v)
      2.2 Import Metadata if Missing  (3 options: ALM skill /
                                       Solution Import / PAC CLI)
      2.3 Confirm Metadata Ready
    P3: Runtime Data Migration & Activation
      3.1 Migrate Transactional References  (snapshots SDM + migrates;
                                             auto-emits customization CSV)
      3.2 Locate Customization Report
      3.3 Remediate Customizations  (with stronger warning — ALM gap)
      3.4 Activate EDM
      3.5 Restart Site (manual)
    Total: 16 sub-steps

Step 1.7 expanded:
- 4 env options (Dev / Test/UAT / Prod / Single env)
- Env-based mode recommendation (Dev/Test/UAT/Single→configurationData,
  Prod→configurationDataReferences) with override warnings
- Track derived from final mode and persisted via --set-track

Schema (migration-state-schema.js):
- Added TRACK constant, DEFAULT_TRACK, PHASE_BLUEPRINTS_BY_TRACK
- rebuildPhasesForTrack() preserves P1 + P4 across track swaps
- buildInitialState() accepts track parameter, defaults to A
- TOTAL_SUB_STEPS removed (now computed per-state since totals differ
  by track: A=13, B=16)

Renderer (render-live-report.js):
- totalSubSteps(state) computes from current phases (no constant)
- APPROVAL_COPY keyed by `<phaseId>:<kind>:<track>` with bare-key fallback
- Track-aware copy for phase-2 and phase-3 start gates;
  1:phase-start, 2:in-phase, 4:phase-start stay track-agnostic

CLI (update-state.js):
- New --set-track A|B command, invoked at end of 1.7 by SKILL.md
- Idempotent (skips work if track already matches)

Samples regenerated for Track A (the more common path):
- stage0-initialized.html (track A default, awaiting Phase 1 approval)
- stage1-migration-plan-pending-approval.html (Phase 1 done, awaiting
  Phase 2 approval, Track A copy in banner)
- stage2-mid-execution.html (Phase 2 mid-execution, 2.1/2.2 done,
  2.3 in-progress with in-phase upload approval, prompts ready)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bottom-of-SKILL.md Progress Tracking table — which the agent reads
in step 1.1 to build the initial task list — still had the pre-track
names ("Customization Remediation" / "Migration Execution") even after
the track-branching refactor renamed the phase headings.

Updated to umbrella names ("Configuration Setup" / "Migration Execution")
matching the actual phase section headings, plus a track-aware naming
note so the agent surfaces the track-specific title once step 1.7 sets
state.track.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 27, 2026 05:28
sparrow1303 and others added 4 commits June 1, 2026 13:07
Three independent SKILL.md bugs surfaced while testing the skill on a
real Preprod site (Site 5 / Starter layout 4):

(1) AskUserQuestion option-limit error in step 1.3 template prompt
    The flat 16-option template list errored with InputValidationError
    too_big (max 4 options). Agent retried 3 times before chunking
    manually. SKILL.md now spells out a 2-question tree: pick family
    first (4 options), then drill to specific template (<=4 each).

(2) PowerShell quoting blew up on parens in --set-site JSON
    "currentDataModel":"Standard (SDM) - eligible for migration" fails
    in PowerShell because (SDM) is parsed as subexpression invocation
    -- gives "SDM: The term 'SDM' is not recognized". Fixed by
    simplifying JSON examples (Standard SDM / Enhanced EDM, no parens
    or em-dashes) and adding a callout telling the agent to use Bash
    tool for update-state.js calls (sh has no subexpression-parens
    gotcha; single-quoted JSON works cleanly).

(3) pac pages migrate-datamodel --checkMigrationStatus --verbose fails
    on PAC 1.47.1+ with "An unknown argument --verbose was passed".
    Agent ran the command twice (failed, then succeeded). Removed
    --verbose from the example and added a build-note about what
    information is no longer available on current PAC builds.

No state-schema or renderer changes -- purely SKILL.md prose.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Output line claimed "customization CSV auto-emitted by PAC" as a
flat fact, which is only true on newer PAC builds. On older builds the
CSV is NOT emitted by 2.1 and step 2.2's explicit-path fallback is
what produces it.

Reworded to call out the version-dependence so the agent doesn't move
on assuming the file exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
(3) Polling loop spun forever when pac wasn't on Bash PATH
Earlier the Phase 2.1/3.1 polling examples said "use this command,
poll every minute, up to 30 attempts" without giving a concrete loop
recipe. The agent improvised an `until [ "$(pac ... | grep ...)" != "" ]`
loop in Bash. When pac wasn't on the Bash $PATH (which varies by how
PAC was installed), the subshell silently produced empty output, the
until condition stayed false, and "Still running..." printed for 12+
iterations after the migration had actually completed.

Replaced with an explicit, bounded PowerShell `for ($i=1; $i -le 30)`
loop in SKILL.md so the agent doesn't have to improvise. Added a
"why this matters" callout explaining the root cause so future
testers understand.

(4) Portal ID URL construction was brittle
Step 3.4 had a cloud-to-domain table (Public/UsGov/UsGovHigh/UsGovDod/
China) for constructing <slug>.<cloud-domain> so user could fetch
portalId from /_services/about. In a real Preprod test, the cloud
wasn't in the table, the agent improvised wrong URL patterns
(<slug>.preprod.powerappsportals.com), user interrupted and pasted
the Portal ID directly.

Dropped the URL construction table entirely. Now: if step 1.3
captured Portal ID from `pac pages list -v`, use it; otherwise ask
the user to paste it directly, with multiple discovery hints (PP
admin center site detail panel, /_services/about, pac data query).
Simpler, more robust, works on every cloud.

(5) Data diff classified SDM-only categories as FAIL
`tags` and `websiteBindings` categories don't appear in `pac pages
download --modelVersion 2` YAML output at all — they're SDM-only
serialization. Records still exist in Dataverse as powerpagecomponent
rows, but the EDM YAML doesn't surface them. The diff was reporting
"SDM=4, EDM=0 — 4 missing in EDM, FAIL" for tags and similar for
websiteBindings, which is misleading.

Added SDM_ONLY_CATEGORIES allowlist in diff-snapshots.js. When the
category is in the allowlist AND edmCount===0 AND sdmCount>0 AND
there's nothing extra in EDM, classify as `warn` (expected-difference)
instead of `fail`. Console output labels these clearly:
   "SDM-only category (not in EDM YAML format; records still in Dataverse)"
Initial allowlist: tags, websiteBindings (identified from real test).
Documented how to add more as they're discovered.

Smoke test with synthetic mismatch on the dogfooded site confirms:
overall status is now WARN (not FAIL), exit code is 0 (not 1), other
categories still classify normally.

(6) Final validation question was confusing
Step 4.1 step 5 combined the /test-site hand-off recommendation and
the final validation status into one AskUserQuestion. User rejected
it and asked "so will it run test skill as well for validation or is
it completed?"

Split into two distinct moments:
- Step 5 now PRINTS a hand-off message in chat (informational text
  only, not a question). Explicitly says "/test-site is NOT
  auto-invoked" and explains why.
- Step 6 then asks the validation status separately.

Added explanation that /test-site uses the live URL — no re-download
needed; site has same URL post-migration with EDM data underneath.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…e as a recommended sub-step

User wanted Phase 4's /test-site recommendation to be a real sub-step
in the live report rather than a hand-off buried inside the validation
section. Done. Phase 4 expanded from 1 sub-step to 3:

  4.1 Data Diff Validation (SDM <-> EDM)
  4.2 Runtime Smoke Test Recommendation (/test-site)
  4.3 Final Status, Optional Rollback, Summary

Updated totals: Track A = 16 sub-steps, Track B = 18 sub-steps.

SCHEMA (migration-state-schema.js)
  PHASE_4 now has three sub-steps instead of one. No track variation
  (Phase 4 stays identical for both tracks).

SKILL.md
  Step 4.1 (Data Diff Validation): re-download EDM, snapshot, diff
    SDM vs EDM, surface result via AskUserQuestion. User picks
    "Looks fine" (-> 4.2) / "Concerning" (-> 4.3 rollback) / "Pause".
  Step 4.2 (Runtime Smoke Test Recommendation): prints the /test-site
    hand-off message in chat (not auto-invoked, see rationale), then
    asks AskUserQuestion so the sub-step actually tracks to a real
    user response: "all passed / issues found / skip / decline".
    This makes 4.2 a meaningful sub-step in the live report instead
    of just a printed message that gets buried.
  Step 4.3 (Final Status): the existing final-status / rollback /
    success-summary flow, renumbered.

  Progress Tracking table updated:
    Phase 4 has 3 sub-steps in both tracks.
    Track A total = 16 (was 14). Track B total = 18 (was 16).

RATIONALE for not auto-invoking /test-site
  Kept the recommendation model rather than cross-skill invocation:
  /test-site is interactive (asks user to log in for auth-gated
  sites), and auto-invoke would deny the user the chance to pick a
  browser session / login profile. The new AskUserQuestion-tracked
  hand-off pattern gives 4.2 a real lifecycle (pending -> completed)
  in the live report without changing the invocation semantics.

SAMPLES
  Stage 0/1/2 regenerated with new total counts.
  Dogfooded report at C:\Users\ashwanikumar\source\migration\reports
  also refreshed (re-init was needed since --render-only doesn't
  restructure the phases array on existing state files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 1, 2026 09:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 23 changed files in this pull request and generated 9 comments.

Comment on lines +6 to +7
* Every mutating command (a) loads state.json, (b) applies the change, (c) bumps
* lastUpdatedAt, (d) writes state.json back, and (e) re-renders the HTML report.
Comment on lines +365 to +367
const link = prompt.path
? `<div class="links"><a href="${escapeHtml(prompt.path)}">📄 ${escapeHtml(prompt.path.replace(/^.*[\\/]/, ''))}</a></div>`
: '';
Comment on lines +294 to +309
function main() {
const args = parseArgs(process.argv.slice(2));
try {
if (args.init) return cmdInit(args);
if (args['set-site'] !== undefined) return cmdSetSite(args);
if (args['set-step'] !== undefined) return cmdSetStep(args);
if (args['set-phase'] !== undefined) return cmdSetPhase(args);
if (args['set-approval'] !== undefined) return cmdSetApproval(args);
if (args['clear-approval']) return cmdClearApproval(args);
if (args['set-prompt'] !== undefined) return cmdSetPrompt(args);
if (args['set-activity'] !== undefined) return cmdSetActivity(args);
if (args['clear-activity']) return cmdClearActivity(args);
if (args['set-track'] !== undefined) return cmdSetTrack(args);
if (args['render-only']) return cmdRenderOnly(args);
console.error('No command given. Run with --help-ish docs at top of update-state.js');
process.exit(2);
Comment on lines +602 to +612
module.exports = {
renderLiveReport,
// exposed for unit-test reuse
_internals: {
escapeHtml,
escapeHtmlAllowingInline,
countCompleted,
overallPercent,
formatElapsed,
},
};
Comment on lines +88 to +113
// Pull each top-level "- " block out of a collection YAML and turn each block
// into a flat record by line-parsing. parseSimpleYaml doesn't handle arrays
// of objects, so we do this directly. Sufficient for sitesetting.yml,
// webrole.yml, sitemarker.yml, and the *.weblinkset.weblink.yml sibling.
function parseCollectionFile(filePath, errors) {
const records = [];
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
let current = null;
for (const rawLine of lines) {
if (/^- /.test(rawLine)) {
if (current) records.push(current);
current = {};
const rest = rawLine.slice(2);
addKeyValueToRecord(current, rest);
} else if (current && /^\s+/.test(rawLine) && rawLine.trim()) {
addKeyValueToRecord(current, rawLine.trim());
}
}
if (current) records.push(current);
} catch (e) {
errors.push({ filePath, message: e.message });
}
return records;
}
Comment on lines +185 to +192
```powershell
node "${CLAUDE_PLUGIN_ROOT}/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js" `
--customization-report "<REPORT_PATH>" `
--site-name "<SITE_NAME>" `
--website-id "<WEBSITE_ID>" `
--template-name "<TEMPLATE_NAME>" `
--output-dir "<OUTPUT_DIR>"
```
Comment on lines +200 to +209
```powershell
node "${CLAUDE_PLUGIN_ROOT}/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js" `
--site-name "<SITE_NAME>" `
--website-id "<WEBSITE_ID>" `
--siteCustomizationReportPath "<REPORT_PATH>" `
--env-url "<ENV_URL>" `
--automate `
--environment-type "<ENV_TYPE>" `
--output-dir "<OUTPUT_DIR>"
```
Comment on lines +215 to +222
```powershell
node "${CLAUDE_PLUGIN_ROOT}/skills/migrate-sdm-to-edm/scripts/generate-migration-reports.js" `
--site-name "<SITE_NAME>" `
--website-id "<WEBSITE_ID>" `
--portal-id "<PORTAL_ID>" `
--template-name "<TEMPLATE_NAME>" `
--output-dir "<OUTPUT_DIR>"
```
Comment on lines +736 to +738
<div class="footer">
<p>Generated by Power Pages Migration Tool | Report created on {{REPORT_DATE}}</p>
</div>
Copilot AI review requested due to automatic review settings June 12, 2026 06:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 27 changed files in this pull request and generated 3 comments.

Comment on lines +100 to +104
for (const entry of files) {
const rel = entry.relativePath;
const from = path.join(stagedDir, rel);
const to = path.join(siteRoot, rel);

Comment on lines +62 to +66
function parseArgs(argv) {
const result = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
| add-cloud-flow | AddCloudFlow | Site/AI/Skills/AddCloudFlow |
| add-ai-webapi | AddAiWebapi | Site/AI/Skills/AddAiWebapi |
| integrate-backend | IntegrateBackend | Site/AI/Skills/IntegrateBackend |
| migrate-sdm-to-edm | MigrateSdmToEdm | Site/AI/Skills/MigrateSdmToEdm |
… compatibility

Some PP-VSCode import paths in older builds and fallback mappers still read deprecated website fields instead of localWebsite and remoteWebsite fields.

Without these aliases, metadata-diff import can fail in RemoveSiteHandler with an undefined siteName access.

Emit websiteId, websiteName, and localSiteName alongside the current schema fields so both modern and legacy paths resolve site identity safely.

Update the sample remediation diff JSON to match the generated payload shape.
Copilot AI review requested due to automatic review settings July 28, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 22 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (6)

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/apply-remediation.js:104

  • relativePath from remediation-diff.json is joined directly into filesystem paths. If the manifest is edited or corrupted, a path like "../../something" (or an absolute path) could cause the script to read/copy outside remediation-staged/ and/or overwrite files outside the site root. Add validation that relativePath is truly relative and resolves under both stagedDir and siteRoot before copying.
  for (const entry of files) {
    const rel = entry.relativePath;
    const from = path.join(stagedDir, rel);
    const to = path.join(siteRoot, rel);

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/apply-remediation.js:5

  • This PR adds new Node scripts for the power-pages plugin (including this one) but does not add node:test coverage under plugins/power-pages/scripts/tests/. Per plugins/power-pages/AGENTS.md ("Script changes require tests"), new/changed scripts should ship with tests.
#!/usr/bin/env node

/**
 * apply-remediation.js
 *

plugins/power-pages/references/skill-tracking-reference.md:45

  • This adds a new user-invocable skill (migrate-sdm-to-edm) to the plugin, but plugins/power-pages/README.md doesn’t appear to document it yet (no occurrences found). plugins/power-pages/AGENTS.md requires new skills to be added to README so they’re discoverable to users.
| migrate-sdm-to-edm | MigrateSdmToEdm | Site/AI/Skills/MigrateSdmToEdm |

plugins/power-pages/skills/migrate-sdm-to-edm/DESIGN.md:220

  • DESIGN.md states that Data Model Extensions remediation involves "No Dataverse API calls", but the existing report generator script does include Dataverse Web API helpers (e.g., checkColumnExists / createStringAttribute in scripts/generate-migration-reports.js). This is internally inconsistent with the rest of the docs in this PR (and can confuse readers) — either clarify that API calls only happen under an explicit opt-in flag, or update the design/prompt text to match the intended behavior.
| **Data Model Extensions** | Custom columns on `adx_*` tables | **Per-table checklist** — grouped by source table; checklist suggests new custom table name, lookup-to-`powerpagecomponent` column, and data-migration steps. **No Dataverse API calls** — schema decisions stay with the user |

plugins/power-pages/skills/migrate-sdm-to-edm/REPORTS_INTEGRATION.md:28

  • This integration guide refers to "Phase 8/9/12" and an --execution-data phase1..phase12 shape, but the skill’s current state model and docs use Phases 1–4 with sub-steps (e.g., 1.1, 2.3, Track A/B). As written, readers following this doc will likely look for non-existent phases/flags. Please reconcile the phase numbering and example commands with the current SKILL.md/state schema.
### Phase 8: Customization Report & Analysis

**Current flow (SKILL.md):**

1. Download customization report via PAC CLI
2. Parse and categorize findings
3. Generate HTML report
4. Present findings to user

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/update-state.js:95

  • The initialization hint in this error message suggests running update-state.js --init ..., but the script is intended to be invoked via Node (and likely not on PATH). Update the message to include node update-state.js ... so the copy/paste command works.
    throw new Error(
      `migration-state.json not found at ${p}. Run \`update-state.js --init --output-dir ${outputDir} --website-id <GUID>\` first.`,
    );

* Changing Track A to authering and Track B to downstream for user understanding

* reordered SDM snapshot so it will be available during migration running

* latest changes

* Phase 3 (Authoring) Phase 3 (Downstream) Phase 4
Before 3 steps: Migrate Refs → Activate EDM → Restart 5 steps: Migrate Refs (with SDM snapshot) → Locate Report → Remediate → Activate EDM → Restart 3 steps: Data Diff → Runtime Smoke Test → Final Summary
After 4 steps: Data Diff Validation → Migrate Refs → Activate EDM → Restart 6 steps: Data Diff Validation (with SDM snapshot) → Migrate Refs → Locate Report → Remediate → Activate EDM → Restart 2 steps: Runtime Smoke Test → Final Summary

* latest changes

* installing cdsbaseportal if its older

* updating report with more context

---------

Co-authored-by: Gokul Raj Gopinathan <gokulrg@microsoft.com>
Copilot AI review requested due to automatic review settings August 6, 2026 10:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (5)

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/apply-remediation.js:104

  • apply-remediation.js trusts remediation-diff.json.relativePath and uses path.join() directly. A crafted or accidentally malformed relativePath containing ".." or an absolute path could copy files outside of --site-root (or outside the staged dir), leading to unintended overwrite of arbitrary paths.
    const rel = entry.relativePath;
    const from = path.join(stagedDir, rel);
    const to = path.join(siteRoot, rel);

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/update-state.js:222

  • --init requires --slug, but the initialized state never persists it (buildInitialState sets site.slug=null and cmdInit only sets environmentName). This leaves migration-state.json missing the slug even though it was provided, which can break report rendering or later steps that rely on the slug being present.
  const state = buildInitialState({ webSiteId, outputDir });
  state.site.environmentName = envName;
  fs.mkdirSync(outputDir, { recursive: true });
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
  fs.writeFileSync(reportPathFor(outputDir), renderLiveReport(state, loadRenderOpts(outputDir)), 'utf-8');

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/snapshot-site.js:374

  • scanWebLinkSets(siteRoot) is executed twice (once for webLinkSets and again for the derived webLinks inventory). This doubles directory walking / YAML parsing work and could yield inconsistent results if the filesystem changes between calls; webLinks should be derived from the same scan result used for webLinkSets.
  const categories = {
    webPages: scanWebPages(siteRoot, withContentHash),
    contentSnippets: scanContentSnippets(siteRoot, withContentHash),
    webLinkSets: scanWebLinkSets(siteRoot),
    // webLinks is derived from the per-set link inventory captured by
    // scanWebLinkSets above. It exposes each individual link as its own
    // record so the Navigation group in the live report shows the actual
    // navigation items, not just the menu containers.
    webLinks: deriveWebLinks(scanWebLinkSets(siteRoot)),
    webTemplates: scanWebTemplates(siteRoot, withContentHash),

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/update-state.js:216

  • In resume mode (--init when migration-state.json already exists and --force is not set), the command returns without printing the resolved per-migration subfolder path. The script’s own usage docs say the init command prints the resolved subfolder path and that users should use it as --output-dir for subsequent commands.
  if (fs.existsSync(statePath) && !force) {
    console.log(`⚠ Existing migration found at ${statePath}`);
    console.log('  Re-using existing state (resume mode). Pass --force to reset and start fresh.');
    return;
  }

plugins/power-pages/skills/migrate-sdm-to-edm/scripts/diff-snapshots.js:53

  • diff-snapshots.js never defines KEY_FIELDS for the new snapshot category "webLinks" (produced by snapshot-site.js). It will fall back to the default ['name'], which can collide when multiple link sets contain a link with the same name and cause missing/extra misclassification.
const KEY_FIELDS = {
  webPages: ['kind', 'name', 'partialUrl', 'language'],
  contentSnippets: ['name', 'language'],
  webLinkSets: ['name', 'language'],
  webTemplates: ['name'],
  webFiles: ['name', 'partialUrl'],

…amodel + report/telemetry updates

Rename skill 'migrate-sdm-to-edm' -> 'migrate-datamodel' across the folder, SKILL.md frontmatter name, all script path references, telemetry SKILL_NAME, the live-report footer, and the skill-tracking table (MigrateDatamodel). Descriptive SDM->EDM report filenames/content are intentionally left unchanged.

Add skill_completed 1DS telemetry via new scripts/emit-telemetry.js (Option A): builds a per-phase rollup from migration-state.json into eventInfo, fails closed, and honors the plugin kill switch. Wired into the final phase of SKILL.md.

Remove the Power Pages VS Code extension dependency from the remediation diff: drop the PP-VSCode import button/guide, importer-only JSON fields, and base64 file contents; slim remediation-diff.json to a skill-owned manifest with absolute livePath/stagedPath; add per-file 'Copy diff command' buttons that copy 'code --diff' (single-quoted absolute paths, works in any terminal/shell, no extension) plus a clipboard script with a file:// fallback.

Convert the Overview boilerplate into grouped bullet points and drop the stale hardcoded sub-step counts (live counts remain in the stats grid and Plan section).

Add an 'AI-generated content may be incorrect' disclaimer to the live report and the customization/migration report footers.

Rename the foundation package reference PowerPagesCore -> PowerPages_Core (6 references).
Copilot AI review requested due to automatic review settings August 13, 2026 12:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (6)

plugins/power-pages/skills/migrate-datamodel/scripts/apply-remediation.js:104

  • apply-remediation.js joins entry.relativePath directly onto stagedDir/siteRoot. If remediation-diff.json is edited (or generated incorrectly), a relativePath like "../..." or an absolute path can write outside siteRoot (path traversal). Validate that relativePath is a non-empty, non-absolute, non-parent-traversing path before copying.
  for (const entry of files) {
    const rel = entry.relativePath;
    const from = path.join(stagedDir, rel);
    const to = path.join(siteRoot, rel);

plugins/power-pages/skills/migrate-datamodel/scripts/update-state.js:215

  • In --init resume mode (existing migration-state.json and no --force), the CLI returns without printing the resolved per-migration subfolder path. The header docs say the init command prints the resolved subfolder path so callers can reuse it as --output-dir, so resume mode should also emit it (and ideally the standard follow-up hint).
  if (fs.existsSync(statePath) && !force) {
    console.log(`⚠ Existing migration found at ${statePath}`);
    console.log('  Re-using existing state (resume mode). Pass --force to reset and start fresh.');
    return;
  }

plugins/power-pages/skills/migrate-datamodel/scripts/snapshot-site.js:374

  • snapshot-site.js scans web link sets twice (once for webLinkSets and again for webLinks via deriveWebLinks(scanWebLinkSets(...))). This duplicates filesystem traversal and YAML parsing work and can become noticeably slow on large sites. Reuse the first scan result when deriving webLinks.
  const categories = {
    webPages: scanWebPages(siteRoot, withContentHash),
    contentSnippets: scanContentSnippets(siteRoot, withContentHash),
    webLinkSets: scanWebLinkSets(siteRoot),
    // webLinks is derived from the per-set link inventory captured by
    // scanWebLinkSets above. It exposes each individual link as its own
    // record so the Navigation group in the live report shows the actual
    // navigation items, not just the menu containers.
    webLinks: deriveWebLinks(scanWebLinkSets(siteRoot)),
    webTemplates: scanWebTemplates(siteRoot, withContentHash),

plugins/power-pages/skills/migrate-datamodel/scripts/diff-snapshots.js:54

  • diff-snapshots.js doesn't define KEY_FIELDS for the webLinks category produced by snapshot-site.js. That makes the diff fall back to ['name'] for webLinks, which can collide across different link sets/languages and yield incorrect missing/extra classifications. Add an explicit identity key that includes the parent set (and language).
const KEY_FIELDS = {
  webPages: ['kind', 'name', 'partialUrl', 'language'],
  contentSnippets: ['name', 'language'],
  webLinkSets: ['name', 'language'],
  webTemplates: ['name'],
  webFiles: ['name', 'partialUrl'],
  pageTemplates: ['name'],

plugins/power-pages/skills/migrate-datamodel/scripts/emit-telemetry.js:57

  • emit-telemetry.js only gates on (disabled || !ikey), but still proceeds when collectorUrl or eventStreamName are empty. In that case the event envelope name can be "" and the dispatcher may fail or emit malformed events. Treat missing collectorUrl/eventStreamName as unconfigured and no-op (same as missing ikey).
  const { ikey, collectorUrl, eventStreamName, disabled } = readIkey();
  // Repo-side hard-off / unconfigured: gate before any work so a disabled or
  // unconfigured plugin costs effectively nothing. (The per-plugin user opt-out
  // is enforced later by the detached dispatcher, which still writes the local
  // diagnostic mirror but skips the POST.)
  if (disabled || !ikey) return;

plugins/power-pages/skills/migrate-datamodel/assets/sdm-to-edm-migration-report.html:478

  • Decorative emoji/icon spans in the HTML report template will be read out by screen readers, adding noise (e.g., "check mark", "gear"). Mark these icons as decorative (aria-hidden="true") so assistive tech reads the actual headings/status text instead.
      <h1>
        <span class="status-icon {{MIGRATION_STATUS}}">{{STATUS_ICON}}</span>
        Power Pages Migration Execution Report

…-datamodel

Bump the power-pages plugin version 2.6.3 -> 2.7.0 in both the Open Plugins manifest and its legacy .claude-plugin mirror.

Add a not-a-gate marker to migrate-datamodel section 1.3 so the ALM gate-marker lint passes: the two AskUserQuestion references there are the template-selection tree (data-gathering) and prose mentions of the tool, not approval gates (approval-gates.md 2).
Copilot AI review requested due to automatic review settings August 13, 2026 12:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 25 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

plugins/power-pages/skills/migrate-datamodel/scripts/update-state.js:218

  • When --init is invoked without --env-name (supported by the workflow), this line overwrites the schema default environmentName: null with undefined. Make the assignment conditional so the field remains null unless explicitly provided.
  state.site.environmentName = envName;

plugins/power-pages/skills/migrate-datamodel/scripts/apply-remediation.js:104

  • relativePath from remediation-diff.json is used directly with path.join() to compute both the staged source and live destination paths. If the manifest ever contains .. segments or absolute paths (accidental corruption or malicious edit), this can copy files outside --site-root (and read outside remediation-staged). Add validation to reject absolute paths and traversal segments before copying.
    const rel = entry.relativePath;
    const from = path.join(stagedDir, rel);
    const to = path.join(siteRoot, rel);

plugins/power-pages/skills/migrate-datamodel/scripts/snapshot-site.js:374

  • scanWebLinkSets(siteRoot) is executed twice (once for webLinkSets and again for webLinks). This duplicates filesystem + YAML parsing work and can lead to inconsistent parseErrors ordering. Cache the result and derive webLinks from the cached inventory.
  const categories = {
    webPages: scanWebPages(siteRoot, withContentHash),
    contentSnippets: scanContentSnippets(siteRoot, withContentHash),
    webLinkSets: scanWebLinkSets(siteRoot),
    // webLinks is derived from the per-set link inventory captured by

Comment on lines +192 to +215
function cmdInit(args) {
const parentDir = args['output-dir'];
const webSiteId = args['website-id'];
const envName = args['env-name'];
const slug = args['slug'];
const force = !!args['force'];
if (!parentDir) throw new Error('--init requires --output-dir (the parent directory; a per-migration subfolder is created inside it)');
if (!webSiteId) throw new Error('--init requires --website-id');
if (typeof envName !== 'string' || envName.trim() === '') {
throw new Error('--init requires --env-name "<NAME>" (the Dataverse environment display name, used to namespace the migration subfolder)');
}
if (typeof slug !== 'string' || slug.trim() === '') {
throw new Error('--init requires --slug "<SLUG>" (the website slug from `pac pages list -v`, used to namespace the migration subfolder)');
}

const subdirName = buildMigrationSubdir({ envName, slug, webSiteId });
const outputDir = path.join(parentDir, subdirName);
const statePath = statePathFor(outputDir);

if (fs.existsSync(statePath) && !force) {
console.log(`⚠ Existing migration found at ${statePath}`);
console.log(' Re-using existing state (resume mode). Pass --force to reset and start fresh.');
return;
}
Comment on lines +48 to +53
const KEY_FIELDS = {
webPages: ['kind', 'name', 'partialUrl', 'language'],
contentSnippets: ['name', 'language'],
webLinkSets: ['name', 'language'],
webTemplates: ['name'],
webFiles: ['name', 'partialUrl'],
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants