@duffcloudservices/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import chalk7 from "chalk";
5
+ import chalk8 from "chalk";
6
6
 
7
7
  // package.json
8
- var version = "0.1.0";
8
+ var version = "0.1.2";
9
9
 
10
10
  // src/commands/auth.ts
11
11
  import chalk2 from "chalk";
@@ -251,7 +251,7 @@ var PortalClient = class {
251
251
  /**
252
252
  * Make an authenticated API request.
253
253
  */
254
- async request(path4, options = {}) {
254
+ async request(path5, options = {}) {
255
255
  let accessToken = getAccessToken();
256
256
  if (!isAuthenticated() && getRefreshToken()) {
257
257
  const refreshToken = getRefreshToken();
@@ -268,7 +268,7 @@ var PortalClient = class {
268
268
  if (!accessToken) {
269
269
  throw new Error("Not authenticated. Please login with `dcs login`.");
270
270
  }
271
- const response = await fetch(`${this.apiUrl}${path4}`, {
271
+ const response = await fetch(`${this.apiUrl}${path5}`, {
272
272
  ...options,
273
273
  headers: {
274
274
  ...options.headers,
@@ -441,181 +441,198 @@ import chalk4 from "chalk";
441
441
  import ora2 from "ora";
442
442
 
443
443
  // src/templates/site-deploy.yml
444
- var site_deploy_default = `# Site Deployment Workflow\r
445
- # Generated by @duffcloudservices/cli\r
446
- # This workflow deploys the site to Azure Static Web Apps using OIDC authentication.\r
447
- \r
448
- name: Site Deployment\r
449
- \r
450
- on:\r
451
- push:\r
452
- branches:\r
453
- - 'release/**'\r
454
- - 'master'\r
455
- workflow_dispatch:\r
456
- inputs:\r
457
- trigger_reason:\r
458
- description: 'Reason for manual deployment'\r
459
- required: false\r
460
- type: string\r
461
- default: 'Manual deployment'\r
462
- \r
463
- # Prevent concurrent deployments for the same branch\r
464
- concurrency:\r
465
- group: site-deploy-\${{ github.ref }}\r
466
- cancel-in-progress: false\r
467
- \r
468
- permissions:\r
469
- contents: read\r
470
- issues: write\r
471
- pull-requests: read\r
472
- id-token: write # Required for Azure OIDC authentication\r
473
- \r
474
- jobs:\r
475
- deploy:\r
476
- name: Deploy to Azure Static Web App\r
477
- runs-on: ubuntu-latest\r
478
- # Skip deployment on branch creation events\r
479
- if: github.event.before != '0000000000000000000000000000000000000000'\r
480
- \r
481
- steps:\r
482
- - name: Determine deployment environment\r
483
- id: environment\r
484
- run: |\r
485
- BRANCH="\${{ github.ref_name }}"\r
486
- echo "Branch: $BRANCH"\r
487
- \r
488
- if [ "$BRANCH" == "master" ]; then\r
489
- echo "environment=Production" >> $GITHUB_OUTPUT\r
490
- echo "environment_name=Production" >> $GITHUB_OUTPUT\r
491
- echo "swa_environment=" >> $GITHUB_OUTPUT\r
492
- else\r
493
- echo "environment=preview" >> $GITHUB_OUTPUT\r
494
- echo "environment_name=Preview" >> $GITHUB_OUTPUT\r
495
- echo "swa_environment=preview" >> $GITHUB_OUTPUT\r
496
- fi\r
497
- \r
498
- - name: Checkout repository\r
499
- uses: actions/checkout@v4\r
500
- \r
501
- - name: Read site configuration\r
502
- id: config\r
503
- run: |\r
504
- CONFIG_FILE=".dcs/site.yaml"\r
505
- \r
506
- if [ ! -f "$CONFIG_FILE" ]; then\r
507
- echo "::error::Missing DCS site configuration file: $CONFIG_FILE"\r
508
- exit 1\r
509
- fi\r
510
- \r
511
- SITE_NAME=$(grep -E '^site_name:' "$CONFIG_FILE" | sed 's/site_name:\\s*//' | tr -d '"' | tr -d "'")\r
512
- SITE_SLUG=$(grep -E '^site_slug:' "$CONFIG_FILE" | sed 's/site_slug:\\s*//' | tr -d '"' | tr -d "'")\r
513
- SWA_RESOURCE_ID=$(grep -E '^swa_resource_id:' "$CONFIG_FILE" | sed 's/swa_resource_id:\\s*//' | tr -d '"' | tr -d "'")\r
514
- PORTAL_API_URL=$(grep -E '^portal_api_url:' "$CONFIG_FILE" | sed 's/portal_api_url:\\s*//' | tr -d '"' | tr -d "'")\r
515
- PORTAL_API_URL="\${PORTAL_API_URL:-https://portal.duffcloudservices.com}"\r
516
- GOOGLE_ANALYTICS_ID=$(grep -E '^google_analytics_id:' "$CONFIG_FILE" | sed 's/google_analytics_id:\\s*//' | tr -d '"' | tr -d "'")\r
517
- \r
518
- # Azure Authentication\r
519
- AZURE_CLIENT_ID=$(grep -E '^\\s*client_id:' "$CONFIG_FILE" | sed 's/.*client_id:\\s*//' | tr -d '"' | tr -d "'")\r
520
- AZURE_TENANT_ID=$(grep -E '^\\s*tenant_id:' "$CONFIG_FILE" | sed 's/.*tenant_id:\\s*//' | tr -d '"' | tr -d "'")\r
521
- AZURE_SUBSCRIPTION_ID=$(grep -E '^\\s*subscription_id:' "$CONFIG_FILE" | sed 's/.*subscription_id:\\s*//' | tr -d '"' | tr -d "'")\r
522
- \r
523
- # Build Configuration\r
524
- APP_LOCATION=$(grep -E '^\\s*app_location:' "$CONFIG_FILE" | sed 's/.*app_location:\\s*//' | tr -d '"' | tr -d "'")\r
525
- OUTPUT_LOCATION=$(grep -E '^\\s*output_location:' "$CONFIG_FILE" | sed 's/.*output_location:\\s*//' | tr -d '"' | tr -d "'")\r
526
- \r
527
- APP_LOCATION="\${APP_LOCATION:-dist}"\r
528
- OUTPUT_LOCATION="\${OUTPUT_LOCATION:-.}"\r
529
- \r
530
- if [ -z "$SWA_RESOURCE_ID" ]; then\r
531
- echo "::error::Missing swa_resource_id in $CONFIG_FILE"\r
532
- exit 1\r
533
- fi\r
534
- \r
535
- echo "site_name=$SITE_NAME" >> $GITHUB_OUTPUT\r
536
- echo "site_slug=$SITE_SLUG" >> $GITHUB_OUTPUT\r
537
- echo "swa_resource_id=$SWA_RESOURCE_ID" >> $GITHUB_OUTPUT\r
538
- echo "app_location=$APP_LOCATION" >> $GITHUB_OUTPUT\r
539
- echo "output_location=$OUTPUT_LOCATION" >> $GITHUB_OUTPUT\r
540
- echo "azure_client_id=$AZURE_CLIENT_ID" >> $GITHUB_OUTPUT\r
541
- echo "azure_tenant_id=$AZURE_TENANT_ID" >> $GITHUB_OUTPUT\r
542
- echo "azure_subscription_id=$AZURE_SUBSCRIPTION_ID" >> $GITHUB_OUTPUT\r
543
- echo "portal_api_url=$PORTAL_API_URL" >> $GITHUB_OUTPUT\r
544
- echo "google_analytics_id=$GOOGLE_ANALYTICS_ID" >> $GITHUB_OUTPUT\r
545
- \r
546
- - name: Azure login via OIDC\r
547
- uses: azure/login@v2\r
548
- with:\r
549
- client-id: \${{ steps.config.outputs.azure_client_id || vars.AZURE_CLIENT_ID }}\r
550
- tenant-id: \${{ steps.config.outputs.azure_tenant_id || vars.AZURE_TENANT_ID }}\r
551
- subscription-id: \${{ steps.config.outputs.azure_subscription_id || vars.AZURE_SUBSCRIPTION_ID }}\r
552
- \r
553
- - name: Get deployment tokens\r
554
- id: tokens\r
555
- run: |\r
556
- # Get access token for DCS API\r
557
- ACCESS_TOKEN=$(az account get-access-token --resource "api://304711b9-8532-4659-beb7-c85726de9ae5" --query accessToken -o tsv)\r
558
- \r
559
- SITE_NAME="\${{ steps.config.outputs.site_name }}"\r
560
- SITE_SLUG="\${{ steps.config.outputs.site_slug }}"\r
561
- PORTAL_API_URL="\${{ steps.config.outputs.portal_api_url }}"\r
562
- \r
563
- RESPONSE=$(curl -s -X POST "\${PORTAL_API_URL}/api/v1/sites/deployment-tokens" \\\r
564
- -H "Authorization: Bearer $ACCESS_TOKEN" \\\r
565
- -H "Content-Type: application/json" \\\r
566
- -d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\"}")\r
567
- \r
568
- SWA_TOKEN=$(echo $RESPONSE | jq -r .swaToken)\r
569
- \r
570
- if [ -z "$SWA_TOKEN" ] || [ "$SWA_TOKEN" == "null" ]; then\r
571
- echo "::error::Failed to retrieve SWA token from DCS API"\r
572
- echo "Response: $RESPONSE"\r
573
- exit 1\r
574
- fi\r
575
- \r
576
- echo "::add-mask::$SWA_TOKEN"\r
577
- echo "swa_token=$SWA_TOKEN" >> $GITHUB_OUTPUT\r
578
- \r
579
- - name: Setup Node.js\r
580
- uses: actions/setup-node@v4\r
581
- with:\r
582
- node-version: "22"\r
583
- \r
584
- - name: Install pnpm\r
585
- uses: pnpm/action-setup@v4\r
586
- \r
587
- - name: Setup pnpm cache\r
588
- uses: actions/cache@v4\r
589
- with:\r
590
- path: ~/.pnpm-store\r
591
- key: \${{ runner.os }}-pnpm-\${{ hashFiles('**/pnpm-lock.yaml') }}\r
592
- restore-keys: |\r
593
- \${{ runner.os }}-pnpm-\r
594
- \r
595
- - name: Install dependencies\r
596
- run: pnpm install --frozen-lockfile\r
597
- \r
598
- - name: Create environment file\r
599
- run: |\r
600
- cat > .env << EOF\r
601
- VITE_BACKEND_URI="\${{ steps.config.outputs.portal_api_url }}"\r
602
- VITE_SITE_SLUG="\${{ steps.config.outputs.site_slug }}"\r
603
- VITE_GOOGLE_ANALYTICS_ID="\${{ steps.config.outputs.google_analytics_id }}"\r
604
- EOF\r
605
- \r
606
- - name: Build site\r
607
- run: pnpm run build\r
608
- \r
609
- - name: Deploy to Azure Static Web Apps\r
610
- uses: Azure/static-web-apps-deploy@v1\r
611
- with:\r
612
- azure_static_web_apps_api_token: \${{ steps.tokens.outputs.swa_token }}\r
613
- repo_token: \${{ secrets.GITHUB_TOKEN }}\r
614
- action: "upload"\r
615
- app_location: \${{ steps.config.outputs.app_location }}\r
616
- output_location: \${{ steps.config.outputs.output_location }}\r
617
- skip_app_build: true\r
618
- deployment_environment: \${{ steps.environment.outputs.swa_environment != '' && steps.environment.outputs.swa_environment || '' }}\r
444
+ var site_deploy_default = `# Site Deployment Workflow
445
+ # Generated by @duffcloudservices/cli
446
+ # This workflow deploys the site to Azure Static Web Apps using OIDC authentication.
447
+
448
+ name: Site Deployment
449
+
450
+ on:
451
+ push:
452
+ branches:
453
+ - 'release/**'
454
+ - 'master'
455
+ workflow_dispatch:
456
+ inputs:
457
+ trigger_reason:
458
+ description: 'Reason for manual deployment'
459
+ required: false
460
+ type: string
461
+ default: 'Manual deployment'
462
+
463
+ # Prevent concurrent deployments for the same branch
464
+ concurrency:
465
+ group: site-deploy-\${{ github.ref }}
466
+ cancel-in-progress: false
467
+
468
+ permissions:
469
+ contents: read
470
+ issues: write
471
+ pull-requests: read
472
+ id-token: write # Required for Azure OIDC authentication
473
+
474
+ jobs:
475
+ deploy:
476
+ name: Deploy to Azure Static Web App
477
+ runs-on: ubuntu-latest
478
+ # Skip deployment on branch creation events
479
+ if: github.event.before != '0000000000000000000000000000000000000000'
480
+
481
+ steps:
482
+ - name: Determine deployment environment
483
+ id: environment
484
+ run: |
485
+ BRANCH="\${{ github.ref_name }}"
486
+ echo "Branch: $BRANCH"
487
+
488
+ if [ "$BRANCH" == "master" ]; then
489
+ echo "environment=Production" >> $GITHUB_OUTPUT
490
+ echo "environment_name=Production" >> $GITHUB_OUTPUT
491
+ echo "swa_environment=" >> $GITHUB_OUTPUT
492
+ else
493
+ echo "environment=preview" >> $GITHUB_OUTPUT
494
+ echo "environment_name=Preview" >> $GITHUB_OUTPUT
495
+ echo "swa_environment=preview" >> $GITHUB_OUTPUT
496
+ fi
497
+
498
+ - name: Checkout repository
499
+ uses: actions/checkout@v4
500
+
501
+ - name: Read site configuration
502
+ id: config
503
+ run: |
504
+ CONFIG_FILE=".dcs/site.yaml"
505
+
506
+ if [ ! -f "$CONFIG_FILE" ]; then
507
+ echo "::error::Missing DCS site configuration file: $CONFIG_FILE"
508
+ exit 1
509
+ fi
510
+
511
+ SITE_NAME=$(grep -E '^site_name:' "$CONFIG_FILE" | sed 's/site_name:\\s*//' | tr -d '"' | tr -d "'")
512
+ SITE_SLUG=$(grep -E '^site_slug:' "$CONFIG_FILE" | sed 's/site_slug:\\s*//' | tr -d '"' | tr -d "'")
513
+ SWA_RESOURCE_ID=$(grep -E '^swa_resource_id:' "$CONFIG_FILE" | sed 's/swa_resource_id:\\s*//' | tr -d '"' | tr -d "'")
514
+ PORTAL_API_URL=$(grep -E '^portal_api_url:' "$CONFIG_FILE" | sed 's/portal_api_url:\\s*//' | tr -d '"' | tr -d "'")
515
+ PORTAL_API_URL="\${PORTAL_API_URL:-https://portal.duffcloudservices.com}"
516
+ GOOGLE_ANALYTICS_ID=$(grep -E '^google_analytics_id:' "$CONFIG_FILE" | sed 's/google_analytics_id:\\s*//' | tr -d '"' | tr -d "'")
517
+ PROD_APPINSIGHTS_CONNECTION_STRING=$(grep -E '^prod_appinsights_connection_string:' "$CONFIG_FILE" | sed 's/prod_appinsights_connection_string:\\s*//' | tr -d '"' | tr -d "'" || true)
518
+ DEV_APPINSIGHTS_CONNECTION_STRING=$(grep -E '^dev_appinsights_connection_string:' "$CONFIG_FILE" | sed 's/dev_appinsights_connection_string:\\s*//' | tr -d '"' | tr -d "'" || true)
519
+
520
+ # Azure Authentication
521
+ AZURE_CLIENT_ID=$(grep -E '^\\s*client_id:' "$CONFIG_FILE" | sed 's/.*client_id:\\s*//' | tr -d '"' | tr -d "'")
522
+ AZURE_TENANT_ID=$(grep -E '^\\s*tenant_id:' "$CONFIG_FILE" | sed 's/.*tenant_id:\\s*//' | tr -d '"' | tr -d "'")
523
+ AZURE_SUBSCRIPTION_ID=$(grep -E '^\\s*subscription_id:' "$CONFIG_FILE" | sed 's/.*subscription_id:\\s*//' | tr -d '"' | tr -d "'")
524
+
525
+ # Build Configuration
526
+ APP_LOCATION=$(grep -E '^\\s*app_location:' "$CONFIG_FILE" | sed 's/.*app_location:\\s*//' | tr -d '"' | tr -d "'")
527
+ OUTPUT_LOCATION=$(grep -E '^\\s*output_location:' "$CONFIG_FILE" | sed 's/.*output_location:\\s*//' | tr -d '"' | tr -d "'")
528
+
529
+ APP_LOCATION="\${APP_LOCATION:-dist}"
530
+ OUTPUT_LOCATION="\${OUTPUT_LOCATION:-.}"
531
+
532
+ if [ -z "$SWA_RESOURCE_ID" ]; then
533
+ echo "::error::Missing swa_resource_id in $CONFIG_FILE"
534
+ exit 1
535
+ fi
536
+
537
+ echo "site_name=$SITE_NAME" >> $GITHUB_OUTPUT
538
+ echo "site_slug=$SITE_SLUG" >> $GITHUB_OUTPUT
539
+ echo "swa_resource_id=$SWA_RESOURCE_ID" >> $GITHUB_OUTPUT
540
+ echo "app_location=$APP_LOCATION" >> $GITHUB_OUTPUT
541
+ echo "output_location=$OUTPUT_LOCATION" >> $GITHUB_OUTPUT
542
+ echo "azure_client_id=$AZURE_CLIENT_ID" >> $GITHUB_OUTPUT
543
+ echo "azure_tenant_id=$AZURE_TENANT_ID" >> $GITHUB_OUTPUT
544
+ echo "azure_subscription_id=$AZURE_SUBSCRIPTION_ID" >> $GITHUB_OUTPUT
545
+ echo "portal_api_url=$PORTAL_API_URL" >> $GITHUB_OUTPUT
546
+ echo "google_analytics_id=$GOOGLE_ANALYTICS_ID" >> $GITHUB_OUTPUT
547
+ echo "prod_appinsights_connection_string=$PROD_APPINSIGHTS_CONNECTION_STRING" >> $GITHUB_OUTPUT
548
+ echo "dev_appinsights_connection_string=$DEV_APPINSIGHTS_CONNECTION_STRING" >> $GITHUB_OUTPUT
549
+
550
+ - name: Azure login via OIDC
551
+ uses: azure/login@v2
552
+ with:
553
+ client-id: \${{ steps.config.outputs.azure_client_id || vars.AZURE_CLIENT_ID }}
554
+ tenant-id: \${{ steps.config.outputs.azure_tenant_id || vars.AZURE_TENANT_ID }}
555
+ subscription-id: \${{ steps.config.outputs.azure_subscription_id || vars.AZURE_SUBSCRIPTION_ID }}
556
+
557
+ - name: Get deployment tokens
558
+ id: tokens
559
+ run: |
560
+ # Get access token for DCS API
561
+ ACCESS_TOKEN=$(az account get-access-token --resource "api://304711b9-8532-4659-beb7-c85726de9ae5" --query accessToken -o tsv)
562
+
563
+ SITE_NAME="\${{ steps.config.outputs.site_name }}"
564
+ SITE_SLUG="\${{ steps.config.outputs.site_slug }}"
565
+ PORTAL_API_URL="\${{ steps.config.outputs.portal_api_url }}"
566
+
567
+ RESPONSE=$(curl -s -X POST "\${PORTAL_API_URL}/api/v1/sites/deployment-tokens" \\
568
+ -H "Authorization: Bearer $ACCESS_TOKEN" \\
569
+ -H "Content-Type: application/json" \\
570
+ -d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\"}")
571
+
572
+ SWA_TOKEN=$(echo $RESPONSE | jq -r .swaToken)
573
+
574
+ if [ -z "$SWA_TOKEN" ] || [ "$SWA_TOKEN" == "null" ]; then
575
+ echo "::error::Failed to retrieve SWA token from DCS API"
576
+ echo "Response: $RESPONSE"
577
+ exit 1
578
+ fi
579
+
580
+ echo "::add-mask::$SWA_TOKEN"
581
+ echo "swa_token=$SWA_TOKEN" >> $GITHUB_OUTPUT
582
+
583
+ - name: Setup Node.js
584
+ uses: actions/setup-node@v4
585
+ with:
586
+ node-version: "22"
587
+
588
+ - name: Install pnpm
589
+ uses: pnpm/action-setup@v4
590
+
591
+ - name: Setup pnpm cache
592
+ uses: actions/cache@v4
593
+ with:
594
+ path: ~/.pnpm-store
595
+ key: \${{ runner.os }}-pnpm-\${{ hashFiles('**/pnpm-lock.yaml') }}
596
+ restore-keys: |
597
+ \${{ runner.os }}-pnpm-
598
+
599
+ - name: Install dependencies
600
+ run: pnpm install --frozen-lockfile
601
+
602
+ - name: Create environment file
603
+ run: |
604
+ cat > .env << EOF
605
+ VITE_APP_VERSION="\${{ github.sha }}"
606
+ VITE_BACKEND_URI="\${{ steps.config.outputs.portal_api_url }}"
607
+ VITE_SITE_SLUG="\${{ steps.config.outputs.site_slug }}"
608
+ VITE_GOOGLE_ANALYTICS_ID="\${{ steps.config.outputs.google_analytics_id }}"
609
+ VITE_PROD_APPINSIGHTS_CONNECTION_STRING="\${{ steps.config.outputs.prod_appinsights_connection_string }}"
610
+ VITE_DEV_APPINSIGHTS_CONNECTION_STRING="\${{ steps.config.outputs.dev_appinsights_connection_string }}"
611
+ EOF
612
+
613
+ - name: Build site
614
+ run: pnpm run build
615
+
616
+ - name: Inject Google Analytics
617
+ if: steps.config.outputs.google_analytics_id != ''
618
+ run: |
619
+ GA_ID="\${{ steps.config.outputs.google_analytics_id }}"
620
+ OUTPUT_DIR="\${{ steps.config.outputs.app_location }}"
621
+ echo "Injecting Google Analytics tag \${GA_ID} into \${OUTPUT_DIR}..."
622
+ find "$OUTPUT_DIR" -name "*.html" -exec sed -i "s|</head>|<script async src=\\"https://www.googletagmanager.com/gtag/js?id=\${GA_ID}\\"></script><script>window.dataLayer=window.dataLayer\\|\\|[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','\${GA_ID}')</script>\\n</head>|" {} +
623
+ INJECTED=$(grep -rl "googletagmanager" "$OUTPUT_DIR" --include="*.html" | wc -l)
624
+ echo "Injected GA tag into \${INJECTED} HTML file(s)"
625
+
626
+ - name: Deploy to Azure Static Web Apps
627
+ uses: Azure/static-web-apps-deploy@v1
628
+ with:
629
+ azure_static_web_apps_api_token: \${{ steps.tokens.outputs.swa_token }}
630
+ repo_token: \${{ secrets.GITHUB_TOKEN }}
631
+ action: "upload"
632
+ app_location: \${{ steps.config.outputs.app_location }}
633
+ output_location: \${{ steps.config.outputs.output_location }}
634
+ skip_app_build: true
635
+ deployment_environment: \${{ steps.environment.outputs.swa_environment != '' && steps.environment.outputs.swa_environment || '' }}
619
636
  `;
620
637
 
621
638
  // src/commands/init.ts
@@ -2082,6 +2099,594 @@ Once verified:
2082
2099
  `;
2083
2100
  }
2084
2101
 
2102
+ // src/commands/capture-snapshots.ts
2103
+ import fs4 from "fs";
2104
+ import path4 from "path";
2105
+ import yaml3 from "js-yaml";
2106
+ import chalk7 from "chalk";
2107
+ import ora3 from "ora";
2108
+ var SECTION_SELECTORS = {
2109
+ explicit: "[data-section]",
2110
+ header: 'header, [role="banner"], .header, #header',
2111
+ footer: 'footer, [role="contentinfo"], .footer, #footer',
2112
+ hero: '[class*="hero"], [data-section-type="hero"], .hero-section, #hero',
2113
+ content: 'main, [role="main"], article, .content, .main-content',
2114
+ gallery: '[class*="gallery"], [data-section-type="gallery"]',
2115
+ cta: '[class*="cta"], [data-section-type="cta"], .call-to-action'
2116
+ };
2117
+ function loadPagesConfig(targetDir) {
2118
+ const configPath = path4.join(targetDir, ".dcs", "pages.yaml");
2119
+ if (!fs4.existsSync(configPath)) {
2120
+ throw new Error(`Configuration file not found: ${configPath}`);
2121
+ }
2122
+ const content = fs4.readFileSync(configPath, "utf-8");
2123
+ return yaml3.load(content);
2124
+ }
2125
+ function loadContentConfig(targetDir) {
2126
+ const contentPath = path4.join(targetDir, ".dcs", "content.yaml");
2127
+ if (!fs4.existsSync(contentPath)) {
2128
+ return null;
2129
+ }
2130
+ const content = fs4.readFileSync(contentPath, "utf-8");
2131
+ return yaml3.load(content);
2132
+ }
2133
+ function loadTargetedSnapshotsConfig(targetDir) {
2134
+ const targetedPath = path4.join(targetDir, ".dcs", "targeted-snapshots.yaml");
2135
+ if (!fs4.existsSync(targetedPath)) {
2136
+ return null;
2137
+ }
2138
+ const content = fs4.readFileSync(targetedPath, "utf-8");
2139
+ return yaml3.load(content);
2140
+ }
2141
+ function getValidTextKeysForPage(contentConfig, pageSlug) {
2142
+ const keys = /* @__PURE__ */ new Map();
2143
+ if (!contentConfig) return keys;
2144
+ if (contentConfig.global) {
2145
+ for (const [key, value] of Object.entries(contentConfig.global)) {
2146
+ keys.set(key, typeof value === "string" ? value : JSON.stringify(value));
2147
+ }
2148
+ }
2149
+ const pageContent = contentConfig.pages?.[pageSlug];
2150
+ if (pageContent) {
2151
+ for (const [key, value] of Object.entries(pageContent)) {
2152
+ const fullKey = `${pageSlug}.${key}`;
2153
+ keys.set(fullKey, typeof value === "string" ? value : JSON.stringify(value));
2154
+ }
2155
+ }
2156
+ return keys;
2157
+ }
2158
+ async function resolveAllPages(config2) {
2159
+ const resolved = [];
2160
+ for (const page of config2.pages) {
2161
+ if (page.type === "dynamic" && page.instances) {
2162
+ for (const instance of page.instances) {
2163
+ const instancePath = page.pathTemplate ? page.pathTemplate.replace(/:[\w]+/, instance) : `${page.path}/${instance}`;
2164
+ resolved.push({
2165
+ slug: `${page.slug}-${instance}`,
2166
+ path: instancePath,
2167
+ config: { ...page, slug: `${page.slug}-${instance}` }
2168
+ });
2169
+ }
2170
+ } else {
2171
+ resolved.push({
2172
+ slug: page.slug,
2173
+ path: page.path,
2174
+ config: page
2175
+ });
2176
+ }
2177
+ }
2178
+ return resolved;
2179
+ }
2180
+ async function getDomPath(element) {
2181
+ return element.evaluate((el) => {
2182
+ const parts = [];
2183
+ let current = el;
2184
+ while (current && current !== document.body) {
2185
+ let selector = current.tagName.toLowerCase();
2186
+ if (current.id) {
2187
+ selector += `#${current.id}`;
2188
+ } else {
2189
+ const classes = Array.from(current.classList).filter((c) => !c.match(/^(v-|nuxt-|vue-|react-|ng-|_)/)).slice(0, 2).join(".");
2190
+ if (classes) {
2191
+ selector += `.${classes}`;
2192
+ }
2193
+ }
2194
+ parts.unshift(selector);
2195
+ current = current.parentElement;
2196
+ }
2197
+ return parts.join(" > ");
2198
+ });
2199
+ }
2200
+ async function generateSectionDisplayName(element, sectionType, index) {
2201
+ const headingText = await element.evaluate((el) => {
2202
+ const heading = el.querySelector('h1, h2, h3, h4, [class*="title"], [class*="heading"]');
2203
+ if (heading && heading.textContent) {
2204
+ return heading.textContent.trim().slice(0, 50);
2205
+ }
2206
+ return null;
2207
+ });
2208
+ if (headingText) return headingText;
2209
+ const typeLabels = {
2210
+ header: "Header",
2211
+ footer: "Footer",
2212
+ hero: "Hero Section",
2213
+ content: "Content Section",
2214
+ gallery: "Gallery",
2215
+ cta: "Call to Action"
2216
+ };
2217
+ return typeLabels[sectionType] || `Section ${index + 1}`;
2218
+ }
2219
+ async function detectTextKeys(_page, sectionElement, _pageSlug, _validTextKeys) {
2220
+ const textKeys = [];
2221
+ const textKeyElements = await sectionElement.$$("[data-text-key]");
2222
+ for (const element of textKeyElements) {
2223
+ const keyInfo = await element.evaluate((el) => {
2224
+ const rect = el.getBoundingClientRect();
2225
+ return {
2226
+ key: el.getAttribute("data-text-key") || "",
2227
+ elementType: el.tagName.toLowerCase(),
2228
+ currentValue: el.textContent?.trim().slice(0, 500) || "",
2229
+ bounds: {
2230
+ x: rect.left + window.scrollX,
2231
+ y: rect.top + window.scrollY,
2232
+ width: rect.width,
2233
+ height: rect.height
2234
+ }
2235
+ };
2236
+ });
2237
+ if (keyInfo.key) {
2238
+ const domPath = await getDomPath(element);
2239
+ textKeys.push({
2240
+ key: keyInfo.key,
2241
+ currentValue: keyInfo.currentValue,
2242
+ elementType: keyInfo.elementType,
2243
+ domPath,
2244
+ bounds: {
2245
+ x: Math.round(keyInfo.bounds.x),
2246
+ y: Math.round(keyInfo.bounds.y),
2247
+ width: Math.round(keyInfo.bounds.width),
2248
+ height: Math.round(keyInfo.bounds.height)
2249
+ }
2250
+ });
2251
+ }
2252
+ }
2253
+ return textKeys;
2254
+ }
2255
+ async function detectFallbackContentSections(page, existingSections) {
2256
+ const candidates = await page.$$('section, article, div[class*="section"], div[class*="container"]');
2257
+ const results = [];
2258
+ const existingBounds = existingSections.map((s) => s.bounds);
2259
+ for (const candidate of candidates) {
2260
+ const bounds = await candidate.evaluate((el) => {
2261
+ const rect = el.getBoundingClientRect();
2262
+ return {
2263
+ y: rect.top + window.scrollY,
2264
+ height: rect.height
2265
+ };
2266
+ });
2267
+ if (bounds.height < 100) continue;
2268
+ const overlaps = existingBounds.some(
2269
+ (eb) => bounds.y < eb.y + eb.height && bounds.y + bounds.height > eb.y
2270
+ );
2271
+ if (!overlaps) {
2272
+ results.push(candidate);
2273
+ }
2274
+ }
2275
+ return results;
2276
+ }
2277
+ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug, validTextKeys, outputDir, verbose) {
2278
+ const context = await browser.newContext({
2279
+ viewport: snapshotConfig.viewport,
2280
+ ignoreHTTPSErrors: true
2281
+ });
2282
+ const page = await context.newPage();
2283
+ const rawUrl = pageInfo.path.startsWith("http") ? pageInfo.path : `${process.env.SITE_BASE_URL || "http://localhost:5173"}${pageInfo.path}`;
2284
+ const pageUrl = new URL(rawUrl);
2285
+ pageUrl.searchParams.set("dcs-hide-ribbon", "");
2286
+ const url = pageUrl.toString();
2287
+ if (verbose) {
2288
+ console.log(` Capturing: ${pageInfo.slug} (${url})`);
2289
+ }
2290
+ try {
2291
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
2292
+ await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
2293
+ if (verbose) console.log(` Note: networkidle timeout, continuing anyway`);
2294
+ });
2295
+ } catch (error) {
2296
+ console.error(` Failed to load: ${error}`);
2297
+ await context.close();
2298
+ throw error;
2299
+ }
2300
+ await page.waitForTimeout(snapshotConfig.waitAfterLoad);
2301
+ const pageOutputDir = path4.join(outputDir, siteSlug, pageInfo.slug);
2302
+ fs4.mkdirSync(path4.join(pageOutputDir, "sections"), { recursive: true });
2303
+ const title = await page.title();
2304
+ let pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
2305
+ const scrollStep = snapshotConfig.viewport.height;
2306
+ for (let scrollY2 = 0; scrollY2 < pageHeight; scrollY2 += scrollStep) {
2307
+ await page.evaluate((y) => window.scrollTo(0, y), scrollY2);
2308
+ await page.waitForTimeout(100);
2309
+ }
2310
+ await page.evaluate(() => window.scrollTo(0, 0));
2311
+ await page.waitForTimeout(200);
2312
+ pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
2313
+ await page.evaluate(() => window.scrollTo(0, 0));
2314
+ await page.waitForTimeout(100);
2315
+ const scrollY = await page.evaluate(() => window.scrollY);
2316
+ if (scrollY !== 0) {
2317
+ console.warn(` Warning: Could not scroll to top (scrollY=${scrollY})`);
2318
+ }
2319
+ const fullPagePath = path4.join(pageOutputDir, "full-page.png");
2320
+ await page.screenshot({
2321
+ path: fullPagePath,
2322
+ fullPage: snapshotConfig.captureFullPage
2323
+ });
2324
+ const thumbnailViewport = { width: 640, height: 360 };
2325
+ await page.setViewportSize(thumbnailViewport);
2326
+ await page.waitForTimeout(200);
2327
+ const thumbnailPath = path4.join(pageOutputDir, "thumbnail.png");
2328
+ await page.screenshot({
2329
+ path: thumbnailPath,
2330
+ fullPage: false
2331
+ });
2332
+ await page.setViewportSize(snapshotConfig.viewport);
2333
+ await page.waitForTimeout(100);
2334
+ const sections = [];
2335
+ const seenDomPaths = /* @__PURE__ */ new Set();
2336
+ const getAccurateBounds = async (el, sectionType) => {
2337
+ return el.evaluate((element, type) => {
2338
+ const rect = element.getBoundingClientRect();
2339
+ const style = window.getComputedStyle(element);
2340
+ const position = style.position;
2341
+ const isFixed = position === "fixed" || position === "sticky";
2342
+ let y = rect.top + window.scrollY;
2343
+ if (isFixed && (type === "header" || element.tagName.toLowerCase() === "header")) {
2344
+ y = 0;
2345
+ } else if (isFixed && (type === "footer" || element.tagName.toLowerCase() === "footer")) {
2346
+ y = document.documentElement.scrollHeight - rect.height;
2347
+ }
2348
+ return {
2349
+ x: rect.left + window.scrollX,
2350
+ y,
2351
+ width: rect.width,
2352
+ height: rect.height,
2353
+ isFixed
2354
+ };
2355
+ }, sectionType);
2356
+ };
2357
+ const explicitElements = await page.$$(SECTION_SELECTORS.explicit);
2358
+ for (let i = 0; i < explicitElements.length; i++) {
2359
+ const element = explicitElements[i];
2360
+ const domPath = await getDomPath(element);
2361
+ if (seenDomPaths.has(domPath)) continue;
2362
+ seenDomPaths.add(domPath);
2363
+ const basicMetadata = await element.evaluate((el) => {
2364
+ return {
2365
+ sectionId: el.getAttribute("data-section") || "",
2366
+ sectionLabel: el.getAttribute("data-section-label") || "",
2367
+ sectionType: el.getAttribute("data-section-type") || "",
2368
+ isDynamic: el.hasAttribute("data-dynamic")
2369
+ };
2370
+ });
2371
+ let resolvedType = "content";
2372
+ if (basicMetadata.sectionType) {
2373
+ resolvedType = basicMetadata.sectionType;
2374
+ } else if (["header", "footer"].includes(basicMetadata.sectionId)) {
2375
+ resolvedType = basicMetadata.sectionId;
2376
+ } else if (basicMetadata.sectionId.includes("hero")) {
2377
+ resolvedType = "hero";
2378
+ } else if (basicMetadata.sectionId.includes("gallery")) {
2379
+ resolvedType = "gallery";
2380
+ }
2381
+ const bounds = await getAccurateBounds(element, resolvedType);
2382
+ if (!bounds || bounds.height < 10) continue;
2383
+ const sectionId = basicMetadata.sectionId || `section-${i}`;
2384
+ const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
2385
+ try {
2386
+ await element.screenshot({ path: sectionPath });
2387
+ } catch {
2388
+ if (verbose) console.log(` Skipped section screenshot: ${sectionId}`);
2389
+ }
2390
+ const isStructuralOnly = ["header", "footer"].includes(resolvedType);
2391
+ let textKeys = [];
2392
+ if (!isStructuralOnly) {
2393
+ textKeys = await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
2394
+ }
2395
+ const isEditable = !isStructuralOnly && textKeys.length > 0;
2396
+ let displayName = basicMetadata.sectionLabel;
2397
+ if (!displayName) {
2398
+ displayName = await generateSectionDisplayName(element, resolvedType, i);
2399
+ }
2400
+ if (verbose) {
2401
+ console.log(
2402
+ ` Section: ${displayName} (${sectionId}) at y=${Math.round(bounds.y)}${basicMetadata.isDynamic ? " [dynamic]" : ""} - ${textKeys.length} text keys`
2403
+ );
2404
+ }
2405
+ sections.push({
2406
+ sectionId,
2407
+ sectionType: resolvedType,
2408
+ displayName,
2409
+ domPath,
2410
+ bounds: {
2411
+ x: Math.round(bounds.x),
2412
+ y: Math.round(bounds.y),
2413
+ width: Math.round(bounds.width),
2414
+ height: Math.round(bounds.height)
2415
+ },
2416
+ textKeys,
2417
+ isEditable,
2418
+ isStructuralOnly
2419
+ });
2420
+ }
2421
+ for (const [sectionType, selector] of Object.entries(SECTION_SELECTORS)) {
2422
+ if (sectionType === "explicit") continue;
2423
+ const elements = await page.$$(selector);
2424
+ for (let i = 0; i < elements.length; i++) {
2425
+ const element = elements[i];
2426
+ const domPath = await getDomPath(element);
2427
+ if (seenDomPaths.has(domPath)) continue;
2428
+ const isInsideExplicitSection = await element.evaluate((el) => {
2429
+ let current = el.parentElement;
2430
+ while (current) {
2431
+ if (current.dataset?.section) {
2432
+ return true;
2433
+ }
2434
+ current = current.parentElement;
2435
+ }
2436
+ return false;
2437
+ });
2438
+ if (isInsideExplicitSection) {
2439
+ continue;
2440
+ }
2441
+ const isAncestorOfExplicitSection = await element.evaluate((el) => {
2442
+ const explicitSections = el.querySelectorAll("[data-section]");
2443
+ return explicitSections.length > 0;
2444
+ });
2445
+ if (isAncestorOfExplicitSection) {
2446
+ if (verbose) {
2447
+ console.log(` Skipping ${sectionType} container (contains explicit sections)`);
2448
+ }
2449
+ continue;
2450
+ }
2451
+ seenDomPaths.add(domPath);
2452
+ const bounds = await getAccurateBounds(element, sectionType);
2453
+ if (!bounds || bounds.height < 10) continue;
2454
+ const sectionId = `${sectionType}-${i}`;
2455
+ const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
2456
+ try {
2457
+ await element.screenshot({ path: sectionPath });
2458
+ } catch {
2459
+ if (verbose) console.log(` Skipped section screenshot: ${sectionId}`);
2460
+ }
2461
+ const isStructuralOnly = ["header", "footer"].includes(sectionType);
2462
+ const textKeys = isStructuralOnly ? [] : await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
2463
+ if (sectionType === "content" && textKeys.length === 0) {
2464
+ continue;
2465
+ }
2466
+ const isEditable = !isStructuralOnly && textKeys.length > 0;
2467
+ const displayName = await generateSectionDisplayName(element, sectionType, i);
2468
+ if (verbose) {
2469
+ console.log(` Section: ${displayName} at y=${Math.round(bounds.y)}${bounds.isFixed ? " [fixed]" : ""}`);
2470
+ }
2471
+ sections.push({
2472
+ sectionId,
2473
+ sectionType,
2474
+ displayName,
2475
+ domPath,
2476
+ bounds: {
2477
+ x: Math.round(bounds.x),
2478
+ y: Math.round(bounds.y),
2479
+ width: Math.round(bounds.width),
2480
+ height: Math.round(bounds.height)
2481
+ },
2482
+ textKeys,
2483
+ isEditable,
2484
+ isStructuralOnly
2485
+ });
2486
+ }
2487
+ }
2488
+ const hasContentSection = sections.some((s) => s.sectionType === "content");
2489
+ if (!hasContentSection) {
2490
+ if (verbose) console.log(` No content sections found, trying fallback detection...`);
2491
+ const fallbackElements = await detectFallbackContentSections(page, sections);
2492
+ for (let i = 0; i < fallbackElements.length; i++) {
2493
+ const element = fallbackElements[i];
2494
+ const domPath = await getDomPath(element);
2495
+ if (seenDomPaths.has(domPath)) continue;
2496
+ seenDomPaths.add(domPath);
2497
+ const bounds = await element.evaluate((el) => {
2498
+ const rect = el.getBoundingClientRect();
2499
+ return {
2500
+ x: rect.left + window.scrollX,
2501
+ y: rect.top + window.scrollY,
2502
+ width: rect.width,
2503
+ height: rect.height
2504
+ };
2505
+ });
2506
+ if (!bounds || bounds.height < 10) continue;
2507
+ const sectionId = `content-fallback-${i}`;
2508
+ const sectionPath = path4.join(pageOutputDir, "sections", `${sectionId}.png`);
2509
+ try {
2510
+ await element.screenshot({ path: sectionPath });
2511
+ } catch {
2512
+ if (verbose) console.log(` Skipped fallback section screenshot: ${sectionId}`);
2513
+ }
2514
+ const textKeys = await detectTextKeys(page, element, pageInfo.slug, validTextKeys);
2515
+ const displayName = await generateSectionDisplayName(element, "content", i);
2516
+ if (verbose) {
2517
+ console.log(
2518
+ ` Fallback Section: ${displayName} at y=${Math.round(bounds.y)} (${textKeys.length} text keys)`
2519
+ );
2520
+ }
2521
+ sections.push({
2522
+ sectionId,
2523
+ sectionType: "content",
2524
+ displayName,
2525
+ domPath,
2526
+ bounds: {
2527
+ x: Math.round(bounds.x),
2528
+ y: Math.round(bounds.y),
2529
+ width: Math.round(bounds.width),
2530
+ height: Math.round(bounds.height)
2531
+ },
2532
+ textKeys,
2533
+ isEditable: textKeys.length > 0,
2534
+ isStructuralOnly: false
2535
+ });
2536
+ }
2537
+ }
2538
+ await context.close();
2539
+ sections.sort((a, b) => a.bounds.y - b.bounds.y);
2540
+ return {
2541
+ pageSlug: pageInfo.slug,
2542
+ path: pageInfo.path,
2543
+ type: pageInfo.config.type,
2544
+ deletable: pageInfo.config.deletable,
2545
+ title: title || pageInfo.config.title,
2546
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
2547
+ previewUrl: url,
2548
+ viewport: snapshotConfig.viewport,
2549
+ pageHeight,
2550
+ sections
2551
+ };
2552
+ }
2553
+ async function captureSnapshotsCommand(options) {
2554
+ const { target, baseUrl, dryRun, verbose, pages: targetedPages } = options;
2555
+ const targetDir = path4.resolve(target);
2556
+ console.log(chalk7.blue("\u{1F4F8} Page Snapshot Capture"));
2557
+ console.log(chalk7.gray("========================"));
2558
+ console.log(chalk7.gray(`Target: ${targetDir}`));
2559
+ console.log(chalk7.gray(`Base URL: ${baseUrl}`));
2560
+ process.env.SITE_BASE_URL = baseUrl;
2561
+ let config2;
2562
+ try {
2563
+ config2 = loadPagesConfig(targetDir);
2564
+ } catch (error) {
2565
+ console.error(chalk7.red("Error:"), error.message);
2566
+ console.log(chalk7.yellow("\nMake sure .dcs/pages.yaml exists in the target directory."));
2567
+ process.exit(1);
2568
+ }
2569
+ console.log(chalk7.gray(`Site: ${config2.siteSlug}`));
2570
+ const outputDir = path4.join(targetDir, config2.snapshot.outputDir || ".dcs/snapshots");
2571
+ const contentConfig = loadContentConfig(targetDir);
2572
+ if (contentConfig) {
2573
+ const pageCount = Object.keys(contentConfig.pages || {}).length;
2574
+ const globalCount = Object.keys(contentConfig.global || {}).length;
2575
+ console.log(chalk7.gray(`Content: Loaded ${pageCount} pages, ${globalCount} global keys`));
2576
+ }
2577
+ const targetedConfig = loadTargetedSnapshotsConfig(targetDir);
2578
+ if (targetedConfig?.mode === "skip") {
2579
+ console.log("");
2580
+ console.log(chalk7.yellow("\u23ED\uFE0F Skip mode activated!"));
2581
+ console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
2582
+ console.log(chalk7.gray(" No snapshots will be captured."));
2583
+ if (!dryRun) {
2584
+ fs4.mkdirSync(outputDir, { recursive: true });
2585
+ const skipMarker = {
2586
+ skipped: true,
2587
+ reason: targetedConfig.reason,
2588
+ triggeredBy: targetedConfig.triggeredBy,
2589
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2590
+ };
2591
+ fs4.writeFileSync(path4.join(outputDir, "skipped.json"), JSON.stringify(skipMarker, null, 2));
2592
+ }
2593
+ console.log(chalk7.green("\n\u2705 Snapshot capture skipped (as requested)"));
2594
+ return;
2595
+ }
2596
+ let pagesToCapture = await resolveAllPages(config2);
2597
+ console.log(chalk7.gray(`Found ${pagesToCapture.length} total pages in configuration`));
2598
+ if (targetedPages && targetedPages.length > 0) {
2599
+ const targetedSlugs = new Set(targetedPages);
2600
+ const originalCount = pagesToCapture.length;
2601
+ pagesToCapture = pagesToCapture.filter((p) => targetedSlugs.has(p.slug));
2602
+ console.log("");
2603
+ console.log(chalk7.cyan(`\u{1F3AF} Targeted mode: Filtering to ${pagesToCapture.length} of ${originalCount} pages`));
2604
+ } else if (targetedConfig?.mode === "targeted" && targetedConfig.pages.length > 0) {
2605
+ const targetedSlugs = new Set(targetedConfig.pages);
2606
+ const originalCount = pagesToCapture.length;
2607
+ pagesToCapture = pagesToCapture.filter((p) => targetedSlugs.has(p.slug));
2608
+ console.log("");
2609
+ console.log(chalk7.cyan(`\u{1F3AF} Targeted mode: Filtering to ${pagesToCapture.length} of ${originalCount} pages`));
2610
+ console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
2611
+ }
2612
+ console.log("");
2613
+ console.log("Pages to capture:");
2614
+ for (const p of pagesToCapture) {
2615
+ console.log(chalk7.gray(` - ${p.slug} (${p.config.type}) ${p.path}`));
2616
+ }
2617
+ if (pagesToCapture.length === 0) {
2618
+ console.log(chalk7.yellow("\n\u26A0\uFE0F No pages to capture!"));
2619
+ return;
2620
+ }
2621
+ if (dryRun) {
2622
+ console.log(chalk7.yellow("\n--dry-run: Stopping before browser launch"));
2623
+ return;
2624
+ }
2625
+ const { chromium } = await import("playwright");
2626
+ if (fs4.existsSync(outputDir)) {
2627
+ fs4.rmSync(outputDir, { recursive: true });
2628
+ }
2629
+ fs4.mkdirSync(outputDir, { recursive: true });
2630
+ const spinner = ora3("Launching browser...").start();
2631
+ const browser = await chromium.launch();
2632
+ spinner.succeed("Browser launched");
2633
+ const snapshots = [];
2634
+ const failures = [];
2635
+ for (const pageInfo of pagesToCapture) {
2636
+ const pageSpinner = ora3(`Capturing ${pageInfo.slug}...`).start();
2637
+ try {
2638
+ const validTextKeys = getValidTextKeysForPage(contentConfig, pageInfo.slug);
2639
+ const snapshot = await capturePageSnapshot(
2640
+ browser,
2641
+ pageInfo,
2642
+ config2.snapshot,
2643
+ config2.siteSlug,
2644
+ validTextKeys,
2645
+ outputDir,
2646
+ verbose || false
2647
+ );
2648
+ snapshots.push(snapshot);
2649
+ const snapshotPath = path4.join(outputDir, config2.siteSlug, pageInfo.slug, "snapshot.json");
2650
+ fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
2651
+ pageSpinner.succeed(`${pageInfo.slug}: ${snapshot.sections.length} sections captured`);
2652
+ } catch (error) {
2653
+ const message = `${pageInfo.slug}: ${error.message}`;
2654
+ failures.push(message);
2655
+ pageSpinner.fail(`${pageInfo.slug}: Failed - ${error.message}`);
2656
+ }
2657
+ }
2658
+ await browser.close();
2659
+ const manifest = {
2660
+ siteSlug: config2.siteSlug,
2661
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
2662
+ capturedVersion: process.env.GITHUB_SHA,
2663
+ deploymentRef: process.env.GITHUB_REF || "local",
2664
+ pagesConfigVersion: config2.version,
2665
+ pages: snapshots.map((s) => ({
2666
+ pageSlug: s.pageSlug,
2667
+ pagePath: s.path,
2668
+ pageType: s.type,
2669
+ deletable: s.deletable,
2670
+ title: s.title,
2671
+ capturedAt: s.capturedAt,
2672
+ sectionCount: s.sections.length,
2673
+ hasSnapshot: true
2674
+ }))
2675
+ };
2676
+ const manifestPath = path4.join(outputDir, config2.siteSlug, "manifest.json");
2677
+ fs4.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
2678
+ console.log("");
2679
+ console.log(chalk7.green("\u2705 Snapshot capture complete!"));
2680
+ console.log(chalk7.gray(` Captured ${snapshots.length} pages`));
2681
+ console.log(chalk7.gray(` - Static: ${snapshots.filter((s) => s.type === "static").length}`));
2682
+ console.log(chalk7.gray(` - Index: ${snapshots.filter((s) => s.type === "index").length}`));
2683
+ console.log(chalk7.gray(` - Dynamic: ${snapshots.filter((s) => s.type === "dynamic").length}`));
2684
+ console.log(chalk7.gray(` Output: ${outputDir}`));
2685
+ if (failures.length > 0) {
2686
+ throw new Error(`Snapshot capture failed for ${failures.length} page(s): ${failures.join("; ")}`);
2687
+ }
2688
+ }
2689
+
2085
2690
  // src/index.ts
2086
2691
  var program = new Command();
2087
2692
  program.name("dcs").description("DCS (Duff Cloud Services) CLI for customer site management").version(version);
@@ -2124,6 +2729,16 @@ program.command("plans").description("Generate DCS integration plan files for AI
2124
2729
  force: options.force
2125
2730
  });
2126
2731
  });
2732
+ program.command("capture-snapshots").description("Capture visual snapshots of site pages for the portal page editor").option("-t, --target <dir>", "Target directory containing .dcs/pages.yaml", ".").option("-u, --base-url <url>", "Base URL of the running site", "http://localhost:5173").option("-p, --pages <slugs>", "Comma-separated list of page slugs to capture").option("--dry-run", "Show what would be captured without launching browser").option("-v, --verbose", "Show detailed output").action(async (options) => {
2733
+ const pages = options.pages ? options.pages.split(",").map((s) => s.trim()) : void 0;
2734
+ await captureSnapshotsCommand({
2735
+ target: options.target,
2736
+ baseUrl: options.baseUrl,
2737
+ dryRun: options.dryRun,
2738
+ verbose: options.verbose,
2739
+ pages
2740
+ });
2741
+ });
2127
2742
  program.exitOverride();
2128
2743
  try {
2129
2744
  await program.parseAsync(process.argv);
@@ -2132,7 +2747,7 @@ try {
2132
2747
  if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
2133
2748
  process.exit(0);
2134
2749
  }
2135
- console.error(chalk7.red("Error:"), error.message);
2750
+ console.error(chalk8.red("Error:"), error.message);
2136
2751
  process.exit(1);
2137
2752
  }
2138
2753
  }