From b9e19cab61fa98c6bb37c5a881975ee3d3a33463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Tue, 13 Oct 2026 15:45:18 -0400 Subject: [PATCH 1/9] upgraded api calls --- .env.example | 35 + .gitignore | 3 + docs/hubSyncHandler/ADR-manifest-control.md | 178 + .../ADR-org-file-selective-sync.md | 707 ++ docs/hubSyncHandler/ADR-reimport_control.md | 320 + docs/hubSyncHandler/BASE_PATH.md | 141 + docs/hubSyncHandler/IMPLEMENTATION-SUMMARY.md | 288 + docs/hubSyncHandler/README.md | 330 + docs/hubSyncHandler/architecture-sequence.md | 222 + .../manifest-filtering-guide.md | 1556 ++++ docs/hubSyncHandler/usecase-merge-behavior.md | 2 + index.js | 74 +- lib/env.js | 14 + lib/hubSyncHandler.js | 1722 ++++ lib/installationCache.js | 149 + lib/routes.js | 802 ++ test/unit/lib/env.test.js | 15 +- test/unit/lib/hubSyncHandler-import.test.js | 131 + ui/.eslintrc.json | 3 + ui/.gitignore | 40 + ui/README.md | 69 + ui/favicon.svg | 3 + ui/next.config.js | 19 + ui/package-lock.json | 7757 +++++++++++++++++ ui/package.json | 30 + ui/public/favicon.ico | 0 ui/public/favicon.svg | 3 + ui/shield.png | Bin 0 -> 4883 bytes ui/shield.svg | 3 + ui/src/app/[slug]/route.js | 17 + ui/src/app/api/logs/route.js | 28 + ui/src/app/components/EnvVariables.jsx | 151 + ui/src/app/components/HubOrgGraph.jsx | 144 + ui/src/app/components/OrganizationsTable.jsx | 583 ++ .../components/Safe-settings-hubContent.jsx | 279 + ui/src/app/components/ThemeContext.jsx | 71 + ui/src/app/components/ThemeToggle.jsx | 17 + ui/src/app/components/TitleBar.css | 174 + ui/src/app/components/TitleBar.jsx | 148 + ui/src/app/dashboard/env/page.jsx | 23 + ui/src/app/dashboard/help/page.jsx | 35 + ui/src/app/dashboard/logs/page.jsx | 146 + ui/src/app/dashboard/organizations/page.jsx | 24 + ui/src/app/dashboard/page.jsx | 25 + .../app/dashboard/safe-settings-hub/page.jsx | 25 + ui/src/app/globals.css | 277 + ui/src/app/hooks/useClientSafe.js | 45 + ui/src/app/hooks/useHydrated.js | 18 + ui/src/app/layout.jsx | 41 + ui/src/app/not-found.jsx | 16 + ui/src/app/utils/basePath.js | 36 + 51 files changed, 16924 insertions(+), 15 deletions(-) create mode 100644 docs/hubSyncHandler/ADR-manifest-control.md create mode 100644 docs/hubSyncHandler/ADR-org-file-selective-sync.md create mode 100644 docs/hubSyncHandler/ADR-reimport_control.md create mode 100644 docs/hubSyncHandler/BASE_PATH.md create mode 100644 docs/hubSyncHandler/IMPLEMENTATION-SUMMARY.md create mode 100644 docs/hubSyncHandler/README.md create mode 100644 docs/hubSyncHandler/architecture-sequence.md create mode 100644 docs/hubSyncHandler/manifest-filtering-guide.md create mode 100644 docs/hubSyncHandler/usecase-merge-behavior.md create mode 100644 lib/hubSyncHandler.js create mode 100644 lib/installationCache.js create mode 100644 lib/routes.js create mode 100644 test/unit/lib/hubSyncHandler-import.test.js create mode 100644 ui/.eslintrc.json create mode 100644 ui/.gitignore create mode 100644 ui/README.md create mode 100644 ui/favicon.svg create mode 100644 ui/next.config.js create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/public/favicon.ico create mode 100644 ui/public/favicon.svg create mode 100644 ui/shield.png create mode 100644 ui/shield.svg create mode 100644 ui/src/app/[slug]/route.js create mode 100644 ui/src/app/api/logs/route.js create mode 100644 ui/src/app/components/EnvVariables.jsx create mode 100644 ui/src/app/components/HubOrgGraph.jsx create mode 100644 ui/src/app/components/OrganizationsTable.jsx create mode 100644 ui/src/app/components/Safe-settings-hubContent.jsx create mode 100644 ui/src/app/components/ThemeContext.jsx create mode 100644 ui/src/app/components/ThemeToggle.jsx create mode 100644 ui/src/app/components/TitleBar.css create mode 100644 ui/src/app/components/TitleBar.jsx create mode 100644 ui/src/app/dashboard/env/page.jsx create mode 100644 ui/src/app/dashboard/help/page.jsx create mode 100644 ui/src/app/dashboard/logs/page.jsx create mode 100644 ui/src/app/dashboard/organizations/page.jsx create mode 100644 ui/src/app/dashboard/page.jsx create mode 100644 ui/src/app/dashboard/safe-settings-hub/page.jsx create mode 100644 ui/src/app/globals.css create mode 100644 ui/src/app/hooks/useClientSafe.js create mode 100644 ui/src/app/hooks/useHydrated.js create mode 100644 ui/src/app/layout.jsx create mode 100644 ui/src/app/not-found.jsx create mode 100644 ui/src/app/utils/basePath.js diff --git a/.env.example b/.env.example index 2252101bc..c28f4f93b 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,38 @@ # Add operation-wide error/plugin metadata, a check-run link, and an unchecked # review checkbox to each comment page. Existing repo counts remain when false. # PR_COMMENT_SUMMARY_ENABLED=false + +# ADMIN_REPO=safe-settings-config +CONFIG_PATH=.github +SETTINGS_FILE_PATH=settings.yml + +# URL prefix for deployment behind a proxy (appears in browser address bar) +# Default: /safe-settings +# Set to empty string for root path deployment: SAFE_SETTINGS_HUB_URL_PREFIX= +# SAFE_SETTINGS_HUB_URL_PREFIX=/safe-settings + +# Configuration support for Hub-Sync safe-settings feature +# SAFE_SETTINGS_HUB_REPO=safe-settings-config-master +# SAFE_SETTINGS_HUB_ORG=foo-training + +# A subfolder under 'CONFIG_PATH' where the 'organizations//' structure is found +# SAFE_SETTINGS_HUB_PATH=safe-settings +# SAFE_SETTINGS_HUB_DIRECT_PUSH=true + +# Allow 'reimport' of ORG-Level settings to HubSync (eg.: oraganizations//), +# If set to true, the hub-sync will permit reimport the org-level settings from the hub (Org) repo, +# If set to false, it will only permit an initial import of the org-level settings in the hub repo. +# SAFE_SETTINGS_HUB_REIMPORT=false # default=false + +# ┌────────────── second (optional) +# │ ┌──────────── minute +# │ │ ┌────────── hour +# │ │ │ ┌──────── day of month +# │ │ │ │ ┌────── month +# │ │ │ │ │ ┌──── day of week +# │ │ │ │ │ │ +# │ │ │ │ │ │ +# * * * * * * +# CRON=* * * * * # Run every minute + + diff --git a/.gitignore b/.gitignore index 3eee961c2..c4fcc6909 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,9 @@ private-key.pem .vscode yarn.lock +# Runtime logs +*.log + # aws sam stuff .aws-sam samconfig.toml diff --git a/docs/hubSyncHandler/ADR-manifest-control.md b/docs/hubSyncHandler/ADR-manifest-control.md new file mode 100644 index 000000000..33f8b3f20 --- /dev/null +++ b/docs/hubSyncHandler/ADR-manifest-control.md @@ -0,0 +1,178 @@ +# ADR: Manifest-Based Hub-Sync Control with Include/Exclude Rules + +**Status:** ✅ Implemented +**Date Created:** 2026-07-07 +**Date Implemented:** 2026-07-07 + +## Context and Problem Statement + +The current hub-sync manifest (`globals/manifest.yml`) uses a simple rules-based structure with basic target organization selection. Users need more fine-grained control over: +1. Which organizations participate in hub-sync operations (blast radius control) +2. Which files/patterns should be synchronized from the master config repo +3. Ability to exclude specific organizations or file patterns from synchronization + +Current limitations: +- Cannot easily exclude specific organizations from receiving updates +- Cannot exclude specific file patterns (e.g., repos/*, suborgs/*) from syncing +- Users must manually manage which files exist vs. which should sync +- No clear way to quarantine test/admin organizations from production syncs + +## Decision Drivers + +- **Safety**: Prevent accidental configuration pushes to production/sensitive orgs +- **Flexibility**: Support different sync strategies for different org/file combinations +- **Clarity**: Make it obvious which orgs and files are managed by hub-sync +- **Backward Compatibility**: Existing manifest configurations should continue to work +- **Simplicity**: Users should be able to understand and configure the manifest easily + +## Considered Options + +### Option 1: Manifest Controls Globals Only +- Manifest only controls distribution of `globals/` files +- Organization-specific changes (`organizations/{org}/`) always sync to their respective org +- Simple but limited blast radius control + +### Option 2: Manifest Controls Everything (CHOSEN) +- Manifest controls ALL hub-sync operations (both globals/ and organizations/) +- Organizations not in `org_targets` receive NO hub-sync updates +- Files not matching `files_to_sync` patterns are never synchronized +- Full blast radius control at both org and file level + +### Option 3: Per-Rule Include/Exclude Within Existing Structure +- Keep current rules structure, add include/exclude within each rule +- More complex but allows multiple policies (e.g., prod vs dev rules) + +## Decision Outcome + +**Chosen option: Option 2 - Manifest Controls Everything** + +The manifest acts as the master control for all hub-sync operations, providing comprehensive blast radius control. + +### Manifest Schema + +```yaml +rules: + - name: global-defaults + org_targets: + include: + - "*" # All organizations + exclude: + - magh-admin # Admin org excluded + - test-* # Test orgs excluded + + files_to_sync: + include: + - "*.yml" # All YAML files + exclude: + - repos/* # No repo configs + - suborgs/* # No suborg configs + + mergeStrategy: merge # merge | overwrite | preserve + enabled: true + + - name: security-baseline + org_targets: + include: + - prod-* + files_to_sync: + include: + - settings.yml + mergeStrategy: overwrite +``` + +### Behavior Examples + +| Scenario | File Changed | Manifest Rule | Result | +|----------|-------------|---------------|---------| +| 1 | `globals/settings.yml` | Org: all except test-*, File: *.yml included | Syncs to prod orgs only | +| 2 | `globals/repos/common.yml` | File: repos/* excluded | NO SYNC (pattern excluded) | +| 3 | `organizations/test-org/settings.yml` | Org: test-* excluded | NO SYNC (org excluded) | +| 4 | `organizations/prod-org/settings.yml` | Org: prod-* included, File: *.yml included | Syncs to prod-org | +| 5 | `organizations/prod-org/repos/repo.yml` | File: repos/* excluded | NO SYNC (pattern excluded) | + +### Positive Consequences + +- **Full Control**: Organizations can be completely quarantined from hub-sync +- **File-Level Safety**: Dangerous file patterns can be excluded globally +- **Clear Boundaries**: Explicit include/exclude makes intent obvious +- **Flexibility**: Multiple rules support different policies +- **Non-Managed Files**: Files can exist in repo for reference without triggering sync + +### Negative Consequences + +- **Breaking Change**: Org-specific files can now be blocked by manifest (previously always synced) +- **Complexity**: Users must understand both org and file filtering +- **Migration**: Existing manifests may need updates to work as expected +- **Surprising Behavior**: Editing `organizations/my-org/settings.yml` might not sync if org is excluded + +## Implementation Notes + +### Key Changes Required + +1. **Manifest Loading** (`hubSyncHandler.js`): + - Add `loadManifest(context, ref)` function + - Parse and validate manifest structure + - Cache manifest for performance + +2. **Pattern Matching**: + - Use `minimatch` library for glob pattern matching + - Support wildcards: `*`, `test-*`, `*/repos/*` + +3. **Filtering Logic**: + - Apply org filtering in `syncHubGlobalsUpdate` and `syncHubOrgUpdate` + - Apply file filtering before creating commits + - Short-circuit sync if org or file is excluded + +4. **PR Validation Enhancement** (`validateAndReportHubSync`): + - Show which orgs will receive updates based on manifest + - Warn if changed files are excluded by manifest + - Display filtered org list in PR comment + +5. **Manifest Validation**: + - Validate manifest YAML syntax in PRs + - Check for valid include/exclude structure + - Warn about conflicting rules + +### Backward Compatibility Strategy + +Support both old and new manifest formats: + +**Old Format (still supported):** +```yaml +rules: + - name: global-defaults + targets: + - "*" + files: + - "*.yml" +``` + +**New Format:** +```yaml +rules: + - name: global-defaults + org_targets: + include: + - "*" + files_to_sync: + include: + - "*.yml" +``` + +**Migration Logic:** +- If `org_targets` exists, use new format +- If only `targets` exists, convert to `org_targets.include` +- Default behavior without manifest: sync everything (backward compatible) + +## Links + +- Related Documentation: [docs/hubSyncHandler/README.md](./README.md) +- Use Case: [docs/hubSyncHandler/usecase-merge-behavior.md](./usecase-merge-behavior.md) +- Implementation: [lib/hubSyncHandler.js](../../lib/hubSyncHandler.js) + +--- + +**Status**: ✅ Implemented +**Date**: 2026-07-07 +**Author**: Safe-Settings Team +**Decision**: Manifest-based hub-sync control with include/exclude rules for organizations and files diff --git a/docs/hubSyncHandler/ADR-org-file-selective-sync.md b/docs/hubSyncHandler/ADR-org-file-selective-sync.md new file mode 100644 index 000000000..b649fe44c --- /dev/null +++ b/docs/hubSyncHandler/ADR-org-file-selective-sync.md @@ -0,0 +1,707 @@ +# ADR: Organization and File Selective Synchronization + +**Status:** ✅ Implemented +**Date Created:** 2026-07-08 +**Related ADR:** [ADR-manifest-control.md](./ADR-manifest-control.md) + +## Context and Problem Statement + +Hub-Sync needs fine-grained control over: +1. **Which organizations** should participate in synchronization operations +2. **Which files** should be synchronized per organization +3. **Mixed policies** where different orgs have different sync rules + +### Real-World Use Cases + +**Use Case 1: Organization-Level Control** +- ORG-1, ORG-2, ORG-3 should receive sync updates +- ORG-4 should NOT receive any sync updates (excluded) + +**Use Case 2: File-Level Control Per Organization** +- ORG-1: Include `settings.yml` for syncing +- ORG-2: Include `settings.yml` for syncing +- ORG-3: Exclude `settings.yml` from syncing (other files still sync) + +**Use Case 3: Quarantine/Test Organizations** +- Production orgs receive all updates +- Test/staging orgs excluded from automatic syncs +- Admin orgs excluded completely + +**Use Case 4: Phased Rollouts** +- Phase 1: Sync to pilot orgs only +- Phase 2: Add production orgs +- Phase 3: Include all remaining orgs + +## Decision + +The manifest.yml `rules` array provides the solution through **include/exclude patterns** for both organizations and files. Multiple rules can be defined, and orgs/files are synced if they match ANY enabled rule. + +## Solution Architecture + +### Manifest Structure + +```yaml +rules: + - name: + enabled: true|false + org_targets: + include: [patterns] + exclude: [patterns] + files_to_sync: + include: [patterns] + exclude: [patterns] + mergeStrategy: merge|overwrite|preserve +``` + +### Matching Logic + +1. **Organization Matching**: + - Org must match at least one rule's `org_targets.include` pattern + - If org matches any `org_targets.exclude` pattern in that rule, it's skipped + - If no rules match the org, sync is skipped + +2. **File Matching**: + - File must match at least one rule's `files_to_sync.include` pattern + - If file matches any `files_to_sync.exclude` pattern in that rule, it's skipped + - If no rules match the file, sync is skipped + +3. **Pattern Syntax**: Uses `minimatch` glob patterns + - `*` = wildcard (e.g., `test-*` matches `test-org1`, `test-org2`) + - `**` = recursive wildcard + - `?` = single character + - Literal strings also supported + +## Globals/ Distribution Concept + +The **globals/ folder** contains configuration files that are **distributed from the hub to multiple organizations**. This is the core "hub-and-spoke" pattern where: + +- **One file → Many orgs**: A single file in `globals/` (e.g., `globals/suborgs/common.yml`) is pushed to multiple organization repositories +- **Manifest controls distribution**: The manifest determines WHICH orgs receive WHICH files +- **Centralized management**: Update once in hub, distribute to many orgs automatically + +### How It Works + +``` +Hub Repo (safe-settings-config-master) +└── .github/safe-settings/ + ├── globals/ + │ ├── settings.yml → Distributed to filtered orgs + │ ├── suborgs/ + │ │ ├── common.yml → Distributed to filtered orgs + │ │ ├── us-east.yml → Distributed to filtered orgs + │ │ └── eu-west.yml → Distributed to filtered orgs + │ └── manifest.yml (Controls distribution rules) + └── organizations/ + ├── org-1/ (Org-specific overrides) + └── org-2/ + +When globals/suborgs/common.yml changes: +1. syncHubGlobalsUpdate() loads manifest +2. Filters orgs: org-1, org-2, org-3 (based on manifest rules) +3. Filters files: suborgs/common.yml (based on manifest rules) +4. Distributes: Pushes file to org-1, org-2, org-3 admin repos +``` + +### Example: Globals/Suborgs Distribution + +**Scenario**: You have suborg configurations in `globals/suborgs/` that need to be distributed to different organizations based on region. + +**manifest.yml:** +```yaml +rules: + # US organizations get US suborg configs + - name: us-region-suborgs + enabled: true + org_targets: + include: + - "org-us-*" # All US orgs + - "customer-us-*" + files_to_sync: + include: + - "settings.yml" + - "suborgs/us-*.yml" # Only US suborg files + - "suborgs/common.yml" # Common suborg config + exclude: + - "suborgs/eu-*.yml" # No EU files + mergeStrategy: merge + + # EU organizations get EU suborg configs + - name: eu-region-suborgs + enabled: true + org_targets: + include: + - "org-eu-*" # All EU orgs + - "customer-eu-*" + files_to_sync: + include: + - "settings.yml" + - "suborgs/eu-*.yml" # Only EU suborg files + - "suborgs/common.yml" # Common suborg config + exclude: + - "suborgs/us-*.yml" # No US files + mergeStrategy: merge + + # Admin organizations get ALL suborg configs + - name: admin-all-regions + enabled: true + org_targets: + include: + - "admin-*" + files_to_sync: + include: + - "suborgs/*.yml" # All suborg files + - "*.yml" # All YAML files + mergeStrategy: merge +``` + +**Distribution Table:** + +| File Changed | US Orgs (org-us-*) | EU Orgs (org-eu-*) | Admin Orgs (admin-*) | +|--------------|--------------------|--------------------|----------------------| +| `globals/suborgs/common.yml` | ✅ Distributed | ✅ Distributed | ✅ Distributed | +| `globals/suborgs/us-east.yml` | ✅ Distributed | ❌ Excluded | ✅ Distributed | +| `globals/suborgs/us-west.yml` | ✅ Distributed | ❌ Excluded | ✅ Distributed | +| `globals/suborgs/eu-west.yml` | ❌ Excluded | ✅ Distributed | ✅ Distributed | +| `globals/suborgs/eu-north.yml` | ❌ Excluded | ✅ Distributed | ✅ Distributed | +| `globals/settings.yml` | ✅ Distributed | ✅ Distributed | ✅ Distributed | + +**Key Point**: When you edit `globals/suborgs/us-east.yml`, it automatically gets pushed to: +- ✅ All `org-us-*` organizations +- ✅ All `admin-*` organizations +- ❌ NOT to `org-eu-*` organizations (excluded by manifest) + +### Code Flow for Globals Distribution + +```javascript +// lib/hubSyncHandler.js - Line 644 +async function syncHubGlobalsUpdate(robot, context, files) { + // 1. Load manifest + const manifest = await loadManifest(context.octokit, prHeadSha, robot.log) + + // 2. Get all organizations with installations + const allOrgLogins = installs + .filter(i => i.account && i.account.type === 'Organization') + .map(i => i.account.login) + + // 3. Filter orgs by manifest (Line 667) + const filteredOrgLogins = filterOrgsByManifest(allOrgLogins, manifest, robot.log) + // Example: ["org-us-east", "org-us-west", "admin-prod"] + + // 4. For each changed file in globals/ + for (const fileObj of changedGlobals) { + const fileName = fileObj.filename.split('/').pop() + + // 5. Check if file should be synced (Line 680) + if (!matchesFilesToSync(fileName, manifest, robot.log)) { + continue // Skip this file + } + + // 6. Distribute file to ALL filtered orgs (Line 833+) + for (const orgName of filteredOrgLogins) { + // Push file to orgName/.github-admin/ + await githubDest.repos.createOrUpdateFileContents({ + owner: orgName, + repo: destRepo, + path: destPath, + content: fileContent, + // ... + }) + } + } +} +``` + +**Example execution:** +``` +Change: globals/suborgs/us-east.yml + +Step 1: Load manifest → Rules loaded +Step 2: Get all orgs → ["org-us-east", "org-us-west", "org-eu-north", "admin-prod"] +Step 3: Filter by manifest → ["org-us-east", "org-us-west", "admin-prod"] + (org-eu-north excluded - doesn't match us-region-suborgs rule) +Step 4: Check file pattern → "suborgs/us-east.yml" matches "suborgs/us-*.yml" +Step 5: Distribute to: + ✅ org-us-east/.github-admin/suborgs/us-east.yml + ✅ org-us-west/.github-admin/suborgs/us-east.yml + ✅ admin-prod/.github-admin/suborgs/us-east.yml +``` + +## Globals/ vs Organizations/ Syncing + +There are two distinct sync patterns in Safe-Settings hub-sync: + +### Pattern 1: Globals/ Distribution (One-to-Many) +- **Source**: `hub/globals/` folder +- **Destination**: Multiple organization repos +- **Purpose**: Share common configuration across many orgs +- **Example**: `globals/suborgs/common.yml` → distributed to org-1, org-2, org-3 +- **Control**: Manifest `org_targets` determines which orgs receive the file + +``` +Hub: globals/suborgs/common.yml + ├─→ org-us-east/.github-admin/suborgs/common.yml + ├─→ org-us-west/.github-admin/suborgs/common.yml + └─→ admin-prod/.github-admin/suborgs/common.yml +``` + +### Pattern 2: Organizations/ Sync (One-to-One) +- **Source**: `hub/organizations/{org-name}/` folder +- **Destination**: Specific organization repo (matching folder name) +- **Purpose**: Org-specific overrides or configurations +- **Example**: `organizations/org-us-east/settings.yml` → only org-us-east +- **Control**: Manifest `org_targets` can still exclude the org entirely + +``` +Hub: organizations/org-us-east/settings.yml + └─→ org-us-east/.github-admin/settings.yml (only this org) +``` + +### Combined Example + +**Hub Structure:** +``` +.github/safe-settings/ +├── globals/ +│ ├── settings.yml (Base settings - distributed to all) +│ └── suborgs/ +│ ├── common.yml (Common suborgs - distributed to all) +│ └── us-east.yml (US-specific - distributed to US orgs) +└── organizations/ + └── org-us-prod/ + └── settings.yml (Override for org-us-prod only) +``` + +**What each org receives:** + +| Organization | Files Received | Source | +|-------------|----------------|---------| +| org-us-east | settings.yml | Merge of globals/ + no org-specific override | +| | suborgs/common.yml | globals/ distribution | +| | suborgs/us-east.yml | globals/ distribution | +| org-us-prod | settings.yml | Merge of globals/ + organizations/org-us-prod/ | +| | suborgs/common.yml | globals/ distribution | +| | suborgs/us-east.yml | globals/ distribution | +| org-eu-west | settings.yml | Merge of globals/ + no org-specific override | +| | suborgs/common.yml | globals/ distribution | +| | ❌ suborgs/us-east.yml | NOT distributed (excluded by manifest) | + +**Key Insight**: +- **globals/** = "Push THIS file to THESE orgs" (manifest controls target orgs) +- **organizations/** = "Push THESE files to THIS org" (folder name = target org) + +## Implementation Examples + +### Example 1: Basic Organization Exclusion + +**Requirement:** +- ORG-1, ORG-2, ORG-3 included +- ORG-4 excluded + +**manifest.yml:** +```yaml +rules: + - name: selected-orgs-only + enabled: true + org_targets: + include: + - "ORG-1" + - "ORG-2" + - "ORG-3" + exclude: + - "ORG-4" + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge +``` + +**Alternative with wildcards:** +```yaml +rules: + - name: all-except-org4 + enabled: true + org_targets: + include: + - "*" # All orgs + exclude: + - "ORG-4" # Except ORG-4 + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge +``` + +### Example 2: Per-Organization File Exclusion + +**Requirement:** +- ORG-1: Include settings.yml +- ORG-2: Include settings.yml +- ORG-3: Exclude settings.yml (but sync other files) + +**manifest.yml:** +```yaml +rules: + # Rule 1: ORG-1 and ORG-2 get settings.yml + - name: org1-and-org2-full-sync + enabled: true + org_targets: + include: + - "ORG-1" + - "ORG-2" + files_to_sync: + include: + - "*.yml" # All YAML files including settings.yml + mergeStrategy: merge + + # Rule 2: ORG-3 gets everything EXCEPT settings.yml + - name: org3-without-settings + enabled: true + org_targets: + include: + - "ORG-3" + files_to_sync: + include: + - "*.yml" # All YAML files + exclude: + - "settings.yml" # Except settings.yml + mergeStrategy: merge +``` + +### Example 3: Production vs Test Separation + +**Requirement:** +- Production orgs: Sync everything +- Test orgs: Sync only specific files +- Admin orgs: No sync at all + +**manifest.yml:** +```yaml +rules: + # Rule 1: Production orgs get all files + - name: production-full-sync + enabled: true + org_targets: + include: + - "prod-*" + - "customer-*" + exclude: + - "*-test" + - "*-admin" + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge + + # Rule 2: Test orgs get limited files + - name: test-limited-sync + enabled: true + org_targets: + include: + - "*-test" + - "sandbox-*" + files_to_sync: + include: + - "settings.yml" + - "repos/test-*.yml" + exclude: + - "repos/prod-*.yml" + mergeStrategy: preserve + + # Rule 3: Admin orgs are implicitly excluded (no rule matches them) + # No rule needed - they won't match any org_targets +``` + +### Example 4: Phased Rollout Strategy + +**Phase 1: Pilot orgs only** +```yaml +rules: + - name: phase-1-pilot + enabled: true + org_targets: + include: + - "pilot-org-1" + - "pilot-org-2" + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge +``` + +**Phase 2: Add production orgs** (modify existing rule) +```yaml +rules: + - name: phase-2-production + enabled: true + org_targets: + include: + - "pilot-org-1" + - "pilot-org-2" + - "prod-*" # Added production orgs + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge +``` + +**Phase 3: Include all except admin** (modify existing rule) +```yaml +rules: + - name: phase-3-all-orgs + enabled: true + org_targets: + include: + - "*" # All orgs + exclude: + - "*-admin" # Except admin orgs + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge +``` + +## Behavior Matrix + +| Org | Rule Match | File | Rule Match | Result | +|-----|-----------|------|-----------|---------| +| ORG-1 | Include in rule-1 | settings.yml | Include in rule-1 | ✅ SYNC | +| ORG-2 | Include in rule-1 | settings.yml | Include in rule-1 | ✅ SYNC | +| ORG-3 | Include in rule-2 | settings.yml | Exclude in rule-2 | ❌ SKIP | +| ORG-3 | Include in rule-2 | repos/common.yml | Include in rule-2 | ✅ SYNC | +| ORG-4 | Exclude in rule-1 | settings.yml | Include in rule-1 | ❌ SKIP (org excluded) | +| test-org | No match | settings.yml | N/A | ❌ SKIP (no rule matches) | + +## Testing Scenarios + +### Scenario 1: Organization Included, File Excluded +```yaml +rules: + - name: test-rule + org_targets: + include: ["test-org"] + files_to_sync: + include: ["*.yml"] + exclude: ["settings.yml"] +``` +- Hub change: `organizations/test-org/settings.yml` +- **Result**: ❌ SKIP (file excluded despite org match) + +### Scenario 2: Organization Excluded, File Included +```yaml +rules: + - name: test-rule + org_targets: + include: ["prod-*"] + exclude: ["prod-admin"] + files_to_sync: + include: ["*.yml"] +``` +- Hub change: `organizations/prod-admin/settings.yml` +- **Result**: ❌ SKIP (org excluded despite file match) + +### Scenario 3: Multiple Rules, One Matches +```yaml +rules: + - name: rule-1 + org_targets: + include: ["org-1"] + files_to_sync: + include: ["settings.yml"] + + - name: rule-2 + org_targets: + include: ["org-2"] + files_to_sync: + include: ["repos/*.yml"] +``` +- Hub change: `organizations/org-2/repos/common.yml` +- **Result**: ✅ SYNC (matches rule-2) + +### Scenario 4: No Rules Match +```yaml +rules: + - name: only-prod + org_targets: + include: ["prod-*"] + files_to_sync: + include: ["*.yml"] +``` +- Hub change: `organizations/test-org/settings.yml` +- **Result**: ❌ SKIP (no rule matches org "test-org") + +## Implementation Details + +### Code Location +- **Manifest Loading**: `lib/hubSyncHandler.js` - `loadManifest()` +- **Org Matching**: `lib/hubSyncHandler.js` - `matchesOrgTargets()` +- **File Matching**: `lib/hubSyncHandler.js` - `matchesFilesToSync()` +- **Normalization**: `lib/hubSyncHandler.js` - `normalizeManifestRules()` + +### Functions + +```javascript +// Check if org should be synced +function matchesOrgTargets(orgName, manifest, logger) { + // Returns true if ANY rule includes the org + // Returns false if org is excluded or no rules match +} + +// Check if file should be synced +function matchesFilesToSync(filePath, manifest, logger) { + // Returns true if ANY rule includes the file + // Returns false if file is excluded or no rules match +} +``` + +### Caching +- Manifest is cached for 1 minute (60 seconds) +- Cache key: manifest content hash +- Cache invalidated on manifest.yml changes + +## Migration from Existing Setup + +### Current State (No Manifest) +- All orgs receive all file changes +- No filtering or exclusion capability + +### Migration Path + +**Step 1: Create basic manifest** (no behavior change) +```yaml +rules: + - name: all-orgs-all-files + org_targets: + include: ["*"] + files_to_sync: + include: ["*.yml"] + mergeStrategy: merge +``` + +**Step 2: Add exclusions incrementally** +```yaml +rules: + - name: all-except-test + org_targets: + include: ["*"] + exclude: ["*-test", "*-admin"] # Added exclusions + files_to_sync: + include: ["*.yml"] + mergeStrategy: merge +``` + +**Step 3: Add per-org file rules** +```yaml +rules: + - name: production-full-sync + org_targets: + include: ["prod-*"] + files_to_sync: + include: ["*.yml"] + mergeStrategy: merge + + - name: test-limited-sync + org_targets: + include: ["*-test"] + files_to_sync: + include: ["settings.yml"] # Only settings.yml for test + exclude: ["repos/*"] + mergeStrategy: preserve +``` + +## Troubleshooting + +### Issue: Org not receiving syncs + +**Diagnostic Steps:** +1. Check if org matches any `org_targets.include` pattern +2. Verify org is not in any `org_targets.exclude` list +3. Ensure at least one rule has `enabled: true` +4. Check logs for "Org {name} not matched by any manifest rule" + +### Issue: File not syncing to org + +**Diagnostic Steps:** +1. Check if file matches any `files_to_sync.include` pattern +2. Verify file is not in any `files_to_sync.exclude` list +3. Check both org AND file rules (both must match) +4. Check logs for "File {path} not matched by any manifest rule" + +### Issue: Unexpected sync behavior + +**Debugging:** +```yaml +# Enable debug logging to see rule matching +rules: + - name: debug-rule + enabled: true + org_targets: + include: ["*"] + files_to_sync: + include: ["*.yml"] +``` +- Check logs for: "Org {name} included by rule '{rule-name}'" +- Check logs for: "File {path} included by rule '{rule-name}'" + +## Best Practices + +1. **Start Broad, Refine Incrementally** + - Begin with `include: ["*"]` + - Add `exclude` patterns as needed + +2. **Use Descriptive Rule Names** + - ✅ `production-orgs-only` + - ❌ `rule-1` + +3. **Document Intent in Comments** + ```yaml + rules: + # Production orgs get all updates for compliance + - name: production-full-sync + org_targets: + include: ["prod-*"] + ``` + +4. **Test with Disabled Rules First** + ```yaml + rules: + - name: experimental-rule + enabled: false # Test rule, not active yet + ``` + +5. **Use Multiple Rules for Complex Logic** + - Separate concerns (prod vs test vs admin) + - Easier to understand and maintain + - Clear intent for each rule + +6. **Prefer Explicit Over Implicit** + - ✅ Explicit exclude list + - ❌ Relying on "no match" behavior + +## Positive Consequences + +- **Precise Control**: Org and file level filtering +- **Safety**: Prevent accidental syncs to sensitive orgs +- **Flexibility**: Different rules for different scenarios +- **Visibility**: Clear declaration of sync policy +- **Testability**: Easy to predict behavior from manifest + +## Negative Consequences + +- **Complexity**: More configuration to manage +- **Hidden Behavior**: Files exist but don't sync (surprising) +- **Maintenance**: Must keep manifest updated as orgs change +- **Learning Curve**: Users must understand pattern matching + +## Related Documentation + +- [Manifest Control ADR](./ADR-manifest-control.md) - Original design decision +- [Hub-Sync Architecture](./architecture-sequence.md) - Overall flow +- [Manifest Reference](./README.md#manifest-yml-reference) - Detailed syntax +- [Implementation Summary](./IMPLEMENTATION-SUMMARY.md) - Code details + +--- + +**Status**: Implemented +**Date**: 2026-07-08 +**Author**: Safe-Settings Team +**Decision**: Organization and file selective synchronization via manifest include/exclude patterns diff --git a/docs/hubSyncHandler/ADR-reimport_control.md b/docs/hubSyncHandler/ADR-reimport_control.md new file mode 100644 index 000000000..7640c45d0 --- /dev/null +++ b/docs/hubSyncHandler/ADR-reimport_control.md @@ -0,0 +1,320 @@ +# ADR: Reimport Control for Hub-Sync Organization Retrieval + +**Status:** Proposed +**Date Created:** 2026-07-08 +**Related Function:** `retrieveSettingsFromOrgs()` +**Environment Variable:** `SAFE_SETTINGS_HUB_REIMPORT` + +## Context and Problem Statement + +The `retrieveSettingsFromOrgs()` function imports organization settings from individual org admin repositories into the hub-sync master repository. Currently, it has limited control over when imports occur: + +**Current Behavior:** +- Checks if `organizations//` folder exists in hub repo +- **Skips** import if folder exists AND has at least one file +- **Proceeds** with import if folder doesn't exist OR is empty + +**Limitations:** +1. **Partial imports cannot be fixed** - If someone manually creates the folder with only some files, or if an import fails partway through, subsequent attempts are blocked +2. **No update mechanism** - If an organization updates their configuration files, there's no way to re-import the updated content +3. **No reimport control** - Cannot refresh hub content from source of truth (org repos) after initial import +4. **Single behavior mode** - All deployments have the same (safe) behavior with no flexibility + +## Decision Drivers + +1. **Safety first** - Default behavior should prevent accidental overwrites in production +2. **Flexibility needed** - Some scenarios require force-updating or completing partial imports +3. **Operational clarity** - Different environments may need different behaviors (dev vs prod) +4. **Backward compatibility** - Existing deployments should not change behavior + +## Considered Options + +### Option 1: Add `allowReimport` function parameter +```javascript +retrieveSettingsFromOrgs(robot, orgNames, { allowReimport: true }) +``` + +**Pros:** +- Explicit control at call site +- Can be conditional per invocation + +**Cons:** +- Requires code changes to use +- No configuration at deployment level +- API callers must handle the flag + +### Option 2: Environment variable `SAFE_SETTINGS_HUB_REIMPORT` (Chosen) +```bash +SAFE_SETTINGS_HUB_REIMPORT=false # Default - only import if nothing exists (safe mode) +SAFE_SETTINGS_HUB_REIMPORT=true # Allow re-import/updates of existing content +``` + +**Pros:** +- ✅ Configuration-based, no code changes needed +- ✅ Different behavior per deployment environment +- ✅ Safe default prevents production accidents +- ✅ Backward compatible (defaults to current behavior) +- ✅ Consistent with other hub-sync env vars +- ✅ Intuitive naming - flag describes what it enables (reimporting) + +**Cons:** +- Cannot override per-call (acceptable tradeoff) +- Requires restart to change behavior (acceptable) + +### Option 3: Manifest-based control per org +```yaml +rules: + - name: allow-reimport + org_targets: ["dev-*"] + reimport_allowed: true +``` + +**Pros:** +- Granular per-organization control + +**Cons:** +- Complex to implement +- Manifest may not exist yet during initial import +- Over-engineered for current needs + +## Decision + +**Implement Option 2: Environment variable `SAFE_SETTINGS_HUB_REIMPORT`** + +### Behavior Matrix + +| Scenario | `REIMPORT=false` (default) | `REIMPORT=true` | +|----------|---------------------------|-------------------| +| Folder doesn't exist (404) | ✅ Import | ✅ Import | +| Folder exists but empty (length=0) | ✅ Import | ✅ Import | +| Folder exists with files | ⛔ Skip (safe) | ✅ Re-import/Update | +| Partial/incomplete import | ⛔ Skip (stuck) | ✅ Complete import | +| Files changed in org | ⛔ Skip (stale) | ✅ Refresh from source | + +### Default Value + +**`REIMPORT=false`** (safe mode) +- Maintains current behavior +- Backward compatible +- Prevents accidental overwrites +- Suitable for production environments + +## Implementation Details + +### Environment Variable + +**Name:** `SAFE_SETTINGS_HUB_REIMPORT` +**Type:** String (boolean) +**Valid Values:** `'true'`, `'false'`, `'1'`, `'0'` +**Default:** `'false'` +**Location:** `.env` file or deployment configuration + +### Code Changes + +**File:** `lib/hubSyncHandler.js` +**Function:** `retrieveSettingsFromOrgs()` +**Lines to modify:** ~1049-1070 (existence check block) + +```javascript +// Read env var (default to 'false' for safe mode - no reimport) +const allowReimport = (env.SAFE_SETTINGS_HUB_REIMPORT || 'false').toLowerCase() === 'true' + +// Check existence unless reimport is explicitly enabled +if (!allowReimport) { + const destOrgPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${orgName}` + try { + const destCheck = await githubHub.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: hubRepoName, + path: destOrgPath, + ref: baseBranch + }) + + if (Array.isArray(destCheck.data) && destCheck.data.length > 0) { + robot.log.info(`Skipping ${orgName}: already present in hub (REIMPORT=false)`) + results.push({ org: orgName, status: 'skipped', reason: 'already_imported' }) + continue + } + } catch (probeErr) { + if (!(probeErr && probeErr.status === 404)) { + // Handle non-404 errors + robot.log.warn(`Failed to probe hub destination for ${orgName}: ${probeErr.message}`) + results.push({ org: orgName, error: `failed to check destination: ${probeErr.message}` }) + continue + } + // 404 = folder doesn't exist, proceed with import + } +} else { + // Reimport mode enabled: always import/update + robot.log.info(`Re-importing ${orgName}: REIMPORT=true, will update existing files`) +} + +// Proceed with import for this org... +``` + +### Result Status Values + +Update result objects to distinguish scenarios: + +| Status | Reason | When | +|--------|--------|------| +| `'imported'` | `'first_import'` | New org, folder didn't exist | +| `'imported'` | `'empty_folder'` | Folder existed but was empty | +| `'imported'` | `'reimported'` | REIMPORT=true, updated existing content | +| `'skipped'` | `'already_imported'` | REIMPORT=false, folder has content | +| `'N/A'` | `'admin_repo_not_found'` | Org's admin repo doesn't exist | + +### Logging Enhancements + +1. **Log mode at function entry:** + ```javascript + robot.log.info(`Import mode: REIMPORT=${allowReimport}`) + ``` + +2. **Log skip reason clearly:** + ```javascript + robot.log.info(`Skipping ${orgName}: already present in hub (REIMPORT=false)`) + ``` + +3. **Log reimport:** + ```javascript + robot.log.info(`Re-importing ${orgName}: REIMPORT=true, will update existing files`) + ``` + +4. **Log file counts:** + ```javascript + robot.log.info(`Imported ${treeEntries.length} files for ${orgName} (mode: ${allowReimport ? 'reimport' : 'initial'})`) + ``` + +### PR Body Enhancement + +When REIMPORT=true and overwriting, update PR body: + +```javascript +const prBody = allowReimport + ? `Automated re-import of settings from ${orgName} admin repo (${adminRepo}) into the hub. + +**Mode:** REIMPORT=true (reimport enabled) +**Previous import detected:** Files will be updated with current org configuration.` + : `Automated import of settings from ${orgName} admin repo (${adminRepo}) into the hub.` +``` + +## Consequences + +### Positive + +1. **Safe by default** - REIMPORT=false prevents production accidents +2. **Operational flexibility** - Dev/staging can use REIMPORT=true for testing +3. **Recovery from failures** - Partial imports can be completed with REIMPORT=true +4. **Content refresh** - Stale hub content can be updated from org source of truth +5. **Backward compatible** - Existing deployments unchanged +6. **Consistent pattern** - Follows existing hub-sync env var conventions +7. **Intuitive semantics** - Flag name describes what it enables (reimporting), not what it restricts + +### Negative + +1. **Global setting** - Cannot override per-call (mitigated by being deployment-scoped) +2. **Requires restart** - Env var change needs process restart (acceptable for this use case) +3. **No per-org granularity** - All orgs in a call use the same mode (can be addressed later if needed) +4. **Overwrites without merge** - REIMPORT=true replaces entire tree (intentional, but document clearly) + +### Neutral + +1. **Additional env var** - Adds to configuration surface (documented in README) +2. **Testing needed** - Requires testing both modes +3. **Documentation updates** - Need to document use cases for each mode + +## Use Cases + +### Use Case 1: Initial Setup (Production) +```bash +SAFE_SETTINGS_HUB_REIMPORT=false # Default - can be omitted +``` +- Import orgs for the first time +- Safe: won't overwrite if something already exists +- Suitable for production + +### Use Case 2: Fix Partial Import (Dev/Ops) +```bash +SAFE_SETTINGS_HUB_REIMPORT=true +``` +- Manually created folder with incomplete files +- Previous import failed partway through +- Need to complete the import + +### Use Case 3: Refresh Stale Content (Dev/Staging) +```bash +SAFE_SETTINGS_HUB_REIMPORT=true +``` +- Organization updated their configuration +- Hub content is out of sync with org repos +- Need to refresh from source of truth + +### Use Case 4: Testing Import Logic (Dev) +```bash +SAFE_SETTINGS_HUB_REIMPORT=true +``` +- Repeatedly test import process +- No need to manually delete folders between tests +- Faster iteration cycle + +## Documentation Updates Needed + +1. **README.md** - Add `SAFE_SETTINGS_HUB_REIMPORT` to environment variables section +2. **API docs** - Update `retrieveSettingsFromOrgs` documentation +3. **Deployment guide** - Recommend REIMPORT=false (default) for production +4. **Troubleshooting** - Document using REIMPORT=true to fix partial imports +5. **Example .env** - Include the variable with comment explaining safe default + +## Testing Strategy + +### Unit Tests + +1. Test with `REIMPORT=false` (default): + - Folder exists with files → skip + - Folder doesn't exist → import + - Folder empty → import + +2. Test with `REIMPORT=true`: + - Folder exists with files → re-import + - Folder doesn't exist → import + - Folder empty → import + +### Integration Tests + +1. Import same org twice: + - First time: should import + - Second time with REIMPORT=false: should skip + - Second time with REIMPORT=true: should re-import + +2. Verify file updates: + - Change org repo content + - Re-import with REIMPORT=true + - Verify hub reflects new content + +## Future Enhancements + +1. **Per-org override** - Add `allowReimport` parameter to API for per-call control +2. **Dry-run mode** - Preview what would be imported without creating PR +3. **Incremental update** - Only update changed files instead of full tree replacement +4. **Import audit log** - Track when imports/re-imports occurred +5. **Manifest integration** - Allow manifest to control per-org reimport policy + +## References + +- Related ADR: [ADR-manifest-control.md](./ADR-manifest-control.md) +- Function: `lib/hubSyncHandler.js:retrieveSettingsFromOrgs()` +- Environment variables: `lib/env.js` +- Existing pattern: `SAFE_SETTINGS_HUB_DIRECT_PUSH`, `SAFE_SETTINGS_HUB_URL_PREFIX` + +## Decision Review + +- [ ] Review and approve ADR +- [ ] Implement env var parsing +- [ ] Update existence check logic +- [ ] Add logging +- [ ] Update result status values +- [ ] Write unit tests +- [ ] Update documentation +- [ ] Test in dev environment +- [ ] Deploy to production with REIMPORT=false (default) diff --git a/docs/hubSyncHandler/BASE_PATH.md b/docs/hubSyncHandler/BASE_PATH.md new file mode 100644 index 000000000..548fbae76 --- /dev/null +++ b/docs/hubSyncHandler/BASE_PATH.md @@ -0,0 +1,141 @@ +# URL Prefix Configuration + +Safe Settings supports deployment behind a reverse proxy (like NGINX) that routes to the application using a custom URL prefix. + +## Overview + +By default, Safe Settings serves its UI and API from `/safe-settings`: +- Dashboard: `http://localhost:3000/safe-settings/dashboard` +- API: `http://localhost:3000/safe-settings/api/safe-settings/...` + +You can customize this by setting the `SAFE_SETTINGS_HUB_URL_PREFIX` environment variable, or set it to an empty string for root path deployment: +- Dashboard (root): `http://localhost:3000/dashboard` +- API (root): `http://localhost:3000/api/safe-settings/...` + +## Configuration + +### Default Behavior + +Safe Settings defaults to `SAFE_SETTINGS_HUB_URL_PREFIX=/safe-settings`. No configuration needed for this default. + +### Customizing the URL Prefix + +To use a different URL prefix, add to your `.env` file: + +```bash +SAFE_SETTINGS_HUB_URL_PREFIX=/my-custom-path +``` + +### Root Path Deployment + +To deploy at the root path instead, set SAFE_SETTINGS_HUB_URL_PREFIX to an empty string: + +```bash +SAFE_SETTINGS_HUB_URL_PREFIX= +``` + +**Important:** +- The SAFE_SETTINGS_HUB_URL_PREFIX will automatically add a leading `/` if you forget it +- Examples: `/safe-settings`, `safe-settings`, `/apps/safe-settings`, `custom-prefix` (all work!) +- Do NOT end with `/` +- Set to empty string or `/` for root path deployment + +### 2. Rebuild the UI + +After changing the SAFE_SETTINGS_HUB_URL_PREFIX, you must rebuild the Next.js UI: + +```bash +cd ui +npm run build +cd .. +``` + +### 3. Restart the application + +```bash +npm start +# or +npm run dev +``` + +## NGINX Configuration Example + +Here's an example NGINX configuration for routing requests to Safe Settings at `/safe-settings`: + +Make sure to set `SAFE_SETTINGS_HUB_URL_PREFIX=/safe-settings` in your `.env` file before starting the application. + +```nginx +server { + listen 80; + server_name your-domain.com; + + # Route /safe-settings to Safe Settings application + location /safe-settings { + proxy_pass http://localhost:3000/safe-settings; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Route other paths to different applications + location /other-app { + proxy_pass http://localhost:4000; + # ... other proxy settings + } +} +``` + +## How It Works + +The SAFE_SETTINGS_HUB_URL_PREFIX configuration affects three layers: + +1. **Backend Routing** (`lib/routes.js`): Express router is mounted at the SAFE_SETTINGS_HUB_URL_PREFIX instead of root +2. **Next.js Configuration** (`ui/next.config.js`): The `basePath` setting tells Next.js to generate assets with the correct URL prefix +3. **Frontend Links & API Calls** (`ui/src/app/**`): Navigation links and API fetch calls use the `withBasePath()` utility to prepend the URL prefix + +All API endpoints in the frontend components (`EnvVariables.jsx`, `OrganizationsTable.jsx`, `Safe-settings-hubContent.jsx`, `HubOrgGraph.jsx`) have been updated to use `withBasePath()` for proper routing. + +## Testing Locally + +To test the default SAFE_SETTINGS_HUB_URL_PREFIX locally without NGINX: + +1. No configuration needed (defaults to `/safe-settings`) +2. Build UI: `cd ui && npm run build && cd ..` +3. Start app: `npm run dev` +4. Access at: `http://localhost:3000/safe-settings/dashboard` + +To test a custom SAFE_SETTINGS_HUB_URL_PREFIX: + +1. Set `SAFE_SETTINGS_HUB_URL_PREFIX=/your-path` in `.env` +2. Rebuild UI: `cd ui && npm run build && cd ..` +3. Start app: `npm run dev` +4. Access at: `http://localhost:3000/your-path/dashboard` + +## Troubleshooting + +### Assets not loading +- Make sure you rebuilt the UI after changing SAFE_SETTINGS_HUB_URL_PREFIX +- Check browser console for 404 errors +- Verify NGINX is correctly proxying all paths under the URL prefix + +### API calls failing +- Ensure your proxy passes the full path including SAFE_SETTINGS_HUB_URL_PREFIX +- Check that relative API URLs are being used (not absolute URLs) + +### Navigation broken +- Verify all `` tags use `withBasePath()` utility +- Check that `pathname` comparisons account for the URL prefix + +## Deploying at Root Path + +To deploy at root path instead of the default `/safe-settings`: + +1. Set `SAFE_SETTINGS_HUB_URL_PREFIX=` (empty string) in `.env` +2. Rebuild UI: `cd ui && npm run build && cd ..` +3. Restart application +4. Access at: `http://localhost:3000/dashboard` diff --git a/docs/hubSyncHandler/IMPLEMENTATION-SUMMARY.md b/docs/hubSyncHandler/IMPLEMENTATION-SUMMARY.md new file mode 100644 index 000000000..fdea8344f --- /dev/null +++ b/docs/hubSyncHandler/IMPLEMENTATION-SUMMARY.md @@ -0,0 +1,288 @@ +# Manifest-Based Hub-Sync Control - Implementation Summary + +**Date:** 2026-07-07 +**Status:** ✅ Completed +**ADR:** [ADR-manifest-control.md](./ADR-manifest-control.md) + +## Overview + +Successfully implemented manifest-based filtering for hub-sync operations with include/exclude pattern support. This feature provides fine-grained control over which organizations and files participate in hub-sync, enabling "blast radius" control for configuration deployments. + +## What Was Implemented + +### 1. Core Manifest Functions + +Added to `lib/hubSyncHandler.js`: + +- **`loadManifest(octokit, ref, logger)`** - Loads and parses manifest.yml with 1-minute caching +- **`normalizeManifestRules(manifest)`** - Converts old format to new include/exclude format for backward compatibility +- **`matchesOrgTargets(orgName, manifest, logger)`** - Checks if an organization matches any rule's org_targets +- **`matchesFilesToSync(filePath, manifest, logger)`** - Checks if a file matches any rule's files_to_sync +- **`shouldSyncOrgUpdate(orgName, filePath, manifest, logger)`** - Combined check for org + file filtering +- **`filterOrgsByManifest(orgNames, manifest, logger)`** - Filters a list of organizations by manifest rules + +### 2. Integration with Sync Operations + +#### `syncHubOrgUpdate()` +- Loads manifest on every PR-triggered sync +- Checks if organization is allowed by manifest rules +- Filters changed files based on manifest patterns +- Skips sync entirely if org or all files are excluded +- Logs filtering decisions with debug information + +#### `syncHubGlobalsUpdate()` +- Loads manifest from PR head SHA +- Filters changed files by manifest rules (skips manifest.yml itself) +- Filters target organizations before syncing +- Special handling for settings.yml merge logic with filtering +- Logs manifest-filtered org counts + +### 3. Enhanced PR Validation + +Updated `validateAndReportHubSync()`: +- Loads manifest and applies filtering logic +- Shows manifest rule count in PR comments +- Displays which orgs will receive updates (filtered list) +- Displays which orgs are excluded by manifest +- Displays which files are excluded by manifest +- Adds warnings for excluded orgs/files +- Maintains backward compatibility when no manifest exists + +### 4. New Manifest Schema + +Updated `.github/safe-settings/globals/manifest.yml`: + +**New Format:** +```yaml +rules: + - name: production-orgs-only + org_targets: + include: + - "prod-*" + - "enterprise-*" + exclude: + - "prod-test-*" + files_to_sync: + include: + - "settings.yml" + - "repos/*.yml" + exclude: + - "repos/experimental-*.yml" + mergeStrategy: merge + enabled: true +``` + +**Backward Compatible (Old Format Still Works):** +```yaml +rules: + - name: global-defaults + targets: + - "*" + files: + - "*.yml" + mergeStrategy: merge +``` + +## Pattern Matching + +Uses `minimatch` library for glob pattern matching: +- `*` - matches any characters except / +- `**` - matches any characters including / +- `?` - matches single character +- Examples: + - `test-*` matches `test-org1`, `test-org2` + - `*.yml` matches any YAML file + - `repos/*.yml` matches YAML files in repos/ directory + - `**/settings.yml` matches settings.yml at any depth + +## Behavior Rules + +1. **Organization Filtering:** + - Org is synced if ANY rule includes it + - Exclude patterns override include patterns within the same rule + - If no rule matches an org, it's excluded from sync + +2. **File Filtering:** + - File is synced if ANY rule includes it + - Exclude patterns override include patterns within the same rule + - If no rule matches a file, it's excluded from sync + - `manifest.yml` is automatically excluded (skipped in code) + +3. **Backward Compatibility:** + - Old `targets` array → converted to `org_targets.include` + - Old `files` array → converted to `files_to_sync.include` + - If no manifest exists, all orgs/files are allowed (default behavior) + +4. **Caching:** + - Manifest is cached for 1 minute to improve performance + - Cache invalidates on each manifest load after TTL expires + +## Example PR Comment Output + +```markdown +#### 🤖 Hub-Sync Master File Validation + +**Files changed:** 3 +**Manifest rules:** 2 active rule(s) +**Organizations with changes:** prod-org1, test-org2, dev-org3 +**✅ Organizations that will receive updates:** prod-org1, test-org2 +**⛔ Organizations excluded by manifest:** dev-org3 +**⛔ Files excluded by manifest:** experimental-config.yml + +#### ✅ YAML Syntax Validation Passed (3/3) +- `.github/safe-settings/globals/settings.yml` ✓ +- `.github/safe-settings/organizations/prod-org1/settings.yml` ✓ +- `.github/safe-settings/organizations/test-org2/repos/repo-x.yml` ✓ + +#### ✅ Merged Configuration Validation Passed (2/2) +- Organization `prod-org1` (settings.yml) ✓ +- Organization `test-org2` (settings.yml) ✓ + +#### ⚠️ Warnings (2) +- 1 organization(s) excluded by manifest rules: dev-org3 +- 1 file(s) excluded by manifest rules: experimental-config.yml +``` + +## Testing Recommendations + +1. **Test backward compatibility:** + - Keep existing manifest.yml with old format + - Verify sync still works as expected + +2. **Test new include/exclude patterns:** + - Create rules with wildcards (`prod-*`, `test-*`) + - Verify orgs are correctly filtered + - Check PR comments show correct filtering + +3. **Test file filtering:** + - Add file patterns to `files_to_sync` + - Verify only matching files are synced + - Check excluded files are listed in PR warnings + +4. **Test no manifest scenario:** + - Temporarily remove manifest.yml + - Verify all orgs/files are synced (default behavior) + - Check PR comment shows "No manifest found" + +## Migration Guide + +### For Existing Users (Old Format) + +No action required! Your existing manifest.yml will continue to work. The old format is automatically converted: + +```yaml +# Old format - Still works! +rules: + - name: my-rule + targets: ["*"] + files: ["*.yml"] +``` + +### For New Users (Recommended Format) + +Use the new include/exclude format for better control: + +```yaml +rules: + - name: my-rule + org_targets: + include: ["*"] + exclude: ["test-*"] + files_to_sync: + include: ["*.yml"] + exclude: ["*.bak"] +``` + +### Gradual Migration + +You can mix old and new formats in the same manifest: + +```yaml +rules: + - name: old-style-rule + targets: ["legacy-*"] + files: ["*.yml"] + + - name: new-style-rule + org_targets: + include: ["prod-*"] + exclude: ["prod-test-*"] + files_to_sync: + include: ["settings.yml"] +``` + +## Files Modified + +1. **`/Users/jefeish/projects/safe-settings/lib/hubSyncHandler.js`** + - Added 7 new manifest filtering functions (~200 lines) + - Updated `syncHubOrgUpdate()` to apply manifest filtering + - Updated `syncHubGlobalsUpdate()` to use new manifest loading + - Enhanced `validateAndReportHubSync()` with manifest filtering display + - Exported new functions for testability + +2. **`/Users/jefeish/projects/safe-settings-config-master/.github/safe-settings/globals/manifest.yml`** + - Added comprehensive examples of new include/exclude format + - Added pattern matching documentation + - Added backward compatibility notes + - Kept old format examples for reference + +3. **`/Users/jefeish/projects/safe-settings/docs/hubSyncHandler/ADR-manifest-control.md`** + - Updated status to "✅ Implemented" + - Added implementation date + +4. **`/Users/jefeish/projects/safe-settings/docs/hubSyncHandler/IMPLEMENTATION-SUMMARY.md`** + - Created comprehensive implementation documentation (this file) + +## Next Steps + +1. **Testing:** + - Test with real PRs modifying globals/ and organizations/ files + - Verify manifest filtering appears correctly in PR comments + - Test pattern matching with various glob patterns + +2. **Documentation:** + - Update main README.md with manifest filtering examples + - Add user guide for manifest configuration + - Create video/tutorial demonstrating the feature + +3. **Future Enhancements:** + - Add manifest validation in PR checks (syntax + pattern validation) + - Add dry-run mode to preview which orgs/files would be affected + - Add metrics/telemetry for manifest usage patterns + - Consider adding regex pattern support alongside glob patterns + +## Dependencies + +- **`minimatch`** (v10.0.3) - Already in package.json, used for glob pattern matching +- **`js-yaml`** (v4.1.0) - Already in package.json, used for YAML parsing + +## Performance Considerations + +1. **Manifest Caching:** 1-minute TTL reduces repeated file reads +2. **Pattern Matching:** minimatch is fast for glob patterns +3. **Early Exit:** Sync operations exit early if org/files are filtered out +4. **Logging:** Debug logging can be disabled in production for performance + +## Security Considerations + +1. **manifest.yml is never synced** - Hardcoded exclusion in syncHubGlobalsUpdate +2. **Patterns are sandboxed** - minimatch doesn't allow path traversal +3. **No arbitrary code execution** - Only YAML parsing and pattern matching +4. **Explicit opt-in** - Orgs must match at least one rule to receive updates + +## Known Limitations + +1. **Cache invalidation:** Manifest cache is time-based (1 minute), not content-based +2. **Single manifest file:** Only supports one manifest.yml per hub repo +3. **No conditional rules:** Rules cannot be conditional based on file content or PR metadata +4. **No inheritance:** Rules don't cascade or inherit from each other + +## Conclusion + +✅ Successfully implemented manifest-based hub-sync control with include/exclude rules +✅ Backward compatible with existing manifest.yml files +✅ Enhanced PR validation shows filtered orgs and files +✅ Comprehensive documentation and examples provided +✅ No breaking changes to existing functionality + +The implementation is ready for production use! diff --git a/docs/hubSyncHandler/README.md b/docs/hubSyncHandler/README.md new file mode 100644 index 000000000..bdec791d0 --- /dev/null +++ b/docs/hubSyncHandler/README.md @@ -0,0 +1,330 @@ +# Safe Settings Organization Sync & Dashboard + + This feature provides a centralized approach to managing the Safe-Settings Admin Repo, allowing Safe-Settings configurations to be sync'd across multiple ORGs. + +## Overview + +This adds the **hub‑and‑spoke synchronization capability** to Safe Settings. + +One central **master admin repository** (the hub) serves as the authoritative source of configuration ('Master' Admin Config Repo) which is automatically propagated to each organization’s **admin repository** (the spokes). + +**Note:** When something changes in the 'Master' repo (the hub), only those changed files are copied to each affected ORG’s admin repo, so everything stays in sync. + +#### :warning: In order for `Safe-Settings Hub-Sync` to enforce Policy sync in the controlled ORGs/Repos, **Safe-Settings** needs to be given **Bypass authority** for any branch protection Ruleset that prevents 'direct pushes'. + +## Sync Lifecycle (High Level) + +```mermaid +graph TD +A0(PR Closed Event) --> A1(HUB Admin Repo) +A1(HUB Admin Repo) --> B(ORG Admin Repo) +A1(HUB Admin Repo) --> C(ORG Admin Repo) +A1(HUB Admin Repo) --> D(ORG Admin Repo) +``` + +## Initial Setup + +Before getting started with the hub-sync configuration, you'll need to deploy and build the Safe Settings App, including the UI. + +### Deploy the Application + +To deploy the Safe Settings application to a hosting environment, choose one of the following deployment options: + +#### Option 1: Virtual Machine (VM) +Deploy Safe Settings on a VM (AWS EC2, Azure VM, Google Compute Engine, etc.): + +1. Set up a VM with Node.js installed +2. Clone the Safe Settings repository to the VM +3. Copy your built UI files to the VM +4. Configure environment variables (see [Environment Variables](#environment-variables--inputs-specific-to-the-hub-sync-feature)) +5. Start the application: + ```bash + npm start + ``` + + :warning: Follow the standard GitHub Probot App [deployment steps](https://probot.github.io/docs/deployment/) + +#### Option 2: Container Deployment +Deploy using Docker: + +1. Use the provided `Dockerfile` or `docker-compose.yml` +2. Build and run the container: + ```bash + docker-compose up -d + ``` + +#### Option 3: Serverless Deployment +Deploy to AWS Lambda or similar serverless platforms using the provided `serverless.yml` configuration. See [AWS deployment documentation](../AWS-README.md) for details. + +#### Option 4: Kubernetes +Deploy to a Kubernetes cluster using the provided Helm charts in the `helm/safe-settings/` directory. + +**Note:** Ensure your deployment environment has network access to GitHub APIs and can receive webhook events from GitHub. + + +### Build the UI + +The Safe Settings dashboard includes a Next.js-based UI that must be compiled before deployment. + +1. Navigate to the UI directory: + ```bash + cd safe-settings/ui/ + ``` + +2. Install dependencies (if not already done): + ```bash + npm install + ``` + +3. Build the Next.js application: + ```bash + npm run build + ``` + +This creates an optimized production build of the dashboard UI that will be served by the Safe Settings application. + +--- + +## Gettings Started + +>**Note:** for the standard setup lets assume that Safe-Settings configuration on the Admin Config Repos (Spokes) are stored in `.github/` + +These are the basic steps to setup the Enterprise-Level Safe-Settings, using **Hub-sync** support. + +### ✅ Step 1: Register the App +**Register the Safe-Settings App** in your Enterprise (Enterprise App) or in your Organization. + +For App "installation tragets" (Where can this GitHub App be installed?) +Choose ***Any account*** + +### ✅ Step 2: Install the App +**Install the Safe-Settings App** in any Organzation that you would like Safe-Settings to manage. + +### ✅ Step 3: Create the 'Org-Level' Safe-Settings Admin Config Repo (Spokes) +Create the Org-Level Repo that is your dedicated Safe-Settings Config Repo and will hold all Safe-Settings configurations for the Org. + +### ✅ Step 4: Create the 'Master' Safe-Settings Admin Config Repo (Hub) +Choose any Organization where the Safe-Settings App is installed and create a 'Master' Safe-Settings Admin Config Repo. + +The Repository requires a standard directory structure for storing the config data: +```bash +.github/ +└─ safe-settings/ + ├── globals/ + │ └── manifest.yml + └── organizations/ + ├── org1/ + │ └── ...yml + └── org2/ + └── ...yml +``` + +Notes: +- The `manifest.yml` is a required file, that defines rules for syncing **Global** Safe-Settings configurations. We will address the content format later. +- `org1` and `org2` are just examples and should be replaced with the real names of the Orgs that you want to manage with the **Hub-Sync**. + +### ✅ Step 5: Configure the 'Master' Safe-Settings Admin Config Repo (Hub) + +The **Hub-Sync** feature supports two options +1. **Organization Sync:** +Any settings file in the `organizations/` directory will be synced to the specific `` (Spoke) Admin config Repo subfolder (eg.: /.github/). Only updated files are sync'd to the ORG admin config Repo (spokes). +1. **Global Sync:** Any settings file in the `globals/` directory will be synced to the specific `` (Spoke) Admin config Repo subfolder (eg.: /.github/). + + :warning: The actual sync operation is based on the rules defined in the `globals/manifest.yml`. The rules provide fine grained control over the sync targets and sync strategy. + +These two options only require that you provide the files you would like to sync, in the correct sub-directory. + +#### ✅ Step 5.1: Configure the `manifest.yml` in the 'Master' Safe-Settings Admin Config Repo (Hub) + +The `manifest.yml` defines the sync rules for global settings distribution. +- Sample `manifest.yml` + + ``` + rules: + - name: global-defaults + # specify the target ORG(s) + targets: + - "*" + files: + - "*.yml" + + # mergeStrategy: merge | overwrite | preserve + # -------------------------------------------- + # merge = use a PR to sync files + # overwrite = sync all files to the target ORG(s) (no PR) + mergeStrategy: merge + + - name: security-policies + # specify the target ORG(s) + targets: + - "acme-*" + - "foo-bar" + files: + - settings.yml + mergeStrategy: overwrite + + # optional toggle, default true + # enabled: false + ``` + +### Example Rule Breakdown + +```yaml +- name: global-defaults + targets: + - "*" + files: + - "*.yml" + mergeStrategy: merge +``` +- **Purpose:** Sync all YAML files to all organizations, merging changes via PR. + +```yaml +- name: security-policies + targets: + - "acme-*" + - "foo-bar" + files: + - settings.yml + mergeStrategy: overwrite + enabled: false +``` + +- **Purpose:** Overwrite `settings.yml` in specific organizations, but currently disabled. + + +### `manifest.yml` Reference + +The `manifest.yml` file defines synchronization rules for Safe-Settings hub-and-spoke configuration management. Each rule specifies which organizations and files to target, and how to handle synchronization. + +### Top-Level Structure + +```yaml +rules: + - name: + targets: [, ...] + files: [, ...] + mergeStrategy: + enabled: # optional + # ...additional fields as needed +``` + +--- + +### Elements + +#### `rules` +- **Type:** Array of objects +- **Description:** List of synchronization rules. Each rule controls how specific files are synced to target organizations. + +#### Rule Object + +##### `name` +- **Type:** String +- **Description:** Unique identifier for the rule. Used for reference and logging. +- **Example:** `global-defaults`, `security-policies` + +##### `targets` +- **Type:** Array of strings +- **Description:** List of organization names or patterns to apply the rule to. + - `"*"`: All organizations + - `"acme-*"`: Organizations with names starting with `acme-` + - `"foo-bar"`: Specific organization +- **Example:** + ```yaml + targets: + - "*" + - "acme-*" + - "foo-bar" + ``` + +##### `files` +- **Type:** Array of strings +- **Description:** File patterns to sync. Supports wildcards. + - `"*.yml"`: All YAML files + - `"settings.yml"`: Specific file +- **Example:** + ```yaml + files: + - "*.yml" + - "settings.yml" + ``` + +##### `mergeStrategy` +- **Type:** String (`merge`, `overwrite`, `preserve`) +- **Description:** Determines how files are synced: + - `merge`: use a PR to sync files + - `overwrite`: Sync all files, replacing existing ones (direct commit, no PR) +- **Example:** + ```yaml + mergeStrategy: merge + ``` + +##### `enabled` +- **Type:** Boolean (optional) +- **Description:** Toggle to enable or disable the rule. Default is `true`. +- **Example:** + ```yaml + enabled: false + ``` + +--- + +### Environment Variables & Inputs Specific to the **Hub-Sync** feature + +| Name | Purpose | Default | +|------|---------|---------| +| `SAFE_SETTINGS_HUB_REPO` | Repo for master safe-settings contents | admin-master | +| `SAFE_SETTINGS_HUB_ORG` | Organization that hold the Repo | admin-master-org | +| `SAFE_SETTINGS_HUB_PATH` | source folder | .github/safe-settings | +| `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Use a PR or direct commit | false | + + +--- +--- + +## Hub Sync Scenarios + +1. Sync the `Hub Admin Repo` changes to a `Safe-Settings Admin Repo` in **the same ORG** as the Hub Admin Repo. + +2. Sync the `Hub Admin changes` to a `Safe-Settings Admin Repo` in **a different ORG**. + +3. _`'Global'`_ `Hub Admin Repo` updates. +Changes will `applied to all Organization` + +--- + +## Safe-Settings Hub API endpoints + +### API Endpoints + +The following table summarizes the Safe Settings API endpoints: + +| Endpoint | Method | Purpose | Example Usage | +|------------------------------------------|--------|------------------------------------------------------|---------------| +| `/api/safe-settings/installation` | GET | Organization installation, repo, and sync status | Fetch org status | +| `/api/safe-settings/hub/content` | GET | List hub repo files/directories | List hub files | +| `/api/safe-settings/hub/content/*` | GET | Fetch specific file or directory from hub repo | Get file content | +| `/api/safe-settings/hub/import` | POST | Import settings from orgs into the hub | Import org settings | +| `/api/safe-settings/app/env` | GET | App environment/config variables | Get env vars | + +**Examples:** +- Fetch org installation status: + ```http + GET /api/safe-settings/installation + ``` +- Import settings from orgs: + ```http + POST /api/safe-settings/hub/import + Body: { "orgs": ["org1", "org2"] } + ``` +- List hub repo files: + ```http + GET /api/safe-settings/hub/content?ref=main&recursive=true + ``` +- Get environment variables: + ```http + GET /api/safe-settings/env + ``` + +--- \ No newline at end of file diff --git a/docs/hubSyncHandler/architecture-sequence.md b/docs/hubSyncHandler/architecture-sequence.md new file mode 100644 index 000000000..1fc39f447 --- /dev/null +++ b/docs/hubSyncHandler/architecture-sequence.md @@ -0,0 +1,222 @@ +# HubSyncHandler Architecture - Sequence Diagram + +This document provides a detailed sequence diagram showing the flow of the Hub Sync Handler, which synchronizes safe-settings configurations between a centralized hub repository and organization-specific admin repositories. + +## Main Flow + +```mermaid +sequenceDiagram + participant GH as GitHub Event + participant Index as index.js + participant HSH as hubSyncHandler + participant SGU as syncHubGlobalsUpdate + participant SOU as syncHubOrgUpdate + participant HubRepo as Hub Repository + participant OrgRepo as Org Admin Repo + + GH->>Index: pull_request.closed event + Index->>HSH: hubSyncHandler(robot, context) + + HSH->>HSH: Validate event source + Note over HSH: Check if from SAFE_SETTINGS_HUB_ORG/SAFE_SETTINGS_HUB_REPO + + alt Not from hub repo/org + HSH->>Index: Return (ignore event) + end + + HSH->>GH: Get PR changed files + GH-->>HSH: List of changed files + + HSH->>HSH: Analyze changed files + + par Parallel Processing + alt Files in globals/ folder + HSH->>SGU: syncHubGlobalsUpdate(robot, context, files) + SGU->>HubRepo: Load manifest.yml + HubRepo-->>SGU: Manifest rules + + loop For each changed global file + SGU->>SGU: Match file against manifest patterns + + loop For each matching rule + SGU->>SGU: Determine target orgs + Note over SGU: Use rule.orgs or all installations + + loop For each target org + SGU->>OrgRepo: Get installation + + alt Direct Push Mode + SGU->>OrgRepo: Push to main branch + else PR Mode + SGU->>OrgRepo: Create branch + SGU->>HubRepo: Get file content + SGU->>OrgRepo: Commit file + SGU->>OrgRepo: Create PR + end + end + end + end + end + + alt Files in organizations// folder + HSH->>SOU: syncHubOrgUpdate(robot, context, orgName, destRepo, destFolder) + + SOU->>SOU: Extract org name from file path + SOU->>OrgRepo: Get org installation + + alt Installation not found + SOU->>HSH: Log warning and return + end + + SOU->>HubRepo: Get changed files for org + HubRepo-->>SOU: List of files + + alt Direct Push Mode + SOU->>OrgRepo: Use main branch + else PR Mode + SOU->>OrgRepo: Create sync branch + Note over SOU: safe-settings-sync/pr-{num}-{org}-{timestamp} + end + + loop For each changed file + SOU->>HubRepo: Get file content from PR head + HubRepo-->>SOU: File content + + SOU->>OrgRepo: Check if file exists + OrgRepo-->>SOU: Existing SHA (or 404) + + SOU->>OrgRepo: Create or update file + Note over SOU: Commit with Safe Settings Bot identity + end + + alt Direct Push Mode + SOU->>HSH: Log direct push complete + else PR Mode + SOU->>OrgRepo: Create PR + Note over SOU: Title: Sync safe-settings from hub PR #X + OrgRepo-->>SOU: PR URL + end + end + end + + HSH->>Index: Complete +``` + +## Reverse Flow: Import Settings from Orgs to Hub + +```mermaid +sequenceDiagram + participant API as API/Route Handler + participant RSO as retrieveSettingsFromOrgs + participant OrgRepo as Org Admin Repo + participant HubRepo as Hub Repository + + API->>RSO: retrieveSettingsFromOrgs(robot, orgNames, options) + + RSO->>RSO: Get all installations + RSO->>HubRepo: Get base branch ref (main) + HubRepo-->>RSO: Base SHA + + loop For each org in orgNames + RSO->>HubRepo: Check if org already exists + Note over RSO: Path: CONFIG_PATH/SAFE_SETTINGS_HUB_PATH/organizations/ + + alt Org already imported + RSO->>RSO: Skip org (already_imported) + else Org not found in hub + RSO->>RSO: Get org installation + + alt Installation not found + RSO->>RSO: Record error and continue + end + + RSO->>OrgRepo: Collect all files recursively + Note over RSO: From CONFIG_PATH in ADMIN_REPO + + alt Admin repo not found + RSO->>RSO: Record N/A status + else Files collected + OrgRepo-->>RSO: List of files with content + + RSO->>HubRepo: Create import branch + Note over RSO: safe-settings-import// + + loop For each file + RSO->>HubRepo: Get existing file SHA (if exists) + RSO->>HubRepo: Create or update file + Note over RSO: Destination: organizations// + end + + RSO->>HubRepo: Create PR + Note over RSO: Title: Import safe-settings from + HubRepo-->>RSO: PR URL + + RSO->>RSO: Record import success + end + end + end + + RSO-->>API: Return results array + Note over RSO: [{org, status, reason|error}, ...] +``` + +## Key Decision Points + +### Event Validation +```mermaid +flowchart TD + A[PR Closed Event] --> B{From Hub Org?} + B -->|No| C[Ignore Event] + B -->|Yes| D{From Hub Repo?} + D -->|No| C + D -->|Yes| E[Process Event] +``` + +### File Routing Logic +```mermaid +flowchart TD + A[Get Changed Files] --> B{Check File Paths} + B --> C{Contains /globals/?} + C -->|Yes| D[syncHubGlobalsUpdate] + B --> E{Contains /organizations/?} + E -->|Yes| F[Extract Org Names] + F --> G[syncHubOrgUpdate for each org] + C -->|No| H[No Action] + E -->|No| H +``` + +### Sync Mode Decision +```mermaid +flowchart TD + A[Ready to Sync] --> B{SAFE_SETTINGS_HUB_DIRECT_PUSH?} + B -->|true| C[Push directly to main] + B -->|false| D[Create sync branch] + D --> E[Commit changes] + E --> F[Create PR] +``` + +## Environment Variables + +| Variable | Purpose | Used In | +|----------|---------|---------| +| `SAFE_SETTINGS_HUB_ORG` | Hub organization name | Event validation | +| `SAFE_SETTINGS_HUB_REPO` | Hub repository name | Event validation | +| `SAFE_SETTINGS_HUB_PATH` | Base path in hub (e.g., "safe-settings") | File path resolution | +| `ADMIN_REPO` | Target repo name in orgs | Destination repo | +| `CONFIG_PATH` | Config folder (e.g., ".github") | File path resolution | +| `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Push mode ("true"/"false") | Branch vs direct push | + +## Error Handling + +All functions implement try-catch blocks with logging: +- **Installation errors**: Log warning and skip org +- **File read errors**: Log error and continue with next file +- **PR creation errors**: Log error and throw (stops sync for that org) +- **Repository not found**: Record as "N/A" status and continue + +## Notes + +- **Parallel Processing**: `syncHubGlobalsUpdate` and `syncHubOrgUpdate` can run in parallel if both globals and organizations folders have changes +- **Authentication**: Each org requires a separate authenticated octokit client via `robot.auth(installationId)` +- **File Logging**: All operations are logged to `hubSyncHandler.log` (configurable) +- **Idempotency**: Functions check for existing branches/PRs before creating new ones diff --git a/docs/hubSyncHandler/manifest-filtering-guide.md b/docs/hubSyncHandler/manifest-filtering-guide.md new file mode 100644 index 000000000..3cc1ae926 --- /dev/null +++ b/docs/hubSyncHandler/manifest-filtering-guide.md @@ -0,0 +1,1556 @@ +# Manifest Filtering Guide: Controlling Hub-Sync Distribution + +**Purpose**: This guide explains how to use `manifest.yml` to control which organizations receive which files during hub-sync operations. + +**Last Updated**: 2026-07-08 + +--- + +## ⚠️ Before You Begin: Critical Concepts + +**Read this first to avoid configuration conflicts:** + +1. **Only `settings.yml` merges** - It's the ONLY file where globals and organizations versions are merged at YAML level +2. **All other files REPLACE** - Files like `suborgs/backend.yml` exsiting in organizations/ and globals/ will OVERWRITE each other, depending on last update +3. **Choose one source** - For files with the same name, except settings.yml, choose one location: globals/ (shared) OR organizations/ (custom), not both +4. **Manifest controls distribution** - Only orgs listed in manifest `include` rules are sync'd via HubSync + +**Quick Decision Tree:** +- Want merged behavior? → Use `settings.yml` only +- Want shared config across orgs? → Use `globals/` + manifest includes +- Want org-specific config? → Use `organizations//` + different filename than `globals/` +- Want customizations that also receive updates? → Not supported (except `settings.yml`) + +--- + +## Overview + +The manifest.yml file provides **two levels of filtering** for hub-sync: + +1. **Globals/ Distribution** - Control which orgs receive which files from `globals/` +2. **Organizations/ Sync Control** - Control which `organizations//` folders sync (ignore/skip specific orgs or files) + +Both mechanisms use the same manifest.yml rules with `org_targets` and `files_to_sync` patterns. + +### Key Concepts + +**Globals/ Distribution (One-to-Many)**: +- Files in `globals/` (including `globals/suborgs/`) are **distributed** to multiple organizations +- One file → many orgs (based on manifest rules) +- Example: `globals/suborgs/backend.yml` can sync to org-1, org-2, org-3 +- Use Case: Shared configurations, team templates, common suborg definitions + +**Organizations/ Sync (One-to-One)**: +- Files in `organizations//` sync only to that **specific** organization +- One folder → one org (manifest can exclude certain orgs or files) +- Example: `organizations/org-1/suborgs/custom.yml` only syncs to org-1 +- Use Case: Org-specific overrides, unique suborg configurations + +--- + +## Rule Precedence and Conflict Resolution + +### How Rules Are Evaluated + +Manifest rules use **OR logic** across multiple rules with **first-match-wins** behavior: + +1. **Rules are evaluated in ORDER** - Top-to-bottom in the YAML array +2. **ANY rule can include** - If ANY enabled rule includes an org/file, it syncs +3. **Within each rule**: `exclude` is checked FIRST, then `include` +4. **First match wins** - As soon as a rule includes something, evaluation stops (returns TRUE) +5. **If excluded by one rule** - Continues checking the next rule +6. **No match = excluded** - If no rule includes it, it's excluded by default + +### Rule Precedence Matrix + +| Scenario | Rule 1 | Rule 2 | Result | Reason | +|----------|--------|--------|--------|--------| +| Both include | ✅ Include | ✅ Include | ✅ Synced | Rule 1 matches first, returns TRUE | +| First excludes, second includes | ❌ Exclude | ✅ Include | ✅ Synced | Rule 1 skips, Rule 2 includes | +| First includes, second excludes | ✅ Include | ❌ Exclude | ✅ Synced | Rule 1 matches first, Rule 2 never checked | +| Both exclude | ❌ Exclude | ❌ Exclude | ❌ Skipped | No rule includes it | + +### Example: Conflicting Rules + +```yaml +rules: + # Rule 1: Specific file to specific org + - name: test-file-to-test-org + enabled: true + org_targets: + include: ["ORG-TEST"] + files_to_sync: + include: ["suborgs/test.yml"] + + # Rule 2: All files to all orgs + - name: all-files-to-all-orgs + enabled: true + org_targets: + include: ["*"] + files_to_sync: + include: ["*.yml", "suborgs/*.yml"] +``` + +**Question**: Does `suborgs/test.yml` sync to `ORG-TEST`? + +**Answer**: ✅ **YES** - Rule 1 matches first and includes it. Rule 2 would also match, but Rule 1 already returned TRUE. + +**Question**: Does `suborgs/test.yml` sync to `ORG-PROD`? + +**Answer**: ✅ **YES** - Rule 1 doesn't match ORG-PROD (continues), Rule 2 matches ORG-PROD and the file pattern. + +**Question**: Does `suborgs/other.yml` sync to `ORG-TEST`? + +**Answer**: ✅ **YES** - Rule 1 doesn't match file pattern (continues), Rule 2 matches both org and file. + +### Example: Exclude Override with Multiple Rules + +```yaml +rules: + # Rule 1: Exclude test file from test org + - name: block-test-file + enabled: true + org_targets: + include: ["ORG-TEST"] + files_to_sync: + include: ["*.yml"] + exclude: ["suborgs/test.yml"] # Explicitly exclude + + # Rule 2: All files to all orgs + - name: all-files-all-orgs + enabled: true + org_targets: + include: ["*"] + files_to_sync: + include: ["suborgs/*.yml"] +``` + +**Question**: Does `suborgs/test.yml` sync to `ORG-TEST`? + +**Answer**: ✅ **YES** (Surprising!) - Here's why: +1. Rule 1 matches ORG-TEST +2. Rule 1 checks exclude first → matches `suborgs/test.yml` → EXCLUDED by this rule +3. Rule 1 **continues** to Rule 2 (doesn't return FALSE, just moves to next rule) +4. Rule 2 matches ORG-TEST (wildcard `*`) +5. Rule 2 includes `suborgs/*.yml` → INCLUDED +6. Returns TRUE - **file syncs** + +**Key Insight**: An exclude in one rule doesn't prevent another rule from including it. Rules use OR logic. + +### Example: True Exclusion Pattern + +If you want to **truly prevent** a file from syncing to an org, you need to **not have ANY rule that includes it**: + +```yaml +rules: + # Rule 1: Most orgs get all files + - name: standard-orgs + enabled: true + org_targets: + include: ["*"] + exclude: ["ORG-TEST"] # Exclude ORG-TEST from this rule + files_to_sync: + include: ["suborgs/*.yml"] + + # Rule 2: ORG-TEST gets selective files only + - name: test-org-limited + enabled: true + org_targets: + include: ["ORG-TEST"] + files_to_sync: + include: ["suborgs/allowed.yml"] # Only specific files + exclude: ["suborgs/test.yml"] # Not included here +``` + +**Result**: `suborgs/test.yml` does NOT sync to ORG-TEST because: +- Rule 1 excludes ORG-TEST entirely +- Rule 2 only includes `suborgs/allowed.yml`, not `suborgs/test.yml` +- No rule includes the file for ORG-TEST → excluded + +### Within-Rule Precedence + +Within a **single rule**, exclude always wins: + +```yaml +rules: + - name: confusing-rule + org_targets: + include: ["*"] + files_to_sync: + include: ["suborgs/*.yml"] + exclude: ["suborgs/test.yml"] +``` + +**Result**: All `suborgs/*.yml` files sync **EXCEPT** `suborgs/test.yml` (exclude wins within this rule). + +### Best Practice: Order Rules from Specific to General + +```yaml +rules: + # Rule 1: Specific exceptions first + - name: test-org-exceptions + enabled: true + org_targets: + include: ["ORG-TEST"] + files_to_sync: + include: ["settings.yml"] # ONLY settings.yml + + # Rule 2: General rules second + - name: all-other-orgs + enabled: true + org_targets: + include: ["*"] + exclude: ["ORG-TEST"] # Already handled above + files_to_sync: + include: ["*.yml"] # All files +``` + +**Advantage**: +- Specific rules are evaluated first +- General rules don't conflict because specific orgs are excluded +- Clear intent and easier to debug + +### Rule Order Matters: Example + +```yaml +# Scenario A: General rule FIRST +rules: + - name: all-files-all-orgs + org_targets: + include: ["*"] + files_to_sync: + include: ["*.yml"] + + - name: test-org-limited # Never evaluated for files! + org_targets: + include: ["ORG-TEST"] + files_to_sync: + include: ["settings.yml"] + exclude: ["suborgs/*.yml"] +``` + +**Result**: ORG-TEST gets ALL files because Rule 1 matches first and returns TRUE. Rule 2 never gets a chance to exclude `suborgs/*.yml`. + +```yaml +# Scenario B: Specific rule FIRST (Correct) +rules: + - name: test-org-limited + org_targets: + include: ["ORG-TEST"] + files_to_sync: + include: ["settings.yml"] # Only this + + - name: all-other-orgs + org_targets: + include: ["*"] + exclude: ["ORG-TEST"] + files_to_sync: + include: ["*.yml"] +``` + +**Result**: ORG-TEST gets ONLY `settings.yml` (Rule 1 matches first), other orgs get all files (Rule 2). + +--- + +## ⚠️ CRITICAL: File Merge vs. Replace Behavior + +Understanding this distinction is **essential** for proper configuration management. + +### `settings.yml` - MERGE Behavior (Special Case) + +`settings.yml` is the **ONLY** file that supports deep YAML merge between globals and organizations. + +**How it works**: +1. **When `organizations/org-1/settings.yml` changes**: + - Hub-sync merges `globals/settings.yml` + `organizations/org-1/settings.yml` + - Deploys merged result ONLY to `org-1` (if manifest allows) + +2. **When `globals/settings.yml` changes**: + - Hub-sync merges `globals/settings.yml` with EACH org's `organizations//settings.yml` + - Deploys to ALL orgs listed in manifest include rules + - Does NOT deploy to `org-3` if not in manifest + +**Example**: +```yaml +# globals/settings.yml +repository: + has_issues: true + has_wiki: false + +# organizations/org-1/settings.yml +repository: + has_projects: true + delete_branch_on_merge: true + +# Merged result deployed to org-1: +repository: + has_issues: true # From globals + has_wiki: false # From globals + has_projects: true # From org-1 + delete_branch_on_merge: true # From org-1 +``` + +### All Other Files - REPLACE Behavior + +**Every file except `settings.yml` uses REPLACE (overwrite) behavior.** + +**How it works**: +- If `globals/suborgs/backend.yml` exists AND `organizations/org-1/suborgs/backend.yml` exists +- When `globals/suborgs/backend.yml` changes: + - Hub-sync deploys globals version to org-1 (if manifest allows) + - **COMPLETELY OVERWRITES** `organizations/org-1/suborgs/backend.yml` + - Org-specific customizations are LOST + +**This creates a conflict scenario**: + +| Scenario | Files Present | What Happens | Problem | +|----------|---------------|--------------|---------| +| Globals change | `globals/suborgs/backend.yml` + `organizations/org-1/suborgs/backend.yml` | Globals REPLACES org version | Org customizations lost | +| Exclude org from manifest | Same | Globals deployment skipped | Org never gets globals updates | + +**You cannot have both** without choosing one or the other. + +--- + +## Configuration Patterns + +### ✅ Correct Pattern 1: Shared Configuration + +Use `globals/` for configurations that should be **identical** across organizations. + +``` +.github/safe-settings/ +├── globals/ +│ └── suborgs/ +│ └── backend.yml # Shared backend suborg +└── organizations/ + ├── org-1/ + │ └── settings.yml # Only settings.yml can exist in both + └── org-2/ + └── settings.yml +``` + +**Manifest**: +```yaml +rules: + - name: all-orgs-shared-suborgs + org_targets: + include: ["org-1", "org-2"] + files_to_sync: + include: ["suborgs/*.yml"] +``` + +**Result**: All orgs get identical `backend.yml` from globals. + +--- + +### ✅ Correct Pattern 2: Org-Specific Configuration + +Use `organizations//` for configurations that are **unique per org**. + +``` +.github/safe-settings/ +├── globals/ +│ └── settings.yml # Only settings.yml in globals +└── organizations/ + ├── org-1/ + │ ├── settings.yml + │ └── suborgs/ + │ └── custom-backend.yml # Unique to org-1 + └── org-2/ + ├── settings.yml + └── suborgs/ + └── custom-frontend.yml # Unique to org-2 +``` + +**Manifest**: +```yaml +rules: + - name: org-1-custom + org_targets: + include: ["org-1"] + files_to_sync: + include: ["suborgs/*.yml"] + + - name: org-2-custom + org_targets: + include: ["org-2"] + files_to_sync: + include: ["suborgs/*.yml"] +``` + +**Result**: Each org gets only its own custom suborg configs. + +--- + +### ❌ Anti-Pattern: Duplicate File Paths + +**AVOID** having the same file path in both `globals/` and `organizations//`. + +``` +.github/safe-settings/ +├── globals/ +│ └── suborgs/ +│ └── backend.yml # ⚠️ Conflict! +└── organizations/ + └── org-1/ + └── suborgs/ + └── backend.yml # ⚠️ Will be overwritten, by lastest update +``` + +**Why this fails**: +1. If manifest includes org-1: Globals overwrites org-1's custom version +2. If manifest excludes org-1: Org-1 never gets globals updates +3. No way to maintain org-specific customizations + +**Exception**: Only `settings.yml` can safely exist in both locations due to merge behavior. + +--- + +### ✅ Correct Pattern 3: Selective Distribution with Naming + +Use **different filenames** for shared vs. org-specific configs. + +``` +.github/safe-settings/ +├── globals/ +│ └── suborgs/ +│ ├── shared-backend.yml # Shared template +│ └── shared-frontend.yml # Shared template +└── organizations/ + └── org-1/ + └── suborgs/ + └── org1-custom-team.yml # Org-specific custom +``` + +**Manifest**: +```yaml +rules: + - name: distribute-shared-suborgs + org_targets: + include: ["*"] + files_to_sync: + include: ["suborgs/shared-*.yml"] # Only shared templates +``` + +**Result**: +- All orgs get shared templates from globals +- Each org maintains unique custom suborgs without conflicts + +--- + +## Manifest.yml Implications + +### For `settings.yml` (Merge Behavior) + +```yaml +rules: + - name: production-orgs + org_targets: + include: ["org-1", "org-2", "prod-*"] + files_to_sync: + include: ["settings.yml"] +``` + +**When `globals/settings.yml` changes**: +- ✅ Merges with `organizations/org-1/settings.yml` → deploys to org-1 +- ✅ Merges with `organizations/org-2/settings.yml` → deploys to org-2 +- ✅ Merges with each `organizations/prod-*/settings.yml` → deploys to matching orgs +- ❌ Does NOT deploy to `org-3` (not in manifest) + +### For All Other Files (Replace Behavior) + +```yaml +rules: + - name: backend-suborg-distribution + org_targets: + include: ["org-1", "org-2"] + files_to_sync: + include: ["suborgs/backend.yml"] +``` + +**When `globals/suborgs/backend.yml` changes**: +- ✅ Deploys to org-1 (REPLACES `organizations/org-1/suborgs/backend.yml` if it exists) +- ✅ Deploys to org-2 (REPLACES `organizations/org-2/suborgs/backend.yml` if it exists) +- ⚠️ Any org-specific customizations in `organizations//suborgs/backend.yml` are LOST + +**Solution**: Don't create `organizations//suborgs/backend.yml` if you want globals to distribute. + +--- + +## Sample Hub Config Structure + +``` +.github/safe-settings/ +├── globals/ +│ ├── manifest.yml # Controls ALL sync rules +│ ├── suborgs/ +│ │ └── backend.yml +│ └── settings.yml # Glocal Base configuration (distributed) +│ +└── organizations/ + ├── org-name-1/ + │ ├── repos/ + │ │ └── repo-x.yml + │ ├── suborgs/ + │ │ └── frontend.yml + │ └── settings.yml # Org-specific override/additions + │ + └── org-name-2/ + ├── repos/ + │ └── repo-y.yml + ├── suborgs/ + │ └── frontend.yml + └── settings.yml # Org-specific override/additions +``` + +--- + +## Use Case 1: Globals/ Distribution Control + +**Goal**: Control which organizations receive files from `globals/` folder. + +### Example Scenario + +You have `globals/settings.yml` and want to: +- ✅ Deploy to `org-name-1` and `org-name-2` +- ❌ NOT deploy to `test-org` or `sandbox-*` orgs + +### Manifest Configuration + +```yaml +# .github/safe-settings/globals/manifest.yml + +rules: + # Production orgs get globals/ files + - name: production-orgs + enabled: true + org_targets: + include: + - "org-name-1" + - "org-name-2" + - "prod-*" # Wildcard for all prod orgs + exclude: + - "test-*" # Exclude all test orgs + - "sandbox-*" # Exclude all sandbox orgs + files_to_sync: + include: + - "settings.yml" + - "suborgs/*.yml" # All suborg configs + mergeStrategy: merge +``` + +### What Happens + +| File Changed | org-name-1 | org-name-2 | test-org | sandbox-123 | +|--------------|------------|------------|----------|-------------| +| `globals/settings.yml` | ✅ Synced | ✅ Synced | ❌ Skipped | ❌ Skipped | +| `globals/suborgs/common.yml` | ✅ Synced | ✅ Synced | ❌ Skipped | ❌ Skipped | + +**Code Flow**: +1. PR changes `globals/settings.yml` +2. Hub-sync loads manifest.yml +3. Filters orgs: `["org-name-1", "org-name-2"]` (test-org and sandbox excluded) +4. Distributes file to filtered orgs only + +--- + +## Use Case 1b: Globals/Suborgs/ Distribution + +**Goal**: Distribute suborg configurations from `globals/suborgs/` to selected organizations. + +### Example Scenario + +You have multiple suborg config files in `globals/suborgs/`: +- `globals/suborgs/backend.yml` - Backend team suborg config +- `globals/suborgs/frontend.yml` - Frontend team suborg config +- `globals/suborgs/mobile.yml` - Mobile team suborg config + +You want to: +- ✅ Distribute ALL suborg configs to `org-name-1` (full-stack org) +- ✅ Distribute ONLY `frontend.yml` to `org-name-2` (frontend-only org) +- ❌ NOT distribute any suborg configs to `test-org` + +### Manifest Configuration + +```yaml +# .github/safe-settings/globals/manifest.yml + +rules: + # org-name-1 gets all suborg configs (full-stack) + - name: org1-all-suborgs + enabled: true + org_targets: + include: + - "org-name-1" + files_to_sync: + include: + - "settings.yml" + - "suborgs/*.yml" # All suborg configs + mergeStrategy: merge + + # org-name-2 gets only frontend suborg (frontend-only) + - name: org2-frontend-only + enabled: true + org_targets: + include: + - "org-name-2" + files_to_sync: + include: + - "settings.yml" + - "suborgs/frontend.yml" # Only frontend + exclude: + - "suborgs/backend.yml" # Exclude backend + - "suborgs/mobile.yml" # Exclude mobile + mergeStrategy: merge +``` + +### What Happens + +| File Changed | org-name-1 | org-name-2 | test-org | +|--------------|------------|------------|----------| +| `globals/suborgs/backend.yml` | ✅ Synced | ❌ Excluded | ❌ No rule | +| `globals/suborgs/frontend.yml` | ✅ Synced | ✅ Synced | ❌ No rule | +| `globals/suborgs/mobile.yml` | ✅ Synced | ❌ Excluded | ❌ No rule | + +**Result**: +- org-name-1 receives all three suborg configs +- org-name-2 receives only frontend.yml +- test-org receives nothing (no matching rule) + +**Real-World Use Case**: +When you update `globals/suborgs/backend.yml` to add a new backend team repository: +1. Change pushed to hub's `globals/suborgs/backend.yml` +2. Hub-sync distributes to: ✅ org-name-1 (has full-stack teams) +3. Hub-sync skips: ❌ org-name-2 (frontend-only, doesn't need backend config) + +--- + +## Use Case 2: Organizations/ Sync Control (Ignore Specific Orgs) + +**Goal**: Ignore certain `organizations//` folders entirely, even when files change. + +### Example Scenario + +You have org folders in hub but want to: +- ✅ Sync `organizations/org-name-1/` → to org-name-1 +- ✅ Sync `organizations/org-name-2/` → to org-name-2 +- ❌ IGNORE `organizations/test-org/` (keep in hub for reference only) +- ❌ IGNORE `organizations/sandbox-*/` (drafts/experiments) + +### Manifest Configuration + +```yaml +# .github/safe-settings/globals/manifest.yml + +rules: + # Only sync production organization folders + - name: production-orgs-only + enabled: true + org_targets: + include: + - "org-name-1" + - "org-name-2" + - "prod-*" + exclude: + - "test-*" # Don't sync test org folders + - "sandbox-*" # Don't sync sandbox org folders + - "*-draft" # Don't sync draft org folders + files_to_sync: + include: + - "*.yml" + mergeStrategy: merge +``` + +### What Happens + +| File Changed | Sync Action | Reason | +|--------------|-------------|---------| +| `organizations/org-name-1/settings.yml` | ✅ Synced to org-name-1 | Org matches rule | +| `organizations/org-name-2/settings.yml` | ✅ Synced to org-name-2 | Org matches rule | +| `organizations/test-org/settings.yml` | ❌ IGNORED | Org excluded by manifest | +| `organizations/sandbox-123/settings.yml` | ❌ IGNORED | Org excluded by manifest | + +**Key Point**: Files in `organizations/test-org/` can exist in hub for documentation/reference but will NOT sync to the org. + +--- + +## Use Case 3: Ignore Specific Files Within Organizations/ + +**Goal**: Sync an org folder but exclude certain files (drafts, notes, experimental configs). + +### Example Scenario + +For `org-name-1`: +- ✅ Sync `settings.yml` +- ✅ Sync `repos/prod-*.yml` +- ❌ IGNORE `draft-*.yml` files +- ❌ IGNORE `repos/experimental-*.yml` files + +### Manifest Configuration + +```yaml +# .github/safe-settings/globals/manifest.yml + +rules: + # org-name-1 with file filtering + - name: org1-with-exclusions + enabled: true + org_targets: + include: + - "org-name-1" + files_to_sync: + include: + - "*.yml" + - "repos/*.yml" + - "suborgs/*.yml" + exclude: + - "draft-*.yml" # Ignore draft files + - "notes.yml" # Ignore notes + - "repos/experimental-*.yml" # Ignore experimental repos + mergeStrategy: merge + + # org-name-2 gets everything (no exclusions) + - name: org2-full-sync + enabled: true + org_targets: + include: + - "org-name-2" + files_to_sync: + include: + - "*.yml" + - "repos/*.yml" + - "suborgs/*.yml" + mergeStrategy: merge +``` + +### What Happens + +| File Changed | org-name-1 | org-name-2 | +|--------------|------------|------------| +| `organizations/org-name-1/settings.yml` | ✅ Synced | ❌ N/A | +| `organizations/org-name-1/draft-settings.yml` | ❌ IGNORED | ❌ N/A | +| `organizations/org-name-1/repos/prod-api.yml` | ✅ Synced | ❌ N/A | +| `organizations/org-name-1/repos/experimental-feature.yml` | ❌ IGNORED | ❌ N/A | +| `organizations/org-name-2/settings.yml` | ❌ N/A | ✅ Synced | +| `organizations/org-name-2/draft-settings.yml` | ❌ N/A | ✅ Synced | + +--- + +## Combined Example: Complete Manifest + +This example covers **all use cases** together: + +```yaml +# .github/safe-settings/globals/manifest.yml + +rules: + # ============================================================================ + # Rule 1: Production orgs get globals/ files + # ============================================================================ + - name: production-globals-distribution + enabled: true + org_targets: + include: + - "org-name-1" + - "org-name-2" + - "prod-*" + exclude: + - "test-*" + - "sandbox-*" + files_to_sync: + include: + - "settings.yml" + - "suborgs/*.yml" + mergeStrategy: merge + + # ============================================================================ + # Rule 2: org-name-1 syncs with file exclusions + # ============================================================================ + - name: org1-with-file-filtering + enabled: true + org_targets: + include: + - "org-name-1" + files_to_sync: + include: + - "*.yml" + - "repos/*.yml" + - "suborgs/*.yml" + exclude: + - "draft-*.yml" + - "notes.yml" + - "repos/experimental-*.yml" + mergeStrategy: merge + + # ============================================================================ + # Rule 3: org-name-2 syncs everything (no exclusions) + # ============================================================================ + - name: org2-full-sync + enabled: true + org_targets: + include: + - "org-name-2" + files_to_sync: + include: + - "*.yml" + - "repos/*.yml" + - "suborgs/*.yml" + mergeStrategy: merge + + # ============================================================================ + # Test and sandbox orgs are implicitly excluded (no rules match them) + # ============================================================================ +``` + +### Complete Behavior Table + +| File Changed | org-name-1 | org-name-2 | test-org | sandbox-123 | +|--------------|------------|------------|----------|-------------| +| **Globals Distribution** | | | | | +| `globals/settings.yml` | ✅ Rule 1 | ✅ Rule 1 | ❌ Excluded | ❌ Excluded | +| `globals/suborgs/frontend.yml` | ✅ Rule 1 | ✅ Rule 1 | ❌ Excluded | ❌ Excluded | +| **Organizations/ Sync** | | | | | +| `organizations/org-name-1/settings.yml` | ✅ Rule 2 | ❌ N/A | ❌ N/A | ❌ N/A | +| `organizations/org-name-1/draft-config.yml` | ❌ Excluded | ❌ N/A | ❌ N/A | ❌ N/A | +| `organizations/org-name-1/repos/prod-api.yml` | ✅ Rule 2 | ❌ N/A | ❌ N/A | ❌ N/A | +| `organizations/org-name-1/repos/experimental-x.yml` | ❌ Excluded | ❌ N/A | ❌ N/A | ❌ N/A | +| `organizations/org-name-2/settings.yml` | ❌ N/A | ✅ Rule 3 | ❌ N/A | ❌ N/A | +| `organizations/org-name-2/draft-config.yml` | ❌ N/A | ✅ Rule 3 | ❌ N/A | ❌ N/A | +| `organizations/test-org/settings.yml` | ❌ N/A | ❌ N/A | ❌ No rule | ❌ N/A | +| `organizations/sandbox-123/settings.yml` | ❌ N/A | ❌ N/A | ❌ N/A | ❌ No rule | + +--- + +## Pattern Matching Rules + +### Wildcards + +- `*` - Matches any characters + - `test-*` matches `test-org`, `test-prod`, `test-123` + - `*.yml` matches `settings.yml`, `config.yml`, etc. + +- `**` - Matches any directory depth + - `**/repos/*.yml` matches `repos/api.yml` and `suborgs/repos/api.yml` + +- `?` - Matches single character + - `org-?` matches `org-1`, `org-a`, but not `org-12` + +### Literal Strings + +```yaml +org_targets: + include: + - "org-name-1" # Exact match only + - "prod-org-specific" # Exact match only +``` + +### Exclude Takes Precedence + +```yaml +org_targets: + include: + - "prod-*" # Matches all prod-* orgs + exclude: + - "prod-test-*" # But excludes prod-test-* orgs +``` + +Result: +- ✅ `prod-api` included +- ✅ `prod-web` included +- ❌ `prod-test-123` excluded (exclude takes precedence) + +--- + +## How It Works: Code Flow + +### Globals/ Distribution + +```javascript +// lib/hubSyncHandler.js - syncHubGlobalsUpdate() + +1. Load manifest.yml +2. Get all organizations with app installations +3. Filter orgs by manifest rules → ["org-name-1", "org-name-2"] +4. For each changed file in globals/: + - Check if file matches manifest patterns + - If yes, distribute to ALL filtered orgs + - If no, skip file +``` + +### Organizations/ Sync + +```javascript +// lib/hubSyncHandler.js - syncHubOrgUpdate() + +1. Load manifest.yml +2. Detect changed files in organizations/org-name-1/ +3. Check if org-name-1 matches manifest org_targets + - If no, skip entire org folder +4. Filter files by manifest files_to_sync patterns +5. Sync only matched files to org-name-1 +``` + +--- + +## Troubleshooting + +### Issue: Org not receiving globals/ files + +**Diagnostic Steps**: + +1. Check manifest.yml rules - does org match any `org_targets.include` pattern? + ```yaml + org_targets: + include: + - "org-name-1" # Does your org match? + ``` + +2. Check if org is explicitly excluded: + ```yaml + org_targets: + exclude: + - "test-*" # Is your org in exclude list? + ``` + +3. Check logs for: `Organization {name} excluded by manifest rules - skipping sync` + +4. Verify at least one rule has `enabled: true` + +### Issue: Organizations/ folder not syncing + +**Diagnostic Steps**: + +1. Check if org matches manifest: + ```bash + # Look for this in logs + "Organization org-name-1 excluded by manifest rules - skipping sync" + ``` + +2. Check if files are excluded: + ```bash + # Look for this in logs + "All 3 file(s) for org org-name-1 were excluded by manifest rules" + ``` + +3. Verify folder name matches exactly: + - Folder: `organizations/org-name-1/` + - Manifest: `org_targets: include: ["org-name-1"]` + - Must match exactly (case-sensitive) + +### Issue: Specific file not syncing + +**Diagnostic Steps**: + +1. Check file pattern match: + ```yaml + files_to_sync: + include: + - "*.yml" # Does your file match? + exclude: + - "draft-*.yml" # Is your file excluded? + ``` + +2. Check logs for: `File {path} excluded by manifest rules - skipping` + +3. Test pattern matching: + - File: `draft-settings.yml` + - Pattern: `draft-*.yml` + - Result: ❌ Excluded (matches exclude pattern) + +### Debugging Tips + +Enable debug logging to see pattern matching: + +```yaml +# Manifest rule with debug-friendly name +rules: + - name: debug-org-name-1 # Clear name helps identify rule in logs + enabled: true + org_targets: + include: ["org-name-1"] +``` + +Look for these log entries: +- `"Org org-name-1 included by rule 'debug-org-name-1'"` +- `"File settings.yml included by rule 'debug-org-name-1'"` +- `"Org test-org not matched by any manifest rule - excluding from sync"` + +### Troubleshooting Rule Conflicts + +#### Issue: File unexpectedly syncing despite exclude rule + +**Symptom**: You added an exclude in one rule, but the file still syncs. + +**Example**: +```yaml +rules: + - name: exclude-test-files + org_targets: + include: ["ORG-PROD"] + files_to_sync: + exclude: ["suborgs/test.yml"] # Trying to block this + + - name: all-files-all-orgs + org_targets: + include: ["*"] + files_to_sync: + include: ["suborgs/*.yml"] # But this includes it! +``` + +**Why**: Rule 2 includes the file for ORG-PROD, overriding Rule 1's exclude. Rules use OR logic. + +**Solution**: Use org exclusion instead of file exclusion: +```yaml +rules: + - name: all-files-most-orgs + org_targets: + include: ["*"] + exclude: ["ORG-PROD"] # Exclude org from this rule + files_to_sync: + include: ["suborgs/*.yml"] + + - name: prod-selective-files + org_targets: + include: ["ORG-PROD"] + files_to_sync: + include: ["settings.yml"] # Only specific files + # suborgs/test.yml NOT included +``` + +#### Issue: Specific rule being ignored + +**Symptom**: You created a specific rule for an org, but a general rule seems to override it. + +**Example**: +```yaml +rules: + - name: all-orgs-all-files + org_targets: ["*"] + files_to_sync: ["*.yml"] + + - name: test-org-settings-only # Never reached! + org_targets: ["ORG-TEST"] + files_to_sync: ["settings.yml"] +``` + +**Why**: Rule 1 matches first (first-match-wins), so Rule 2 never evaluates. + +**Solution**: Reorder rules - specific before general: +```yaml +rules: + - name: test-org-settings-only # Check this first + org_targets: ["ORG-TEST"] + files_to_sync: ["settings.yml"] + + - name: all-other-orgs-all-files # Then general + org_targets: + include: ["*"] + exclude: ["ORG-TEST"] # Exclude already-handled orgs + files_to_sync: ["*.yml"] +``` + +#### Issue: Can't figure out which rule is matching + +**Symptom**: File syncs but you don't know why. + +**Solution**: Use clear rule names and check logs: + +```yaml +rules: + - name: PROD-ONLY-settings # Clear naming convention + org_targets: ["ORG-PROD"] + files_to_sync: ["settings.yml"] + + - name: ALL-ORGs-suborgs # Indicates scope + org_targets: ["*"] + files_to_sync: ["suborgs/*.yml"] +``` + +Enable debug logging and search for: +``` +"Org ORG-PROD included by rule 'PROD-ONLY-settings'" +"File suborgs/backend.yml included by rule 'ALL-ORGs-suborgs'" +``` + +#### Issue: Exclude pattern not working as expected + +**Symptom**: Pattern like `exclude: ["suborgs/test.yml"]` doesn't match. + +**Why**: The file path being matched might be different (e.g., full path vs relative path). + +**Patterns checked** (from code): +1. Just filename: `test.yml` +2. Relative path (last 2 segments): `suborgs/test.yml` +3. Full path: `globals/suborgs/test.yml` or `organizations/org-1/suborgs/test.yml` + +**Solution**: Use multiple patterns or glob matching: +```yaml +files_to_sync: + exclude: + - "test.yml" # Matches filename + - "suborgs/test.yml" # Matches relative path + - "**/test.yml" # Glob matches anywhere +``` + +### Issue: Org-specific customizations being overwritten + +**Symptom**: You have `organizations/org-1/suborgs/backend.yml` with custom content, but it keeps getting replaced with `globals/suborgs/backend.yml`. + +**Root Cause**: Non-settings.yml files use REPLACE behavior. When `globals/suborgs/backend.yml` is distributed, it OVERWRITES the org-specific version. + +**Solution Options**: + +**Option 1: Use Different Filenames** (Recommended) +``` +.github/safe-settings/ +├── globals/ +│ └── suborgs/ +│ └── shared-backend.yml # Shared template +└── organizations/ + └── org-1/ + └── suborgs/ + └── org1-backend.yml # Org-specific custom +``` + +**Option 2: Exclude Org from Globals Distribution** +```yaml +rules: + - name: backend-suborg-to-most-orgs + org_targets: + include: ["*"] + exclude: ["org-1"] # org-1 maintains its own custom version + files_to_sync: + include: ["suborgs/backend.yml"] +``` + +**Trade-off**: org-1 will never receive globals updates for `backend.yml`. + +**Option 3: Remove Org-Specific Version** + +If globals should be the source of truth, delete `organizations/org-1/suborgs/backend.yml` entirely and let globals distribute. + +### Issue: "I need org-specific customizations but also want globals updates" + +**Symptom**: You want `organizations/org-1/suborgs/backend.yml` to receive updates from `globals/suborgs/backend.yml` BUT also maintain org-specific additions. + +**Reality**: This is **NOT supported** except for `settings.yml`. + +**Why**: Only `settings.yml` has deep YAML merge capability. All other files use simple REPLACE. + +**Workaround Options**: + +**Option 1: Use `settings.yml` for mergeable content** + +Move mergeable configuration into `settings.yml`: +```yaml +# globals/settings.yml +suborgs: + backend: + name: "backend-team" + permission: "push" + +# organizations/org-1/settings.yml +suborgs: + backend: + org_specific_field: "custom-value" # This will merge +``` + +**Option 2: Use inheritance via naming** +``` +globals/suborgs/backend-base.yml # Shared baseline +organizations/org-1/suborgs/backend-custom.yml # Org additions +``` + +Application layer loads both files and merges them. + +**Option 3: Accept globals as source of truth** + +Remove org-specific customizations and manage everything from globals with conditional logic: + +```yaml +# globals/suborgs/backend.yml +teams: + - name: backend + permission: push + - name: backend-org1 + permission: admin + orgs: ["org-1"] # Conditional for org-1 +``` + +### Issue: Manifest exclude creates a deadlock + +**Symptom**: +- I want `organizations/org-1/suborgs/backend.yml` to NOT be overwritten by globals +- So I exclude org-1 from manifest +- But now org-1 never gets ANY globals updates for suborgs + +**Root Cause**: Manifest rules are file-pattern based, not merge-aware. + +**Solution**: This is the expected behavior. You must choose: + +**Choice A: Globals Distribution** (Recommended for most orgs) +- Remove `organizations/org-1/suborgs/backend.yml` +- Include org-1 in manifest +- Globals distributes and updates automatically + +**Choice B: Org-Specific Management** (For unique orgs) +- Keep `organizations/org-1/suborgs/backend.yml` +- Exclude org-1 from manifest +- Manually maintain org-1's configuration + +**You cannot have both** unless you use different filenames (see Option 1 above). + +--- + +## Best Practices + +### 1. Use Descriptive Rule Names + +✅ **Good**: +```yaml +rules: + - name: production-orgs-full-sync + - name: test-orgs-limited-sync + - name: draft-org-no-sync +``` + +❌ **Bad**: +```yaml +rules: + - name: rule-1 + - name: rule-2 +``` + +### 2. Start Broad, Refine Incrementally + +**Phase 1**: Start with everything +```yaml +rules: + - name: all-orgs-all-files + org_targets: + include: ["*"] + files_to_sync: + include: ["*.yml"] +``` + +**Phase 2**: Add exclusions +```yaml +rules: + - name: all-except-test + org_targets: + include: ["*"] + exclude: ["test-*"] # Added exclusion + files_to_sync: + include: ["*.yml"] +``` + +**Phase 3**: Add file filtering +```yaml +rules: + - name: production-filtered + org_targets: + include: ["prod-*"] + files_to_sync: + include: ["*.yml"] + exclude: ["draft-*.yml"] # Added file exclusion +``` + +### 3. Document Intent with Comments + +```yaml +rules: + # Production orgs receive all configuration updates for compliance + - name: production-full-sync + org_targets: + include: ["prod-*"] + + # Test orgs get limited updates to prevent accidental changes + - name: test-limited-sync + org_targets: + include: ["test-*"] + files_to_sync: + exclude: ["repos/prod-*.yml"] # Don't sync prod repos to test +``` + +### 4. Test with Disabled Rules First + +```yaml +rules: + # Testing new exclusion pattern - not active yet + - name: experimental-filtering + enabled: false # Safe to test without affecting production + org_targets: + include: ["test-org"] + files_to_sync: + exclude: ["draft-*.yml"] +``` + +Once verified, set `enabled: true` + +### 5. Keep Reference Files in Hub + +Files can exist in hub without syncing - perfect for: +- Documentation: `organizations/org-1/README.md` +- Drafts: `organizations/org-1/draft-settings.yml` +- Templates: `organizations/template-org/settings.yml` + +Just exclude them in manifest: +```yaml +files_to_sync: + exclude: + - "README.md" + - "draft-*.yml" +``` + +--- + +## Real-World Examples + +### Example 1: Phased Rollout + +**Scenario**: Rolling out new configuration to orgs in phases + +```yaml +# Phase 1: Pilot orgs only +rules: + - name: phase-1-pilot + enabled: true + org_targets: + include: + - "pilot-org-1" + - "pilot-org-2" + files_to_sync: + include: ["settings.yml"] + mergeStrategy: merge +``` + +After validation, update to Phase 2: +```yaml +# Phase 2: Add production orgs +rules: + - name: phase-2-production + enabled: true + org_targets: + include: + - "pilot-org-1" + - "pilot-org-2" + - "prod-*" # Added production orgs + files_to_sync: + include: ["settings.yml"] + mergeStrategy: merge +``` + +### Example 2: Regional Distribution + +**Scenario**: Different suborg configs for different regions based on geographic teams + +You have in `globals/suborgs/`: +- `us-east.yml`, `us-west.yml` - US region teams +- `eu-west.yml`, `eu-north.yml` - EU region teams +- `common.yml` - Shared across all regions + +```yaml +rules: + # US orgs get US suborg configs + - name: us-region + org_targets: + include: ["org-us-*"] + files_to_sync: + include: + - "settings.yml" + - "suborgs/us-*.yml" # All US suborg configs + - "suborgs/common.yml" # Common suborg config + exclude: + - "suborgs/eu-*.yml" # No EU configs + mergeStrategy: merge + + # EU orgs get EU suborg configs + - name: eu-region + org_targets: + include: ["org-eu-*"] + files_to_sync: + include: + - "settings.yml" + - "suborgs/eu-*.yml" # All EU suborg configs + - "suborgs/common.yml" # Common suborg config + exclude: + - "suborgs/us-*.yml" # No US configs + mergeStrategy: merge +``` + +**Distribution Table**: + +| File Changed | org-us-east | org-us-west | org-eu-west | org-eu-north | +|--------------|-------------|-------------|-------------|--------------| +| `globals/suborgs/us-east.yml` | ✅ Synced | ✅ Synced | ❌ Excluded | ❌ Excluded | +| `globals/suborgs/us-west.yml` | ✅ Synced | ✅ Synced | ❌ Excluded | ❌ Excluded | +| `globals/suborgs/eu-west.yml` | ❌ Excluded | ❌ Excluded | ✅ Synced | ✅ Synced | +| `globals/suborgs/eu-north.yml` | ❌ Excluded | ❌ Excluded | ✅ Synced | ✅ Synced | +| `globals/suborgs/common.yml` | ✅ Synced | ✅ Synced | ✅ Synced | ✅ Synced | + +**Key Benefit**: Regional compliance and data sovereignty - EU teams don't receive US configurations and vice versa. + +### Example 3: Compliance Separation + +**Scenario**: Compliance-critical orgs get vetted configs only + +```yaml +rules: + # Compliance orgs - strict filtering + - name: compliance-orgs + org_targets: + include: ["compliance-*", "audit-*"] + files_to_sync: + include: + - "settings.yml" # Only approved configs + exclude: + - "repos/*" # No repo configs + - "suborgs/*" # No suborg configs + - "experimental-*" # No experimental configs + mergeStrategy: overwrite # Force exact configuration + + # Dev orgs - everything allowed + - name: dev-orgs + org_targets: + include: ["dev-*"] + files_to_sync: + include: ["*.yml"] # All files + mergeStrategy: merge +``` + +### Example 4: Selective Suborg Deployment + +**Scenario**: You create a new suborg config for a DevOps team and want to roll it out selectively + +**Step 1**: Create the new suborg config +```bash +# Create new DevOps suborg configuration +touch .github/safe-settings/globals/suborgs/devops-platform.yml +``` + +**Step 2**: Configure selective distribution in manifest +```yaml +rules: + # Production orgs with DevOps teams + - name: prod-orgs-with-devops + org_targets: + include: + - "org-prod-main" + - "org-prod-platform" + - "org-prod-services" + files_to_sync: + include: + - "settings.yml" + - "suborgs/devops-platform.yml" # Only the new DevOps config + - "suborgs/backend.yml" # Existing backend config + - "suborgs/frontend.yml" # Existing frontend config + mergeStrategy: merge + + # Dev/Test orgs - exclude the new DevOps suborg (not ready yet) + - name: dev-test-orgs + org_targets: + include: + - "org-dev-*" + - "org-test-*" + files_to_sync: + include: + - "settings.yml" + - "suborgs/*.yml" + exclude: + - "suborgs/devops-platform.yml" # Exclude DevOps from dev/test + mergeStrategy: merge +``` + +**Step 3**: Push changes +```bash +git add .github/safe-settings/globals/suborgs/devops-platform.yml +git commit -m "Add DevOps platform suborg configuration" +git push +``` + +**Result Distribution**: + +| Organization | Receives devops-platform.yml? | Reason | +|--------------|-------------------------------|--------| +| org-prod-main | ✅ Yes | Explicitly included in prod rule | +| org-prod-platform | ✅ Yes | Explicitly included in prod rule | +| org-prod-services | ✅ Yes | Explicitly included in prod rule | +| org-dev-sandbox | ❌ No | Excluded by dev-test rule | +| org-test-qa | ❌ No | Excluded by dev-test rule | + +**Key Benefit**: New suborg configurations can be tested in production orgs first before rolling out to dev/test environments. + +--- + +## Related Documentation + +- [ADR: Organization and File Selective Synchronization](./ADR-org-file-selective-sync.md) - Design decision and architecture +- [ADR: Manifest-Based Hub-Sync Control](./ADR-manifest-control.md) - Original manifest design +- [Hub-Sync Architecture](./architecture-sequence.md) - Overall sync flow +- [Implementation Summary](./IMPLEMENTATION-SUMMARY.md) - Technical details + +--- + +## Quick Reference + +### Manifest Structure + +```yaml +rules: + - name: + enabled: true|false + org_targets: + include: [patterns] + exclude: [patterns] + files_to_sync: + include: [patterns] + exclude: [patterns] + mergeStrategy: merge|overwrite|preserve +``` + +### Common Patterns + +| Pattern | Matches | Example | +|---------|---------|---------| +| `*` | Any characters | `test-*` → `test-org`, `test-123` | +| `*.yml` | Any .yml file | `settings.yml`, `config.yml` | +| `repos/*.yml` | .yml in repos/ | `repos/api.yml` | +| `suborgs/*.yml` | .yml in suborgs/ | `suborgs/backend.yml`, `suborgs/frontend.yml` | +| `suborgs/us-*.yml` | US suborgs | `suborgs/us-east.yml`, `suborgs/us-west.yml` | +| `suborgs/eu-*.yml` | EU suborgs | `suborgs/eu-west.yml`, `suborgs/eu-north.yml` | +| `test-?` | Single char | `test-1`, `test-a` | +| Literal | Exact match | `"org-1"` → only `org-1` | + +### Rule Precedence Quick Reference + +**How Rules Work**: +- ✅ **OR logic** - ANY rule can include an org/file +- 🔢 **Order matters** - Rules evaluated top-to-bottom +- 🏆 **First match wins** - First rule to include returns TRUE immediately +- 🚫 **Within rule** - Exclude checked before include +- ➡️ **Exclude continues** - If excluded by one rule, checks next rule +- ❌ **No match = excluded** - Default is to exclude if no rule includes + +**Key Rules**: +1. Put **specific** rules BEFORE general rules +2. An exclude in one rule doesn't prevent another rule from including +3. To truly block something, ensure NO rule includes it +4. Use clear rule names for debugging + +**Quick Test**: "Does file X sync to org Y?" +1. Check rules in order (top to bottom) +2. For each rule: Is rule enabled? → Does org match include (and not exclude)? → Does file match include (and not exclude)? +3. If YES to all → **SYNCS** (stop checking) +4. If NO to any → Try next rule +5. If no rule matches → **EXCLUDED** + +### Merge Strategies + +- **merge**: Create PR with changes (safe, reviewable) +- **overwrite**: Direct push, replace all content (fast, no review) +- **preserve**: Only add new files, never update existing + +--- + +**Questions?** Check the [Troubleshooting](#troubleshooting) section or review the logs for detailed pattern matching information. diff --git a/docs/hubSyncHandler/usecase-merge-behavior.md b/docs/hubSyncHandler/usecase-merge-behavior.md new file mode 100644 index 000000000..7ecd57fd6 --- /dev/null +++ b/docs/hubSyncHandler/usecase-merge-behavior.md @@ -0,0 +1,2 @@ +# Github Safe-Settings HubSync Usecases + diff --git a/index.js b/index.js index 4ed6c2398..ca82fc99b 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,9 @@ const NopCommand = require('./lib/nopcommand') const SettingsGenerator = require('./lib/settingsGenerator') const AppOctokitClient = require('./lib/appOctokitClient') const env = require('./lib/env') +const { setupRoutes } = require('./lib/routes') +const { initCache } = require('./lib/installationCache') +const { hubSyncHandler } = require('./lib/hubSyncHandler') const { getProxyForUrl } = require('proxy-from-env') const { setGlobalDispatcher, ProxyAgent } = require('undici') @@ -19,8 +22,18 @@ if (proxyAddress) { let deploymentConfig -module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => { +module.exports = (robot, { addHandler }, Settings = require('./lib/settings')) => { let appSlug = 'safe-settings' + + // Initialize all routes (static UI + API) via centralized module + // Full-sync runs without Probot's HTTP server; only initialize routes when + // the runtime provides one. + if (typeof addHandler === 'function') { + setupRoutes(robot, addHandler) + } + // Initialize installation cache (env-controlled prefetch) + initCache(robot) + // Cache of enterprise slug → enterprise installation id. Keyed by slug so a // cached id is never reused for a different enterprise (e.g. when the app // handles events from multiple enterprises). @@ -487,7 +500,8 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const { repository } = payload const adminRepo = repository.name === env.ADMIN_REPO - if (!adminRepo) { + const hubMasterRepo = repository.name === env.SAFE_SETTINGS_HUB_REPO && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + if (!adminRepo && !hubMasterRepo) { return } @@ -735,9 +749,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const { payload } = context const { repository } = payload const adminRepo = repository.name === env.ADMIN_REPO - robot.log.debug(`Is Admin repo event ${adminRepo}`) - if (!adminRepo) { - robot.log.debug('Not working on the Admin repo, returning...') + const hubMasterRepo = repository.name === env.SAFE_SETTINGS_HUB_REPO && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + robot.log.debug(`Is Admin repo event ${adminRepo}, Is Hub-sync master repo ${hubMasterRepo}`) + if (!adminRepo && !hubMasterRepo) { + robot.log.debug('Not working on the Admin repo or Hub-sync master repo, returning...') return } const defaultBranch = payload.check_suite.head_branch === repository.default_branch @@ -764,9 +779,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const { payload } = context const { repository } = payload const adminRepo = repository.name === env.ADMIN_REPO - robot.log.debug(`Is Admin repo event ${adminRepo}`) - if (!adminRepo) { - robot.log.debug('Not working on the Admin repo, returning...') + const hubMasterRepo = repository.name === env.SAFE_SETTINGS_HUB_REPO && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + robot.log.debug(`Is Admin repo event ${adminRepo}, Is Hub-sync master repo ${hubMasterRepo}`) + if (!adminRepo && !hubMasterRepo) { + robot.log.debug('Not working on the Admin repo or Hub-sync master repo, returning...') return } const defaultBranch = payload.pull_request.head_branch === repository.default_branch @@ -785,9 +801,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const pull_request = payload.pull_request const adminRepo = repository.name === env.ADMIN_REPO - robot.log.debug(`Is Admin repo event ${adminRepo}`) - if (!adminRepo) { - robot.log.debug('Not working on the Admin repo, returning...') + const hubMasterRepo = repository.name === env.SAFE_SETTINGS_HUB_REPO && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + robot.log.debug(`Is Admin repo event ${adminRepo}, Is Hub-sync master repo ${hubMasterRepo}`) + if (!adminRepo && !hubMasterRepo) { + robot.log.debug('Not working on the Admin repo or Hub-sync master repo, returning...') return } @@ -799,6 +816,20 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => return createCheckRun(context, pull_request, payload.pull_request.head.sha, payload.pull_request.head.ref) }) + /** + * @description Handle pull_request.closed events to support hub synchronization + * @param {Object} context - The context object provided by Probot + */ + robot.on('pull_request.closed', async context => { + if (!context.payload.pull_request?.merged) return null + try { + await hubSyncHandler(robot, context) + } catch (err) { + robot.log.error(`pull_request.closed handler failed: ${err && err.message ? err.message : err}`) + } + return null + }) + robot.on(['check_suite.rerequested'], async context => { robot.log.debug('Check suite was rerequested!') return createCheckRun(context) @@ -828,9 +859,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => } const adminRepo = repository.name === env.ADMIN_REPO - robot.log.debug(`Is Admin repo event ${adminRepo}`) - if (!adminRepo) { - robot.log.debug('Not working on the Admin repo, returning...') + const hubMasterRepo = repository.name === env.SAFE_SETTINGS_HUB_REPO && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + robot.log.debug(`Is Admin repo event ${adminRepo}, Is Hub-sync master repo ${hubMasterRepo}`) + if (!adminRepo && !hubMasterRepo) { + robot.log.debug('Not working on the Admin repo or Hub-sync master repo, returning...') return } @@ -859,6 +891,20 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const repoChanges = getChangedRepoConfigName(files, context.repo().owner) const subOrgChanges = getChangedSubOrgConfigName(files) + // Check if hub-sync master files changed - handle separately from Safe-Settings validation + const hubPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}`.replace(/\/+/g, '/') + const globalsPattern = new RegExp(`^${hubPath}/globals/.*\\.ya?ml$`) + const orgsPattern = new RegExp(`^${hubPath}/organizations/([^/]+)/.*\\.ya?ml$`) + const hubSyncFilesChanged = files.filter(f => globalsPattern.test(f) || orgsPattern.test(f)) + + const baseRef = pull_request.base.ref || repository.default_branch + + if (hubSyncFilesChanged.length > 0) { + robot.log.info(`Hub-sync master files detected: ${hubSyncFilesChanged.join(', ')}`) + const { validateAndReportHubSync } = require('./lib/hubSyncHandler') + return validateAndReportHubSync(robot, context, payload, pull_request, hubSyncFilesChanged, baseRef) + } + if (settingsModified) { robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`) const baseRef = pull_request.base.ref || repository.default_branch diff --git a/lib/env.js b/lib/env.js index bde22c40c..264f9b501 100644 --- a/lib/env.js +++ b/lib/env.js @@ -1,5 +1,7 @@ module.exports = { ADMIN_REPO: process.env.ADMIN_REPO || 'admin', + APP_ID: process.env.APP_ID || null, + PRIVATE_KEY_PATH: process.env.PRIVATE_KEY_PATH || 'private-key.pem', CONFIG_PATH: process.env.CONFIG_PATH || '.github', SETTINGS_FILE_PATH: process.env.SETTINGS_FILE_PATH || 'settings.yml', DEPLOYMENT_CONFIG_FILE_PATH: process.env.DEPLOYMENT_CONFIG_FILE || 'deployment-settings.yml', @@ -12,4 +14,16 @@ module.exports = { GH_ORG: process.env.GH_ORG, GHE_HOST: process.env.GHE_HOST, GHE_PROTOCOL: process.env.GHE_PROTOCOL, + SAFE_SETTINGS_HUB_REPO: process.env.SAFE_SETTINGS_HUB_REPO || 'admin-master', + SAFE_SETTINGS_HUB_ORG: process.env.SAFE_SETTINGS_HUB_ORG || 'admin-master-org', + SAFE_SETTINGS_HUB_DIRECT_PUSH: process.env.SAFE_SETTINGS_HUB_DIRECT_PUSH || 'false', + SAFE_SETTINGS_HUB_PATH: process.env.SAFE_SETTINGS_HUB_PATH || 'safe-settings', + SAFE_SETTINGS_HUB_URL_PREFIX: (() => { + const configuredPrefix = process.env.SAFE_SETTINGS_HUB_URL_PREFIX + const prefix = configuredPrefix === undefined ? '/safe-settings' : configuredPrefix + // Normalize: add leading '/' if missing, treat '/' as empty string for root path + if (!prefix || prefix === '/') return '' + return prefix.startsWith('/') ? prefix : `/${prefix}` + })(), + SAFE_SETTINGS_HUB_REIMPORT: process.env.SAFE_SETTINGS_HUB_REIMPORT || 'false' } diff --git a/lib/hubSyncHandler.js b/lib/hubSyncHandler.js new file mode 100644 index 000000000..d36299c81 --- /dev/null +++ b/lib/hubSyncHandler.js @@ -0,0 +1,1722 @@ +const { minimatch } = require('minimatch') +const env = require('./env') +const { getInstallations } = require('./installationCache') +const yaml = require('js-yaml') +const path = require('path') +const fs = require('fs') +const os = require('os') +const util = require('util') +const mergeBy = require('./mergeArrayBy') + +/** + * Attach a file-backed logger to robot.log that mirrors all log calls to a file. + * It preserves the original behavior and appends each log line to a file, trimming + * the file to the last `maxLines` entries (default 1000). + * + * Usage: call attachFileLogger(robot, { filePath: '/tmp/safe-settings.log', maxLines: 1000 }) + */ +function attachFileLogger (robot, options = {}) { + if (!robot || !robot.log) return + if (robot.log.__fileLoggerAttached) return + const filePath = options.filePath || process.env.SAFE_SETTINGS_LOG_FILE || path.join(process.cwd(), 'hubSyncHandler.log') + const maxLines = Number(options.maxLines || process.env.SAFE_SETTINGS_LOG_FILE_MAX_LINES || 1000) + const methods = ['info', 'warn', 'debug', 'error', 'fatal', 'trace', 'notice'] + + methods.forEach(method => { + const orig = (robot.log && robot.log[method]) ? robot.log[method].bind(robot.log) : (...args) => { /* no-op */ } + robot.log[method] = (...args) => { + // call original logger so console output still occurs + try { orig(...args) } catch (e) { /* swallow */ } + + // Build a single-line message representation + try { + const msg = args.map(a => (typeof a === 'string' ? a : util.inspect(a, { depth: 2 }))).join(' ') + const line = `${new Date().toISOString()} [${method.toUpperCase()}] ${msg}` + // append and then trim to last `maxLines` + fs.appendFile(filePath, line + os.EOL, err => { + if (err) { + try { orig(`Failed to append log to ${filePath}: ${err.message}`) } catch (e) { /* swallow */ } + return + } + // trim asynchronously + fs.promises.readFile(filePath, 'utf8').then(data => { + const lines = data.split(/\r?\n/) + // Remove a possible trailing empty line created by join + if (lines.length && lines[lines.length - 1] === '') lines.pop() + if (lines.length > maxLines) { + const tail = lines.slice(-maxLines) + return fs.promises.writeFile(filePath, tail.join(os.EOL) + os.EOL, 'utf8') + } + return Promise.resolve() + }).catch(() => { /* don't break logging on trim failures */ }) + }) + } catch (e) { + try { orig(`Failed to write log to ${filePath}: ${e && e.message ? e.message : e}`) } catch (e) { /* swallow */ } + } + } + }) + + robot.log.__fileLoggerAttached = true +} + +/** + * Get authenticated octokit client for an org installation + * @param {import('probot').Probot} robot + * @param {string} orgName + * @returns {Promise} Authenticated client or null + */ +async function getOrgInstallation (robot, orgName) { + const installs = await getInstallations(robot) + const install = installs.find(i => i.account && i.account.type === 'Organization' && i.account.login.toLowerCase() === orgName.toLowerCase()) + if (!install) { + return null + } + return await robot.auth(install.id) +} + + +// Helper to create a branch if not direct push +async function createBranchIfNeeded(githubClient, owner, repo, baseBranch, branchName, directPush, logger) { + if (!directPush) { + try { + const baseRef = await githubClient.rest.git.getRef({ owner, repo, ref: `heads/${baseBranch}` }) + const baseSha = baseRef.data.object.sha + await githubClient.rest.git.createRef({ owner, repo, ref: `refs/heads/${branchName}`, sha: baseSha }) + logger.info(`Created branch ${branchName} in ${owner}/${repo}`) + } catch (err) { + if (err.status === 422) { + logger.warn(`Branch ${branchName} already exists, continuing`) + } else { + throw err + } + } + } +} + +// Helper to create or update a file in a repo +async function createOrUpdateFile(githubClient, params, logger) { + try { + await githubClient.rest.repos.createOrUpdateFileContents(params) + logger.info(`Committed ${params.path} to ${params.owner}/${params.repo}@${params.branch}`) + } catch (err) { + logger.error(`Failed to sync file ${params.path}: ${err.message}`) + throw err + } +} + +// ============================================================================ +// MANIFEST-BASED FILTERING FUNCTIONS +// ============================================================================ + +let manifestCache = null +let manifestCacheTime = 0 +const MANIFEST_CACHE_TTL = 60000 // 1 minute + +/** + * Load and parse the manifest file with caching + * @param {import('@octokit/rest').Octokit} octokit + * @param {string} ref - Git ref to load manifest from + * @param {object} logger - Logger instance + * @returns {Promise} Parsed manifest or null if not found/invalid + */ +async function loadManifest(octokit, ref, logger) { + const now = Date.now() + + // Return cached manifest if still valid + if (manifestCache && (now - manifestCacheTime) < MANIFEST_CACHE_TTL) { + logger.debug('Using cached manifest') + return manifestCache + } + + const manifestPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/globals/manifest.yml` + + try { + const resp = await octokit.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: manifestPath, + ref: ref || 'main' + }) + + if (Array.isArray(resp.data)) { + logger.warn(`Expected manifest file but got directory at ${manifestPath}`) + return null + } + + const manifestContent = Buffer.from(resp.data.content, resp.data.encoding).toString('utf8') + const manifest = yaml.load(manifestContent) + + if (!manifest || !manifest.rules || !Array.isArray(manifest.rules)) { + logger.warn('Invalid manifest: missing or invalid rules array') + return null + } + + // Normalize rules to new format (include/exclude) + const normalized = normalizeManifestRules(manifest) + + // Cache the result + manifestCache = normalized + manifestCacheTime = now + + logger.debug(`Loaded manifest with ${normalized.rules.length} rule(s)`) + return normalized + + } catch (err) { + if (err.status === 404) { + logger.warn(`Manifest not found at ${manifestPath} - defaulting to sync all`) + } else { + logger.error(`Failed to load manifest: ${err.message}`) + } + return null + } +} + +/** + * Normalize manifest rules from old format to new include/exclude format + * Supports backward compatibility with old "targets" and "files" arrays + * @param {object} manifest - Raw manifest object + * @returns {object} Normalized manifest + */ +function normalizeManifestRules(manifest) { + const normalized = { rules: [] } + + for (const rule of manifest.rules) { + const normalizedRule = { + name: rule.name || 'unnamed-rule', + enabled: rule.enabled !== false, // Default to true + mergeStrategy: rule.mergeStrategy || 'merge', + org_targets: null, + files_to_sync: null + } + + // Normalize org_targets + if (rule.org_targets) { + // New format already present + normalizedRule.org_targets = { + include: Array.isArray(rule.org_targets.include) ? rule.org_targets.include : [], + exclude: Array.isArray(rule.org_targets.exclude) ? rule.org_targets.exclude : [] + } + } else if (rule.targets) { + // Old format - convert to include list + normalizedRule.org_targets = { + include: Array.isArray(rule.targets) ? rule.targets : [], + exclude: [] + } + } else { + // No org targets specified - default to all + normalizedRule.org_targets = { + include: ['*'], + exclude: [] + } + } + + // Normalize files_to_sync + if (rule.files_to_sync) { + // New format already present + normalizedRule.files_to_sync = { + include: Array.isArray(rule.files_to_sync.include) ? rule.files_to_sync.include : [], + exclude: Array.isArray(rule.files_to_sync.exclude) ? rule.files_to_sync.exclude : [] + } + } else if (rule.files) { + // Old format - convert to include list + normalizedRule.files_to_sync = { + include: Array.isArray(rule.files) ? rule.files : [], + exclude: [] + } + } else { + // No files specified - default to all yml files + normalizedRule.files_to_sync = { + include: ['*.yml'], + exclude: [] + } + } + + normalized.rules.push(normalizedRule) + } + + return normalized +} + +/** + * Check if an organization name matches any rule's org_targets + * @param {string} orgName - Organization name to check + * @param {object} manifest - Normalized manifest object + * @param {object} logger - Logger instance + * @returns {boolean} True if org should be included in sync + */ +function matchesOrgTargets(orgName, manifest, logger) { + if (!manifest || !manifest.rules) { + // No manifest - default to allowing all orgs + return true + } + + // Check each rule - org is included if ANY rule includes it + for (const rule of manifest.rules) { + if (!rule.enabled) continue + + const { include, exclude } = rule.org_targets + + // Check if excluded first + const isExcluded = exclude.some(pattern => minimatch(orgName, pattern)) + if (isExcluded) { + logger.debug(`Org ${orgName} excluded by rule '${rule.name}' (pattern matched exclude list)`) + continue + } + + // Check if included + const isIncluded = include.some(pattern => minimatch(orgName, pattern)) + if (isIncluded) { + logger.debug(`Org ${orgName} included by rule '${rule.name}'`) + return true + } + } + + logger.info(`Org ${orgName} not matched by any manifest rule - excluding from sync`) + return false +} + +/** + * Check if a file path matches any rule's files_to_sync + * @param {string} filePath - Relative file path (e.g., 'settings.yml', 'repos/repo-x.yml') + * @param {object} manifest - Normalized manifest object + * @param {object} logger - Logger instance + * @returns {boolean} True if file should be synced + */ +function matchesFilesToSync(filePath, manifest, logger) { + if (!manifest || !manifest.rules) { + // No manifest - default to allowing all files + return true + } + + // Extract just the filename/path relative to the org or globals folder + const fileName = filePath.split('/').pop() + const relPath = filePath.includes('/') ? filePath.split('/').slice(-2).join('/') : fileName + + // Check each rule - file is synced if ANY rule includes it + for (const rule of manifest.rules) { + if (!rule.enabled) continue + + const { include, exclude } = rule.files_to_sync + + // Check if excluded first + const isExcluded = exclude.some(pattern => + minimatch(fileName, pattern) || minimatch(relPath, pattern) || minimatch(filePath, pattern) + ) + if (isExcluded) { + logger.debug(`File ${filePath} excluded by rule '${rule.name}'`) + continue + } + + // Check if included + const isIncluded = include.some(pattern => + minimatch(fileName, pattern) || minimatch(relPath, pattern) || minimatch(filePath, pattern) + ) + if (isIncluded) { + logger.debug(`File ${filePath} included by rule '${rule.name}'`) + return true + } + } + + logger.info(`File ${filePath} not matched by any manifest rule - excluding from sync`) + return false +} + +/** + * Check if an org update should proceed based on manifest rules + * @param {string} orgName - Organization name + * @param {string} filePath - File being synced + * @param {object} manifest - Normalized manifest + * @param {object} logger - Logger instance + * @returns {boolean} True if sync should proceed + */ +function shouldSyncOrgUpdate(orgName, filePath, manifest, logger) { + // Check both org and file filters + const orgMatches = matchesOrgTargets(orgName, manifest, logger) + const fileMatches = matchesFilesToSync(filePath, manifest, logger) + + const shouldSync = orgMatches && fileMatches + + if (!shouldSync) { + logger.info(`Skipping sync for ${orgName}/${filePath}: org_match=${orgMatches}, file_match=${fileMatches}`) + } + + return shouldSync +} + +/** + * Filter a list of organizations by manifest rules + * @param {string[]} orgNames - List of organization names + * @param {object} manifest - Normalized manifest + * @param {object} logger - Logger instance + * @returns {string[]} Filtered list of organizations + */ +function filterOrgsByManifest(orgNames, manifest, logger) { + if (!manifest) { + return orgNames + } + + return orgNames.filter(orgName => matchesOrgTargets(orgName, manifest, logger)) +} + +// ============================================================================ +// END MANIFEST-BASED FILTERING FUNCTIONS +// ============================================================================ + +/** + * Sync changed safe-settings organization files from the master admin PR + * into the target organization's admin repository. + * @param {import('probot').Probot} robot + * @param {import('probot').Context} context + * @param {string} orgName Destination organization login (also folder name under organizations/) + * @param {string} destRepo Destination repo name inside orgName (e.g. admin repo) + * @param {string} destinationFolder Base folder in destination repo where content lives (e.g. .github or .github/safe-settings) + */ +async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationFolder) { + attachFileLogger(robot) + try { + robot.log.info(`Syncing safe settings for organization: ${orgName}`) + robot.log.info(`Organization: ${orgName}, Destination Repo: ${destRepo}, Destination Folder: ${destinationFolder}`) + const pr = context.payload.pull_request + if (!pr) { + robot.log.warn('No pull_request payload found; aborting sync') + return + } + const { owner: srcOwner, repo: srcRepo } = context.repo() + const pull_number = pr.number + const configRoot = env.CONFIG_PATH || '.github/' + const sourceBase = (`${configRoot}/${env.SAFE_SETTINGS_HUB_PATH}/organizations`).replace(/\/$/, '') + robot.log.debug(`sourceBase='${sourceBase}'`) + robot.log.debug(`env.CONFIG_PATH='${env.CONFIG_PATH}', env.SAFE_SETTINGS_HUB_PATH='${env.SAFE_SETTINGS_HUB_PATH}'`) + const files = await context.octokit.paginate( + context.octokit.rest.pulls.listFiles, + { owner: srcOwner, repo: srcRepo, pull_number, per_page: 100 } + ) + robot.log.debug(`PR #${pull_number} contains ${files.length} changed file(s)`) + if (files.length) robot.log.debug(`files=${files.map(f => f.filename).join(', ')}`) + if (files.length) { + try { + robot.log.debug(`first file object = ${JSON.stringify(files[0], null, 2)}`) + robot.log.debug(`file[0] keys = ${Object.keys(files[0] || {}).join(', ')}`) + } catch (e) { + robot.log.debug(`failed to stringify first file: ${e.message}`) + } + files.forEach((f, i) => { + try { + robot.log.debug(`FILE[${i}] raw=${JSON.stringify(f)}`) + robot.log.debug(`FILE[${i}] filename=${JSON.stringify(f.filename)} length=${(f.filename || '').length}`) + } catch (e) { + robot.log.debug(`FILE[${i}] stringify error: ${e.message}`) + } + }) + } + const orgPrefix = `${sourceBase}/${orgName}/` + robot.log.debug(`files=${files.map(f => f.filename).join(', ')}`) + robot.log.debug(`Path ${sourceBase}/${orgName}`) + const relevant = files.filter(f => f.filename === `${sourceBase}/${orgName}` || f.filename.startsWith(orgPrefix)) + robot.log.debug(`Found ${relevant.length} changed file(s) relevant to org ${orgName}`) + if (!relevant.length) { + robot.log.info(`No files for org ${orgName} in PR #${pull_number}`) + files.forEach(f => { + const exact = f.filename === `${sourceBase}/${orgName}` + const pref = f.filename.startsWith(orgPrefix) + robot.log.info(`MATCH CHECK: file='${f.filename}' exact=${exact} prefix=${pref}`) + }) + const altBase = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/organizations` + const altPrefix = `${altBase}/${orgName}/` + files.forEach(f => { + const exactAlt = f.filename === `${altBase}/${orgName}` + const prefAlt = f.filename.startsWith(altPrefix) + robot.log.info(`ALT CHECK: file='${f.filename}' exactAlt=${exactAlt} prefAlt=${prefAlt}`) + }) + return + } + + // Load manifest and check if this org/files should be synced + const manifest = await loadManifest(context.octokit, pr.head.sha, robot.log) + + // Check if org is allowed by manifest + if (!matchesOrgTargets(orgName, manifest, robot.log)) { + robot.log.info(`Organization ${orgName} excluded by manifest rules - skipping sync`) + return + } + + // Filter files based on manifest rules + const filteredFiles = relevant.filter(f => { + const relativePath = f.filename.replace(orgPrefix, '') + return matchesFilesToSync(relativePath, manifest, robot.log) + }) + + if (!filteredFiles.length) { + robot.log.info(`All ${relevant.length} file(s) for org ${orgName} were excluded by manifest rules - skipping sync`) + return + } + + robot.log.info(`After manifest filtering: ${filteredFiles.length} of ${relevant.length} file(s) will be synced`) + + const destOwner = orgName + const destBase = (destinationFolder || env.CONFIG_PATH || '.github').replace(/\/$/, '') + const destBaseBranch = 'main' + const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') + const githubDest = await getOrgInstallation(robot, destOwner) + if (!githubDest) { + robot.log.warn(`Installation for destination org ${destOwner} not found; cannot sync`) + return + } + robot.log.info(`Syncing from ${srcOwner}/${srcRepo} PR #${pull_number} to ${destOwner}/${destRepo}@${destBaseBranch} under ${destBase} (directPush=${directPush})`) + const timestamp = Date.now() + const branchName = directPush ? destBaseBranch : `safe-settings-sync/pr-${pull_number}-${orgName}-${timestamp}` + await createBranchIfNeeded(githubDest, destOwner, destRepo, destBaseBranch, branchName, directPush, robot.log) + // Check if settings.yml is being synced - if so, use merge logic + const settingsFileChanged = filteredFiles.some(f => f.filename.endsWith('/settings.yml')) + + if (settingsFileChanged) { + robot.log.info(`Detected settings.yml change - will merge with globals`) + + // Load and merge settings (in-memory only) + const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, pr.head.sha) + + if (mergedContent) { + const destPath = `${destBase}/settings.yml`.replace(/\/+/g, '/') + const encoded = Buffer.from(mergedContent, 'utf8').toString('base64') + + let existingSha + try { + const destGet = await githubDest.rest.repos.getContent({ owner: destOwner, repo: destRepo, path: destPath, ref: destBaseBranch }) + if (!Array.isArray(destGet.data)) existingSha = destGet.data.sha + } catch (getErr) { + if (getErr.status !== 404) throw getErr + } + + await createOrUpdateFile(githubDest, { + owner: destOwner, + repo: destRepo, + path: destPath, + message: directPush ? `Direct sync safe-settings (merged) from ${srcOwner}/${srcRepo} PR #${pull_number}` : `Sync safe-settings (merged) from ${srcOwner}/${srcRepo} PR #${pull_number}`, + content: encoded, + branch: branchName, + sha: existingSha, + committer: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' }, + author: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' } + }, robot.log) + + robot.log.info(`Successfully synced merged settings.yml to ${destOwner}/${destRepo}`) + } + } + + // Sync other files (non-settings.yml) without merging + for (const f of filteredFiles) { + let relative + if (f.filename === `${sourceBase}/${orgName}`) { + continue + } else { + relative = f.filename.slice(orgPrefix.length) + } + + // Skip settings.yml - already handled with merge above + if (relative === 'settings.yml') { + continue + } + + const destPath = `${destBase}/${relative}`.replace(/\/+/g, '/') + const srcContentResp = await context.octokit.rest.repos.getContent({ owner: srcOwner, repo: srcRepo, path: f.filename, ref: pr.head.sha }) + const data = srcContentResp.data + if (Array.isArray(data)) { + continue + } + const fileContent = Buffer.from(data.content, data.encoding).toString('utf8') + const encoded = Buffer.from(fileContent, 'utf8').toString('base64') + let existingSha + try { + const destGet = await githubDest.rest.repos.getContent({ owner: destOwner, repo: destRepo, path: destPath, ref: destBaseBranch }) + if (!Array.isArray(destGet.data)) existingSha = destGet.data.sha + } catch (getErr) { + if (getErr.status !== 404) throw getErr + } + await createOrUpdateFile(githubDest, { + owner: destOwner, + repo: destRepo, + path: destPath, + message: directPush ? `Direct sync safe-settings from ${srcOwner}/${srcRepo} PR #${pull_number}` : `Sync safe-settings from ${srcOwner}/${srcRepo} PR #${pull_number}`, + content: encoded, + branch: branchName, + sha: existingSha, + committer: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' }, + author: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' } + }, robot.log) + } + if (!directPush) { + try { + const prTitle = `Sync safe-settings from ${srcOwner}/${srcRepo} PR #${pull_number}` + const prBody = `Automated sync of safe-settings for ${orgName} from ${srcOwner}/${srcRepo} PR #${pull_number}.` + const created = await githubDest.rest.pulls.create({ owner: destOwner, repo: destRepo, title: prTitle, head: branchName, base: destBaseBranch, body: prBody }) + robot.log.info(`Created PR ${created.data.html_url} in ${destOwner}/${destRepo}`) + } catch (prErr) { + robot.log.error(`Failed to create PR in ${destOwner}/${destRepo}: ${prErr.message}`) + throw prErr + } + } else { + robot.log.info(`Changes pushed directly to ${destOwner}/${destRepo}@${destBaseBranch}`) + } + } catch (err) { + robot.log.error(`syncSafeSettingConfig error for org ${orgName}: ${err.message}`) + } +} + +/** + * Handle closed pull requests to sync safe-settings changes to target organizations. + * Focus on the organization and repository specified in the pull request and if they belong to the Safe-Settings Hub. + * @param {import('probot').Probot} robot + * @param {import('probot').Context} context + */ +async function hubSyncHandler (robot, context) { + attachFileLogger(robot) + const { payload } = context + const { repository, pull_request } = payload || {} + robot.log.info(`Received 'pull_request.closed' event: ${pull_request && pull_request.number}`) + try { + // Ensure the event is from the configured Safe-Settings Hub repo/org + const isMasterRepo = repository && repository.name === env.SAFE_SETTINGS_HUB_REPO + const isMasterOrg = repository && repository.owner && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + + if (!(isMasterRepo && isMasterOrg)) { + robot.log.info(`Pull request.closed is not from master admin repo/org (${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO}), ignoring`) + return + } + + robot.log.info(`Pull request closed on Safe-Settings Hub: (${repository.full_name})`) + + // Get the PR details + const pr = pull_request + const { owner, repo } = context.repo() + const pull_number = pr.number + + // Paginate through all files changed in the PR + const files = await context.octokit.paginate( + context.octokit.rest.pulls.listFiles, + { owner, repo, pull_number, per_page: 100 } + ) + + robot.log.info(`Files changed in PR #${pull_number}: ${files.map(f => f.filename).join(', ')}`) + + // Routing logic: check for 'globals' or 'organizations' folder changes + const globalsChanged = files.some(f => /\/globals\//.test(f.filename)) + const orgsChanged = files.some(f => /\/organizations\//.test(f.filename)) + + if (globalsChanged) { + robot.log.debug('Detected changes in the globals folder. Routing to syncHubGlobalsUpdate(...).') + await module.exports.syncHubGlobalsUpdate(robot, context, files) + } + + if (orgsChanged) { + robot.log.debug('Detected changes in the organizations folder. Routing to syncHubOrgUpdate(...).') + // Only sync updates in organization subfolders, not files directly in organizations folder + const baseSettingsPath = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}/organizations` + const normalizedBase = baseSettingsPath.replace(/\/$/, '') + const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Only match files in org subfolders: .../organizations//... + const orgSubfolderPattern = new RegExp(`^${escapeRegex(normalizedBase)}/([^/]+)/.+`) + const orgNamesSet = new Set() + files.forEach(f => { + const m = f.filename.match(orgSubfolderPattern) + if (m && m[1]) { + orgNamesSet.add(m[1]) + } + }) + const orgNames = Array.from(orgNamesSet) + robot.log.info(`Orgs updated in PR #${pull_number}: ${orgNames.join(', ')}`) + for (const orgName of orgNames) { + const destRepo = env.ADMIN_REPO + const destinationFolder = env.CONFIG_PATH || '.github' + await module.exports.syncHubOrgUpdate(robot, context, orgName, destRepo, destinationFolder) + } + } + } catch (err) { + robot.log.error(`Failed to sync safe settings: ${err && err.message ? err.message : err}`) + } +} + +/** + * Handle updates in the globals folder and sync to destinations defined in manifest.yml rules + * @param {import('probot').Probot} robot + * @param {import('probot').Context} context + * @param {Array} files - Array of changed file objects from PR + */ +async function syncHubGlobalsUpdate (robot, context, files) { + attachFileLogger(robot) + robot.log.info(`Syncing safe settings for 'globals/'.`) + + // Get PR head SHA for loading manifest and files + const pr = context.payload.pull_request + const prHeadSha = pr ? pr.head.sha : 'main' + + // Load manifest with new filtering logic + const manifest = await loadManifest(context.octokit, prHeadSha, robot.log) + + const changedGlobals = files.filter(f => /\/globals\//.test(f.filename)) + if (!changedGlobals.length) { + robot.log.info('No changed files in globals folder.') + return + } + + // Get all org installations + const installs = await getInstallations(robot) + const allOrgLogins = installs.filter(i => i.account && i.account.type === 'Organization').map(i => i.account.login) + + // Filter orgs by manifest rules + const filteredOrgLogins = filterOrgsByManifest(allOrgLogins, manifest, robot.log) + robot.log.info(`After manifest filtering: ${filteredOrgLogins.length} of ${allOrgLogins.length} org(s) will receive updates`) + + if (!filteredOrgLogins.length) { + robot.log.info('No organizations match manifest rules - skipping globals sync') + return + } + + for (const fileObj of changedGlobals) { + const fileName = fileObj.filename.split('/').pop() + if (fileName === 'manifest.yml') { + robot.log.debug(`Skipping sync for manifest.yml (should only exist in hub)`) + continue + } + + // Check if file should be synced according to manifest + if (!matchesFilesToSync(fileName, manifest, robot.log)) { + robot.log.info(`File ${fileName} excluded by manifest rules - skipping`) + continue + } + + robot.log.debug(`Evaluating globals file: ${fileObj.filename}`) + + // Special handling for settings.yml - merge with each org's settings + if (fileName === 'settings.yml') { + robot.log.info(`Detected globals/settings.yml change - will merge with all org-specific settings.yml`) + + // Get all organizations from the organizations/ directory + const orgsPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/organizations` + let orgDirs = [] + try { + const orgsResp = await context.octokit.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: orgsPath, + ref: prHeadSha + }) + if (Array.isArray(orgsResp.data)) { + orgDirs = orgsResp.data.filter(item => item.type === 'dir').map(item => item.name) + + // Filter orgDirs by manifest rules + orgDirs = filterOrgsByManifest(orgDirs, manifest, robot.log) + + robot.log.info(`Found ${orgDirs.length} organizations to sync (after manifest filtering): ${orgDirs.join(', ')}`) + } + } catch (err) { + robot.log.warn(`Could not list organizations directory: ${err.message}`) + } + + // Merge and sync settings.yml for each org + for (const orgName of orgDirs) { + robot.log.info(`Processing merge for org: ${orgName}`) + + const destRepo = env.ADMIN_REPO + const githubDest = await getOrgInstallation(robot, orgName) + if (!githubDest) { + robot.log.info(`Skipping org ${orgName}: no installation found.`) + continue + } + + // Check if destination repo exists + let repoExists = false + try { + await githubDest.rest.repos.get({ owner: orgName, repo: destRepo }) + repoExists = true + } catch (err) { + if (err.status === 404) { + robot.log.info(`Skipping org ${orgName}: config repo '${destRepo}' does not exist.`) + continue + } else { + throw err + } + } + + if (!repoExists) continue + + // Load and merge settings + const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, prHeadSha) + if (!mergedContent) { + robot.log.info(`No settings content to sync for org ${orgName}`) + continue + } + + const destPath = `${env.CONFIG_PATH}/settings.yml` + const encoded = Buffer.from(mergedContent, 'utf8').toString('base64') + + let existingSha + try { + const destGet = await githubDest.rest.repos.getContent({ + owner: orgName, + repo: destRepo, + path: destPath, + ref: 'main' + }) + if (!Array.isArray(destGet.data)) { + existingSha = destGet.data.sha + } + } catch (err) { + if (err.status !== 404) { + robot.log.warn(`Error checking existing file: ${err.message}`) + } + } + + // Determine push strategy + const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') + const timestamp = Date.now() + const branchName = directPush ? 'main' : `safe-settings-sync/globals-${timestamp}` + + if (!directPush) { + try { + const baseRef = await githubDest.rest.git.getRef({ owner: orgName, repo: destRepo, ref: 'heads/main' }) + const baseSha = baseRef.data.object.sha + await githubDest.rest.git.createRef({ owner: orgName, repo: destRepo, ref: `refs/heads/${branchName}`, sha: baseSha }) + robot.log.info(`Created branch ${branchName} in ${orgName}/${destRepo}`) + } catch (err) { + if (err.status === 422) { + robot.log.warn(`Branch ${branchName} already exists, continuing`) + } else { + throw err + } + } + } + + // Commit merged file + try { + await githubDest.rest.repos.createOrUpdateFileContents({ + owner: orgName, + repo: destRepo, + path: destPath, + message: `Sync merged settings.yml from globals update (PR #${pr.number})`, + content: encoded, + branch: branchName, + sha: existingSha, + committer: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' }, + author: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' } + }) + robot.log.info(`Committed merged settings.yml to ${orgName}/${destRepo}@${branchName}`) + + // Create PR if not direct push + if (!directPush) { + try { + const prTitle = `Sync settings.yml from globals update` + const prBody = `Automated sync of merged settings.yml from globals/settings.yml update.\n\nThis PR contains the merged configuration from globals and org-specific settings.` + const created = await githubDest.rest.pulls.create({ + owner: orgName, + repo: destRepo, + title: prTitle, + head: branchName, + base: 'main', + body: prBody + }) + robot.log.info(`Created PR ${created.data.html_url} in ${orgName}/${destRepo}`) + } catch (prErr) { + robot.log.error(`Failed to create PR in ${orgName}/${destRepo}: ${prErr.message}`) + } + } + } catch (commitErr) { + robot.log.error(`Failed to commit to ${orgName}/${destRepo}: ${commitErr.message}`) + } + } + + // Skip the regular sync logic for settings.yml + continue + } + + // For non-settings.yml files, sync to filtered orgs + robot.log.debug(`Syncing ${fileName} to ${filteredOrgLogins.length} organization(s)`) + + for (const orgName of filteredOrgLogins) { + robot.log.debug(`Preparing to sync file '${fileName}' to org '${orgName}'`) + const destRepo = env.ADMIN_REPO + const githubDest = await getOrgInstallation(robot, orgName) + if (!githubDest) { + robot.log.info(`Skipping org ${orgName}: no installation found.`) + continue + } + + // Check if destination repo exists + let repoExists = false + try { + await githubDest.rest.repos.get({ owner: orgName, repo: destRepo }) + repoExists = true + } catch (err) { + if (err.status === 404) { + robot.log.info(`Skipping org ${orgName}: config repo '${destRepo}' does not exist.`) + continue + } else { + throw err + } + } + + if (!repoExists) continue + + const destPath = `${env.CONFIG_PATH}/${fileName}` + let exists = false + let existingSha = undefined + try { + robot.log.debug(`Checking existence of ${destPath} in ${orgName}/${destRepo}`) + const resp = await githubDest.rest.repos.getContent({ + owner: orgName, + repo: destRepo, + path: destPath, + ref: 'main' + }); + if (!Array.isArray(resp.data)) { + robot.log.debug(`Found ${destPath} in ${orgName}/${destRepo}`); + exists = true; + existingSha = resp.data.sha; + } + } catch (err) { + if (err.status === 404) { + robot.log.info(`File ${destPath} not found in ${orgName}/${destRepo} (this is fine for both merge strategies)`); + exists = false; + existingSha = undefined; + } else { + robot.log.error(`Error checking ${destPath} in ${orgName}/${destRepo}: ${err.message}`) + throw err + } + } + + // Sync the file to this org + robot.log.info(`Syncing ${fileName} to ${orgName}`) + try { + let srcContentResp + const srcRef = prHeadSha + srcContentResp = await context.octokit.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: fileObj.filename, + ref: srcRef + }) + const data = srcContentResp.data + if (Array.isArray(data)) { + robot.log.debug(`Skipping directory ${fileObj.filename}`) + continue + } + const fileContent = Buffer.from(data.content, data.encoding).toString('utf8') + const encoded = Buffer.from(fileContent, 'utf8').toString('base64') + const destBaseBranch = 'main' + const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') + const timestamp = Date.now() + const branchName = directPush ? destBaseBranch : `safe-settings-globals-sync/${orgName}-${fileName}-${timestamp}` + await createBranchIfNeeded(githubDest, orgName, destRepo, destBaseBranch, branchName, directPush, robot.log) + await createOrUpdateFile(githubDest, { + owner: orgName, + repo: destRepo, + path: destPath, + message: directPush ? `Direct sync globals file '${fileName}' from hub` : `Sync globals file '${fileName}' from hub`, + content: encoded, + branch: branchName, + sha: exists ? existingSha : undefined, + committer: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' }, + author: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' } + }, robot.log) + if (!directPush) { + try { + const prTitle = `Sync globals file '${fileName}' from hub` + const prBody = `Automated sync of globals file '${fileName}' from hub to ${orgName}.` + const created = await githubDest.rest.pulls.create({ owner: orgName, repo: destRepo, title: prTitle, head: branchName, base: destBaseBranch, body: prBody }) + robot.log.info(`Created PR ${created.data.html_url} in ${orgName}/${destRepo}`) + } catch (prErr) { + robot.log.error(`Failed to create PR in ${orgName}/${destRepo}: ${prErr.message}`) + throw prErr + } + } else { + robot.log.info(`Changes pushed directly to ${orgName}/${destRepo}@${destBaseBranch}`) + } + } catch (syncErr) { + robot.log.error(`Failed to sync globals file ${fileName} to ${orgName}: ${syncErr.message}`) + } + } + } +} + +/** + * Retrieve settings files from remote organization admin repositories, + * commit them into a branch in the hub repository, and open a pull request. + * @param {import('probot').Probot} robot + * @param {Array} orgNames Array of organization names to retrieve settings from + * @param {Object} options Options for the operation + * @param {string} options.baseBranch Base branch to create new branches from (default: 'main') + * @returns {Promise>} Results of the operation for each organization + */ +async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { + attachFileLogger(robot) + const results = [] + try { + if (!Array.isArray(orgNames) || orgNames.length === 0) return results + + const installs = await getInstallations(robot) + + const hubOwnerLogin = (env.SAFE_SETTINGS_HUB_ORG || '').toLowerCase() + const hubRepoName = env.SAFE_SETTINGS_HUB_REPO + if (!hubOwnerLogin || !hubRepoName) { + throw new Error('SAFE_SETTINGS_HUB_ORG and SAFE_SETTINGS_HUB_REPO must be configured') + } + + const hubInstall = installs.find(i => i.account && i.account.login && i.account.login.toLowerCase() === hubOwnerLogin) + if (!hubInstall) throw new Error(`Installation for hub org ${env.SAFE_SETTINGS_HUB_ORG} not found`) + + const githubHub = await robot.auth(hubInstall.id) + const baseBranch = options.baseBranch || 'main' + + // Read REIMPORT mode from environment variable (default: false for safe mode) + const allowReimport = (env.SAFE_SETTINGS_HUB_REIMPORT || 'false').toLowerCase() === 'true' + robot.log.info(`Import mode: REIMPORT=${allowReimport}`) + + // Resolve the base sha for creating branches + let baseRef, baseSha + try { + baseRef = await githubHub.rest.git.getRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `heads/${baseBranch}` }) + baseSha = baseRef.data && baseRef.data.object && baseRef.data.object.sha + } catch (refErr) { + if (refErr && refErr.status === 404) { + // Hub repo doesn't exist - return N/A for all requested orgs + robot.log.warn(`Hub repository ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName} or branch '${baseBranch}' not found`) + return orgNames.map(org => ({ org, status: 'N/A', reason: `hub_repo_not_found: ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName}` })) + } + throw refErr + } + + // Helper: collect all files under a path in a repo (recursively) + async function collectFilesFromRepo (githubClient, owner, repo, dirPath, ref = 'main') { + const out = [] + + // First verify the repo exists by checking for the ref + try { + await githubClient.rest.git.getRef({ owner, repo, ref: `heads/${ref}` }) + } catch (repoCheckErr) { + if (repoCheckErr && repoCheckErr.status === 404) { + const err404 = new Error(`Repository ${owner}/${repo} or branch '${ref}' not found`) + err404.status = 404 + throw err404 + } + throw repoCheckErr + } + + async function walk (p) { + try { + const resp = await githubClient.rest.repos.getContent({ owner, repo, path: p, ref }) + const data = resp.data + if (Array.isArray(data)) { + for (const item of data) { + if (item.type === 'file') { + try { + const fileResp = await githubClient.rest.repos.getContent({ owner, repo, path: item.path, ref }) + if (!Array.isArray(fileResp.data) && typeof fileResp.data.content === 'string') { + const decoded = Buffer.from(fileResp.data.content, fileResp.data.encoding || 'base64').toString('utf8') + out.push({ path: fileResp.data.path, content: decoded }) + } + } catch (fe) { + // skip unreadable files, but log + robot.log && robot.log.warn && robot.log.warn(`collectFilesFromRepo: failed to fetch ${item.path} from ${owner}/${repo}: ${fe.message}`) + } + } else if (item.type === 'dir') { + await walk(item.path) + } else { + // skip other types (submodules, symlinks) + robot.log && robot.log.debug && robot.log.debug(`Skipping unsupported item type ${item.type} at ${item.path}`) + } + } + } else if (typeof data.content === 'string') { + const decoded = Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + out.push({ path: data.path, content: decoded }) + } + } catch (e) { + if (e && e.status === 404) { + // path does not exist on repo -> no files + return + } + throw e + } + } + await walk(dirPath) + return out + } + + // Iterate requested orgs and import their CONFIG_PATH into the hub repo under the organizations/ tree + for (const orgName of orgNames) { + try { + if (!orgName) { results.push({ org: orgName, error: 'invalid org name' }); continue } + robot.log.info(`Retrieving settings from org: ${orgName}`) + + // Existence check: skip if org folder already exists (only when REIMPORT=false) + let isReimport = false + if (!allowReimport) { + try { + const destOrgPath = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${orgName}` + try { + const destCheck = await githubHub.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, path: destOrgPath, ref: baseBranch }) + if (Array.isArray(destCheck.data) && destCheck.data.length > 0) { + robot.log.info(`Skipping ${orgName}: already present in hub (REIMPORT=false)`) + results.push({ org: orgName, status: 'skipped', reason: 'already_imported' }) + continue + } + } catch (probeErr) { + if (!(probeErr && probeErr.status === 404)) { + robot.log && robot.log.warn && robot.log.warn(`Failed to probe hub destination for ${orgName}: ${probeErr.message}`) + results.push({ org: orgName, error: `failed to check destination: ${probeErr.message}` }) + continue + } + // 404 -> not present, proceed with import + } + } catch (e) { + robot.log && robot.log.warn && robot.log.warn(`Unexpected error while probing destination for ${orgName}: ${e.message}`) + results.push({ org: orgName, error: `probe error: ${e.message}` }) + continue + } + } else { + // REIMPORT=true: Check if content exists to distinguish import vs reimport in results + try { + const destOrgPath = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${orgName}` + const destCheck = await githubHub.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, path: destOrgPath, ref: baseBranch }) + if (Array.isArray(destCheck.data) && destCheck.data.length > 0) { + isReimport = true + robot.log.info(`Re-importing ${orgName}: REIMPORT=true, will update existing files`) + } + } catch (probeErr) { + // 404 or other error -> treat as first import + if (probeErr && probeErr.status === 404) { + robot.log.info(`First import for ${orgName}: destination not found`) + } + } + } + + const srcInstall = installs.find(i => i.account && i.account.login && i.account.login.toLowerCase() === orgName.toLowerCase()) + if (!srcInstall) { + results.push({ org: orgName, error: 'installation not found for org' }) + continue + } + + const githubSrc = await robot.auth(srcInstall.id) + const adminRepo = env.ADMIN_REPO + if (!adminRepo) { + results.push({ org: orgName, error: 'ADMIN_REPO is not configured' }) + continue + } + + const sourceBase = (env.CONFIG_PATH || '.github').replace(/\/$/, '') + // collect files from the source admin repo under CONFIG_PATH + let files + try { + files = await collectFilesFromRepo(githubSrc, orgName, adminRepo, sourceBase, 'main') + } catch (collectErr) { + if (collectErr && collectErr.status === 404) { + robot.log.info(`Skipping ${orgName}: admin repo '${adminRepo}' not found`) + results.push({ org: orgName, status: 'N/A', reason: `admin_repo_not_found: ${adminRepo}` }) + continue + } + throw collectErr + } + + if (!files || files.length === 0) { + results.push({ org: orgName, status: 'N/A', reason: 'no_files_at_config_path' }) + continue + } + + const timestamp = Date.now() + const branchName = `safe-settings-import/${orgName}/${timestamp}`.replace(/[^a-zA-Z0-9_\-./]/g, '-') + + // Instead of creating/updating files one-by-one, build a single tree and commit so the PR contains all files atomically + try { + const treeEntries = [] + for (const f of files) { + // relative path under the sourceBase + const rel = path.posix.relative(sourceBase, f.path) + // Destination should be: CONFIG_PATH/SAFE_SETTINGS_HUB_PATH/organizations// + const destBase = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}` + const destPath = path.posix.join(destBase, 'organizations', orgName, rel).replace(/\/+/g, '/') + treeEntries.push({ path: destPath, mode: '100644', type: 'blob', content: f.content }) + } + + // Get base commit and tree + const baseCommitResp = await githubHub.rest.git.getCommit({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, commit_sha: baseSha }) + const baseTreeSha = baseCommitResp.data && baseCommitResp.data.tree && baseCommitResp.data.tree.sha + + // Create a new tree rooted at the base tree + const createdTree = await githubHub.rest.git.createTree({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, tree: treeEntries, base_tree: baseTreeSha }) + + if (createdTree.data.sha === baseTreeSha) { + robot.log.info(`Skipping ${orgName}: imported settings match the hub base branch`) + results.push({ org: orgName, status: 'skipped', reason: 'no_changes' }) + continue + } + + // Create the branch only after confirming that the imported tree differs from the base tree + try { + await githubHub.rest.git.createRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `refs/heads/${branchName}`, sha: baseSha }) + } catch (createErr) { + if (createErr && createErr.status === 422) { + robot.log.info(`Branch ${branchName} already exists, continuing`) + } else { + throw createErr + } + } + + // Create a commit that points to the new tree + const commitMessage = `Import safe-settings from ${orgName}` + const newCommit = await githubHub.rest.git.createCommit({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, message: commitMessage, tree: createdTree.data.sha, parents: [baseSha] }) + + // Update the branch ref to point to the new commit + await githubHub.rest.git.updateRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `heads/${branchName}`, sha: newCommit.data.sha }) + + robot.log.info(`Created commit ${newCommit.data.sha} on ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName}@${branchName} with ${treeEntries.length} files (mode: ${isReimport ? 'reimport' : 'initial'})`) + } catch (commitErr) { + robot.log.error(`Failed to create commit tree for ${orgName}: ${commitErr && commitErr.message ? commitErr.message : commitErr}`) + results.push({ org: orgName, error: `failed to commit files: ${commitErr && commitErr.message ? commitErr.message : String(commitErr)}` }) + continue + } + + // Create a PR in the hub repo for this branch + try { + const prTitle = isReimport ? `Re-import safe-settings from ${orgName}` : `Import safe-settings from ${orgName}` + const prBody = isReimport + ? `Automated re-import of settings from ${orgName} admin repo (${adminRepo}) into the hub.\n\n**Mode:** REIMPORT=true (reimport enabled)\n**Previous import detected:** Files will be updated with current org configuration.` + : `Automated import of settings from ${orgName} admin repo (${adminRepo}) into the hub.` + const created = await githubHub.rest.pulls.create({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, title: prTitle, head: branchName, base: baseBranch, body: prBody }) + results.push({ org: orgName, status: isReimport ? 'reimported' : 'imported', pr: created.data && created.data.html_url }) + robot.log.info(`Created PR ${created.data && created.data.html_url} for ${orgName}`) + } catch (prErr) { + robot.log.error(`Failed to create PR for ${orgName}: ${prErr && prErr.message ? prErr.message : prErr}`) + results.push({ org: orgName, error: `failed to create PR: ${prErr && prErr.message ? prErr.message : String(prErr)}` }) + } + } catch (errInner) { + robot.log.error(`Error importing settings for org ${orgName}: ${errInner && errInner.message ? errInner.message : errInner}`) + results.push({ org: orgName, error: errInner && errInner.message ? errInner.message : String(errInner) }) + } + } + + return results + } catch (err) { + robot.log.error(`retrieveSettingsFromOrgs error: ${err && err.message ? err.message : err}`) + throw err + } +} + +// Properties used to identify matching items in arrays +const NAME_FIELDS = ['name', 'username', 'actor_id', 'login', 'type', 'key_prefix', 'context'] + +/** + * Load and merge global settings with org-specific settings (in-memory only). + * Returns merged YAML string ready to be pushed to target repo. + * Source files in the hub repo are NEVER modified. + * + * @param {import('probot').Probot} robot - Robot instance for logging + * @param {import('@octokit/rest').Octokit} githubHub - Authenticated GitHub client for hub repo + * @param {string} orgName - Organization name + * @param {string} prHeadSha - PR head SHA to read files from + * @returns {Promise} Merged YAML content or null if both files missing + */ +async function loadAndMergeSettings (robot, githubHub, orgName, prHeadSha) { + const configRoot = env.CONFIG_PATH || '.github' + const globalsPath = `${configRoot}/${env.SAFE_SETTINGS_HUB_PATH}/globals/settings.yml` + const orgPath = `${configRoot}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${orgName}/settings.yml` + + let globalsContent = '' + let orgContent = '' + + // Load globals/settings.yml (if exists) + try { + const globalsResp = await githubHub.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: globalsPath, + ref: prHeadSha + }) + if (!Array.isArray(globalsResp.data)) { + globalsContent = Buffer.from(globalsResp.data.content, globalsResp.data.encoding).toString('utf8') + robot.log.debug(`Loaded globals/settings.yml for merge (${globalsContent.length} bytes)`) + } + } catch (err) { + if (err.status === 404) { + robot.log.debug(`globals/settings.yml not found (this is OK)`) + } else { + robot.log.warn(`Error loading globals/settings.yml: ${err.message}`) + } + } + + // Load organizations//settings.yml (if exists) + try { + const orgResp = await githubHub.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: orgPath, + ref: prHeadSha + }) + if (!Array.isArray(orgResp.data)) { + orgContent = Buffer.from(orgResp.data.content, orgResp.data.encoding).toString('utf8') + robot.log.debug(`Loaded organizations/${orgName}/settings.yml for merge (${orgContent.length} bytes)`) + } + } catch (err) { + if (err.status === 404) { + robot.log.debug(`organizations/${orgName}/settings.yml not found (this is OK)`) + } else { + robot.log.warn(`Error loading organizations/${orgName}/settings.yml: ${err.message}`) + } + } + + // If both are empty, return null + if (!globalsContent && !orgContent) { + robot.log.info(`No settings.yml found in globals or organizations/${orgName}`) + return null + } + + // If only one exists, return it with header + if (!globalsContent) { + robot.log.info(`Using pure org settings (no globals found)`) + const header = `# Auto-generated by Safe-Settings Hub-Sync +# Source: organizations/${orgName}/settings.yml only +# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} + +` + return header + orgContent + } + if (!orgContent) { + robot.log.info(`Using pure globals (no org-specific settings found)`) + const header = `# Auto-generated by Safe-Settings Hub-Sync +# Source: globals/settings.yml only +# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} + +` + return header + globalsContent + } + + // Both exist - merge them (org takes precedence) + robot.log.info(`Merging globals with organizations/${orgName}/settings.yml (org takes precedence)`) + try { + const merged = mergeConfigs(globalsContent, orgContent, false) // false = smart merge, org overrides globals + const mergedYaml = yaml.dump(merged, { lineWidth: -1 }) + + // Add header comment indicating auto-generation + const header = `# Auto-generated by Safe-Settings Hub-Sync +# Merged from globals/settings.yml + organizations/${orgName}/settings.yml +# Organization settings take precedence over globals +# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} + +` + + return header + mergedYaml + } catch (mergeErr) { + robot.log.error(`Failed to merge settings: ${mergeErr.message}`) + robot.log.error(`Error stack: ${mergeErr.stack}`) + + // Fallback to org-only if merge fails (only during sync, not validation) + robot.log.warn(`Falling back to org-specific settings only`) + const header = `# Auto-generated by Safe-Settings Hub-Sync (merge failed, using org only) +# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} + +` + return header + orgContent + } +} + +/** + * Merge two JSON/YAML configuration strings at the object level. + * + * @param {string} json1 - First JSON/YAML content string (base) + * @param {string} json2 - Second JSON/YAML content string (overlay - takes precedence) + * @param {boolean} replaceArrays - If true (default), arrays in json2 replace arrays in json1. + * If false, arrays are merged intelligently: + * - Simple arrays: deduplicated (no duplicates added) + * - Object arrays: matched by name/username/etc and merged + * @returns {Object} Merged configuration object + * + * @example + * const json1 = ` + * teams: + * - team-a + * - team-b + * ` + * const json2 = ` + * teams: + * - team-b + * - team-c + * ` + * + * // Replace mode (default): result = { teams: ['team-b', 'team-c'] } + * const replaced = mergeConfigs(json1, json2) + * // or explicitly: mergeConfigs(json1, json2, true) + * + * // Smart merge mode: result = { teams: ['team-a', 'team-b', 'team-c'] } + * const smartMerge = mergeConfigs(json1, json2, false) + * mergeConfigs + * @example + * // Smart merge with objects + * const json1 = ` + * collaborators: + * - username: alice + * permission: push + * ` + * const json2 = ` + * collaborators: + * - username: alice + * permission: admin + * - username: bob + * permission: pull + * ` + * // Result: alice updated to admin, bob added + */ +function mergeConfigs (json1, json2, replaceArrays = true) { + // Parse input strings as YAML (which also handles JSON) + const obj1 = yaml.load(json1) || {} + const obj2 = yaml.load(json2) || {} + + // Perform the merge + return deepMerge(obj1, obj2, replaceArrays) +} + +/** + * Deep merge two objects with configurable array handling + * + * @param {*} target - Base object + * @param {*} source - Overlay object (takes precedence) + * @param {boolean} replaceArrays - Array merge strategy + * @returns {*} Merged result + */ +function deepMerge (target, source, replaceArrays) { + // Handle null/undefined + if (source === null || source === undefined) { + return target + } + if (target === null || target === undefined) { + return source + } + + // If source is not an object, it replaces target + if (typeof source !== 'object' || source === null) { + return source + } + + // Handle arrays + if (Array.isArray(source)) { + if (Array.isArray(target)) { + if (replaceArrays) { + // Replace: return only source array + return [...source] + } else { + // Smart merge: deduplicate primitives or merge objects by matching properties + return smartMergeArrays(target, source) + } + } + // Target is not an array, replace with source array + return [...source] + } + + // Handle objects (not arrays) + if (typeof target !== 'object' || target === null || Array.isArray(target)) { + // Target is not a plain object, replace with source + target = {} + } + + const result = { ...target } + + // Merge source properties into result + for (const key in source) { + // Skip prototype pollution + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue + } + + const sourceValue = source[key] + const targetValue = result[key] + + // Recursively merge if both are objects or arrays + if ( + sourceValue !== null && + typeof sourceValue === 'object' && + targetValue !== null && + typeof targetValue === 'object' + ) { + result[key] = deepMerge(targetValue, sourceValue, replaceArrays) + } else { + // For primitives, source overwrites target + result[key] = sourceValue + } + } + + return result +} + +/** + * Smart merge two arrays: deduplicates primitives, or matches objects by name/username/etc and deep-merges them. + * + * **Primitive arrays**: ['a', 'b'] + ['b', 'c'] → ['a', 'b', 'c'] + * + * **Object arrays** (via mergeBy): + * - Matches by NAME_FIELDS (name, username, actor_id, login, type, key_prefix, context) + * - Deep-merges matching objects (source properties override target, nested props preserved) + * - Appends non-matching source objects + * - Preserves target-only objects + * + * Example (labels): + * ``` + * target: [{ name: 'bug', color: 'red' }, { name: 'feature', color: 'blue' }] + * source: [{ name: 'bug', color: 'green' }, { name: 'docs', color: 'yellow' }] + * result: [{ name: 'bug', color: 'green' }, // updated + * { name: 'feature', color: 'blue' }, // preserved + * { name: 'docs', color: 'yellow' }] // added + * ``` + * + * @param {Array} target - Base array (primitives or objects) + * @param {Array} source - Overlay array (primitives or objects) + * @returns {Array} Merged array + */ +function smartMergeArrays (target, source) { + // Check if arrays contain objects or primitives + const hasObjects = source.some(item => item && typeof item === 'object' && !Array.isArray(item)) + + if (!hasObjects) { + // Primitives: deduplicate + const result = [...target] + source.forEach(item => { + if (!result.includes(item)) { + result.push(item) + } + }) + return result + } + + // Objects: match by NAME_FIELDS and deep-merge + // mergeBy(key, configvalidator, overridevalidator, properties, target, source, options, githubContext) + return mergeBy(null, null, null, NAME_FIELDS, target, source, undefined, undefined) +} + +/** + * Validates hub-sync master files and reports results via PR comments and check runs. + * This is the hub-sync equivalent of Settings.handleResults() - it validates YAML syntax, + * tests merges, and creates formatted PR comments. + * + * @param {import('probot').Probot} robot - Probot robot instance + * @param {import('probot').Context} context - GitHub context + * @param {object} payload - Webhook payload + * @param {object} pullRequest - Pull request object + * @param {string[]} changedFiles - Hub-sync files that changed + * @param {string} baseRef - Base branch ref + */ +async function validateAndReportHubSync (robot, context, payload, pullRequest, changedFiles, baseRef) { + robot.log.info(`Hub-sync validation: Processing ${changedFiles.length} file(s)`) + + const syntaxErrors = [] + const syntaxSuccesses = [] + const mergeErrors = [] + const mergeSuccesses = [] + const warnings = [] + + const hubPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}`.replace(/\/+/g, '/') + const settingsPattern = new RegExp(`^${hubPath}/(globals|organizations/[^/]+)/settings\\.ya?ml$`) + const orgsPattern = new RegExp(`^${hubPath}/organizations/([^/]+)/settings\\.ya?ml$`) + + const orgsWithSettingsChanges = new Set() + let globalsSettingsChanged = false + + // Load manifest for filtering + const manifest = await loadManifest(context.octokit, pullRequest.head.sha, robot.log) + + // Phase 1: YAML Syntax Validation - check all changed files + for (const filePath of changedFiles) { + robot.log.debug(`Validating YAML syntax: ${filePath}`) + + // Track if this is a settings.yml file (do this BEFORE validation so we track even if validation fails) + if (settingsPattern.test(filePath)) { + if (filePath.includes('/globals/settings.')) { + globalsSettingsChanged = true + } + const orgMatch = filePath.match(orgsPattern) + if (orgMatch && orgMatch[1]) { + orgsWithSettingsChanges.add(orgMatch[1]) + } + } + + try { + const fileContent = await context.octokit.rest.repos.getContent({ + owner: context.repo().owner, + repo: context.repo().repo, + path: filePath, + ref: pullRequest.head.sha + }) + + if (Array.isArray(fileContent.data)) { + syntaxErrors.push(`\`${filePath}\`: Expected a file but got a directory`) + continue + } + + if (!fileContent.data.content) { + syntaxErrors.push(`\`${filePath}\`: No content found`) + continue + } + + const decoded = Buffer.from(fileContent.data.content, 'base64').toString() + yaml.load(decoded) // Will throw on invalid YAML + syntaxSuccesses.push(`\`${filePath}\``) + } catch (e) { + syntaxErrors.push(`\`${filePath}\`: ${e.message}`) + } + } + + // Filter organizations by manifest rules + const allAffectedOrgs = Array.from(orgsWithSettingsChanges) + const filteredOrgs = filterOrgsByManifest(allAffectedOrgs, manifest, robot.log) + const excludedOrgs = allAffectedOrgs.filter(org => !filteredOrgs.includes(org)) + + // Filter files by manifest rules + const changedFilesList = changedFiles.map(f => { + const fileName = f.split('/').pop() + const willSync = matchesFilesToSync(fileName, manifest, robot.log) + return { path: f, fileName, willSync } + }) + const excludedFiles = changedFilesList.filter(f => !f.willSync) + + // Phase 2: Merged Configuration Validation - only for orgs with settings.yml changes + if (orgsWithSettingsChanges.size > 0) { + for (const orgName of orgsWithSettingsChanges) { + robot.log.debug(`Testing merge for organization: ${orgName}`) + try { + const mergedYaml = await loadAndMergeSettings( + robot, + context.octokit, + orgName, + pullRequest.head.sha + ) + + if (!mergedYaml) { + mergeErrors.push(`Organization \`${orgName}\` (settings.yml): Merge failed - no configuration found`) + continue + } + + // Parse and validate the merged YAML syntax + yaml.load(mergedYaml) + mergeSuccesses.push(`Organization \`${orgName}\` (settings.yml)`) + } catch (e) { + mergeErrors.push(`Organization \`${orgName}\` (settings.yml): ${e.message}`) + } + } + } + + // Build PR comment with clear sections + const totalSyntaxChecks = syntaxErrors.length + syntaxSuccesses.length + const totalMergeChecks = mergeErrors.length + mergeSuccesses.length + const hasErrors = syntaxErrors.length > 0 || mergeErrors.length > 0 + const conclusion = hasErrors ? 'failure' : 'success' + const title = hasErrors + ? 'Hub-Sync Master File Validation Failed' + : 'Hub-Sync Master Files Validated Successfully' + + let commentBody = `#### :robot: Hub-Sync Master File Validation\n\n` + commentBody += `**Files changed:** ${changedFiles.length}\n` + + // Show manifest filtering information + if (manifest) { + commentBody += `**Manifest rules:** ${manifest.rules.length} active rule(s)\n` + } else { + commentBody += `**Manifest rules:** No manifest found (all orgs/files will be synced)\n` + } + + if (orgsWithSettingsChanges.size > 0) { + commentBody += `**Organizations with changes:** ${allAffectedOrgs.join(', ')}\n` + + if (manifest) { + if (filteredOrgs.length > 0) { + commentBody += `**✅ Organizations that will receive updates:** ${filteredOrgs.join(', ')}\n` + } + if (excludedOrgs.length > 0) { + commentBody += `**⛔ Organizations excluded by manifest:** ${excludedOrgs.join(', ')}\n` + warnings.push(`${excludedOrgs.length} organization(s) excluded by manifest rules: ${excludedOrgs.join(', ')}`) + } + } + } + + if (excludedFiles.length > 0 && manifest) { + commentBody += `**⛔ Files excluded by manifest:** ${excludedFiles.map(f => f.fileName).join(', ')}\n` + warnings.push(`${excludedFiles.length} file(s) excluded by manifest rules: ${excludedFiles.map(f => f.fileName).join(', ')}`) + } + + commentBody += `\n` + + // Section 1: YAML Syntax Validation + if (syntaxErrors.length > 0) { + commentBody += `#### ❌ YAML Syntax Validation Failed (${syntaxSuccesses.length}/${totalSyntaxChecks})\n\n` + commentBody += syntaxErrors.map(e => `- ${e}`).join('\n') + '\n\n' + if (syntaxSuccesses.length > 0) { + commentBody += `**Passed:**\n` + commentBody += syntaxSuccesses.map(s => `- ${s} ✓`).join('\n') + '\n\n' + } + } else { + commentBody += `#### ✅ YAML Syntax Validation Passed (${syntaxSuccesses.length}/${totalSyntaxChecks})\n\n` + commentBody += syntaxSuccesses.map(s => `- ${s} ✓`).join('\n') + '\n\n' + } + + // Section 2: Merged Configuration Validation (only if settings.yml files changed) + if (totalMergeChecks > 0) { + if (mergeErrors.length > 0) { + commentBody += `#### ❌ Merged Configuration Validation Failed (${mergeSuccesses.length}/${totalMergeChecks})\n\n` + commentBody += mergeErrors.map(e => `- ${e}`).join('\n') + '\n\n' + if (mergeSuccesses.length > 0) { + commentBody += `**Passed:**\n` + commentBody += mergeSuccesses.map(s => `- ${s} ✓`).join('\n') + '\n\n' + } + } else { + commentBody += `#### ✅ Merged Configuration Validation Passed (${mergeSuccesses.length}/${totalMergeChecks})\n\n` + commentBody += mergeSuccesses.map(s => `- ${s} ✓`).join('\n') + '\n\n' + } + } + + // Warnings section (if any) + if (warnings.length > 0) { + commentBody += `#### ⚠️ Warnings (${warnings.length})\n\n` + commentBody += warnings.map(w => `- ${w}`).join('\n') + '\n\n' + } + + // Create PR comment + try { + await context.octokit.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullRequest.number, + body: commentBody + }) + } catch (e) { + robot.log.error(`Failed to create PR comment: ${e.message}`) + } + + // Update check run + const params = { + owner: payload.repository.owner.login, + repo: payload.repository.name, + check_run_id: payload.check_run.id, + status: 'completed', + completed_at: new Date().toISOString(), + conclusion, + output: { + title, + summary: commentBody + } + } + + robot.log.info(`Hub-sync validation complete: ${conclusion} (syntax: ${syntaxErrors.length} errors, merge: ${mergeErrors.length} errors)`) + return context.octokit.rest.checks.update(params) +} + +// Export all internal functions for testability +module.exports = { + hubSyncHandler, + retrieveSettingsFromOrgs, + syncHubOrgUpdate, + syncHubGlobalsUpdate, + getOrgInstallation, + mergeConfigs, + loadAndMergeSettings, + validateAndReportHubSync, + // Manifest-based filtering functions + loadManifest, + normalizeManifestRules, + matchesOrgTargets, + matchesFilesToSync, + shouldSyncOrgUpdate, + filterOrgsByManifest +} diff --git a/lib/installationCache.js b/lib/installationCache.js new file mode 100644 index 000000000..c9f814bba --- /dev/null +++ b/lib/installationCache.js @@ -0,0 +1,149 @@ +// Installation cache with TTL for GitHub App installations. +// Provides a hybrid approach: live refresh when stale, fast reads otherwise. + +let cachedInstallations = [] +let cachedOrgLogins = [] +let lastFetchedAt = null +let inFlightPromise = null + +/** + * Returns the TTL (time-to-live) in milliseconds for the installation cache. + * Reads from INSTALLATION_CACHE_TTL_MS env variable, defaults to 60s, minimum 5s. + */ +const DEFAULT_TTL_MS = 60_000 +function getTtlMs () { + const v = parseInt(process.env.INSTALLATION_CACHE_TTL_MS, 10) + return isNaN(v) || v < 5_000 ? DEFAULT_TTL_MS : v +} + +/** + * Fetches all GitHub App installations using the provided robot instance. + * Returns an array of installation objects. Uses pagination for large orgs. + * @param {Probot} robot - The Probot robot instance + * @param {Object} opts - Options (perPage) + * @returns {Promise} Array of installation objects + */ +async function fetchInstallations (robot, { perPage = 100 } = {}) { + const github = await robot.auth() + return github.paginate( + github.rest.apps.listInstallations.endpoint.merge({ per_page: perPage }) + ) +} + +/** + * Refreshes the installation cache by fetching live installations from GitHub. + * Updates cachedInstallations, cachedOrgLogins, and lastFetchedAt. + * Ensures only one refresh is in flight at a time. + * @param {Probot} robot - The Probot robot instance + * @param {Object} opts - Options for fetchInstallations + * @returns {Promise} Array of installation objects + */ +async function refresh (robot, opts = {}) { + if (inFlightPromise) return inFlightPromise + inFlightPromise = (async () => { + try { + const installs = await fetchInstallations(robot, opts) + cachedInstallations = installs + cachedOrgLogins = installs + .filter(i => i.account && i.account.type === 'Organization') + .map(i => i.account.login) + .sort() + lastFetchedAt = new Date() + } catch (e) { + robot.log && robot.log.warn && robot.log.warn(`Installation cache refresh failed: ${e.message}`) + throw e + } finally { + inFlightPromise = null + } + return cachedInstallations + })() + return inFlightPromise +} + +/** + * Starts a prefetch of installations to warm up the cache at startup. + * Returns a promise for the refresh operation. + * @param {Probot} robot - The Probot robot instance + * @param {Object} opts - Options for refresh + * @returns {Promise} Array of installation objects + */ +function startPrefetch (robot, opts = {}) { + return refresh(robot, opts) +} + +/** + * Initialize cache (always prefetch once at startup) and log result. + */ + +/** + * Initializes the installation cache by prefetching installations at startup. + * Logs the result and returns true/false for success/failure. + * @param {Probot} robot - The Probot robot instance + * @returns {Promise} True if prefetch succeeded, false otherwise + */ +function initCache (robot) { + return startPrefetch(robot) + .then(installs => { + robot.log && robot.log.info && robot.log.info(`Installation cache prefetched ${installs.length} installs (${cachedOrgLogins.length} orgs) [TTL=${getTtlMs()}ms]`) + return true + }) + .catch(e => { + robot.log && robot.log.warn && robot.log.warn(`Installation cache prefetch failed: ${e.message}`) + return false + }) +} + +/** + * Ensures the cache is fresh by checking TTL and refreshing if stale. + * Called before serving cached installations to guarantee freshness. + * @param {Probot} robot - The Probot robot instance + */ +async function ensureFresh (robot) { + const ttl = getTtlMs() + if (!lastFetchedAt || (Date.now() - lastFetchedAt.getTime()) > ttl) { + try { await refresh(robot) } catch (_) { /* stale ok */ } + } +} + +/** + * Returns the cached installations, refreshing if the cache is stale. + * Always returns a copy of the cached array. + * @param {Probot} robot - The Probot robot instance + * @returns {Promise} Array of installation objects + */ +async function getInstallations (robot) { + await ensureFresh(robot) + return cachedInstallations.slice() +} + +/** + * Returns a copy of the cached organization logins (GitHub org names). + * @returns {Array} Array of org login strings + */ +function getOrgLogins () { return cachedOrgLogins.slice() } + +/** + * Returns the Date when installations were last fetched. + * @returns {Date|null} Last fetched date or null if never fetched + */ +function getLastFetchedAt () { return lastFetchedAt } + +/** + * Test-only helper: Forces the cache to appear stale on next access. + * Used for diagnostics and testing cache refresh logic. + */ +function __forceStale () { + lastFetchedAt = new Date(Date.now() - (getTtlMs() + 10_000)) +} + +module.exports = { + startPrefetch, + initCache, + refresh, + getInstallations, + getOrgLogins, + getLastFetchedAt, + // for tests / diagnostics + _debug: () => ({ size: cachedInstallations.length, lastFetchedAt }), + __forceStale +} diff --git a/lib/routes.js b/lib/routes.js new file mode 100644 index 000000000..8d4564884 --- /dev/null +++ b/lib/routes.js @@ -0,0 +1,802 @@ +/** + * Router setup for Safe Settings UI & API endpoints + * Centralizes Express/Next asset & API wiring away from core app logic. + * + * Exports: + * setupRoutes(robot, addHandler) -> configured router + * + * Responsibilities: + * - Serve static exported Next.js UI (from ui/out) + * - Dashboard HTML entry points + * - JSON API endpoints + * + * This version removes dependency on robot-level cached installation getters + * (`robot.getCachedInstallations`, `robot.getOrganizationLogins`) and instead + * fetches installations live per request. If performance becomes an issue, + * a lightweight in-module memoization layer with short TTL can be reintroduced. + */ + +const path = require('path') +const util = require('util') +const fs = require('fs') +const express = require('express') +const env = require('./env') +const { getInstallations: cacheGetInstallations, getOrgLogins, getLastFetchedAt } = require('./installationCache') + +// Lightweight commit metadata cache (path+ref -> meta) with TTL to avoid +// repeated GitHub commit lookups across requests. +const COMMIT_META_TTL_MS = parseInt(process.env.COMMIT_META_TTL_MS || '300000') // 5m default +const _commitMetaCache = new Map() // key => { meta, expiresAt } +function getCachedCommitMeta (key) { + const entry = _commitMetaCache.get(key) + if (!entry) return null + if (Date.now() > entry.expiresAt) { _commitMetaCache.delete(key); return null } + return entry.meta +} +function setCachedCommitMeta (key, meta) { + _commitMetaCache.set(key, { meta, expiresAt: Date.now() + COMMIT_META_TTL_MS }) +} + +function setupRoutes (robot, addHandler) { + // Support for URL prefix (e.g., /safe-settings) when behind a proxy + const basePath = env.SAFE_SETTINGS_HUB_URL_PREFIX || '' + const mountPath = basePath || '/' + + const router = express.Router() + const app = express() + app.use(mountPath, router) + + addHandler((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname + const matchesBasePath = !basePath || pathname === basePath || pathname.startsWith(`${basePath}/`) + if (!matchesBasePath) return false + + app(req, res) + return true + }) + + // Ensure JSON/urlencoded body parsing is enabled for API endpoints + router.use(express.json({ limit: '1mb' })) + router.use(express.urlencoded({ extended: true })) + + // Static assets: produced by Next export/build step (ui/out) + const rootDir = path.join(__dirname, '..') // lib -> project root + const uiPath = path.join(rootDir, 'ui', 'out') + router.use(express.static(uiPath)) + + // HTML entrypoints (exported files). Adjust if you move/rename pages. + // Redirect root route to /dashboard + router.get('/', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard.html')) + }) + + router.get('/dashboard', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard.html')) + }) + + router.get('/dashboard/organizations', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'organizations.html')) + }) + + router.get('/dashboard/settings', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'settings.html')) + }) + + router.get('/dashboard/safe-settings-hub', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'safe-settings-hub.html')) + }) + + router.get('/dashboard/env', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'env.html')) + }) + + router.get('/dashboard/help', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'help.html')) + }) + + router.get('/dashboard/logs', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'logs.html')) + }) + + // Apple touch icon (silence 404s). Replace file logic if you add a real 180x180 asset. + const APPLE_TOUCH_ICON_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAQAAAA9zQYyAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==' // 180x180 transparent PNG + router.get('/apple-touch-icon.png', (req, res) => { + // If a real file exists at project root, serve it; otherwise fallback to embedded transparent PNG. + const filePath = path.join(rootDir, 'apple-touch-icon.png') + fs.access(filePath, fs.constants.R_OK, (err) => { + if (!err) { + return res.sendFile(filePath) + } + const buf = Buffer.from(APPLE_TOUCH_ICON_BASE64, 'base64') + res.setHeader('Content-Type', 'image/png') + res.setHeader('Cache-Control', 'public, max-age=86400, immutable') + res.send(buf) + }) + }) + + /** + * GET /api/safe-settings/installation + * Returns live organization installation metadata + optional last commit info. + * Query param: disableActivity=true to skip commit lookups (faster). + */ + router.get('/api/safe-settings/installation', async (req, res) => { + const disableActivity = req.query.disableActivity === 'true' + const includeActivity = !disableActivity + + const crypto = require('crypto') + function hashContent (str) { + return crypto.createHash('sha256').update(str || '').digest('hex') + } + + try { + const installs = await cacheGetInstallations(robot) + const orgLogins = getOrgLogins() + const orgInstalls = installs.filter(i => i.account && i.account.type === 'Organization') + const lastCommits = {} + const syncStatus = {} + let installationDtos + + if (includeActivity && env.ADMIN_REPO) { + const orgs = orgLogins + const limit = 1 // reduce concurrency for API rate safety + const queue = [...orgs] + robot.log.info(`Starting commit and sync status fetch for ${queue} organizations...`) + + const runners = [] + const runNext = async () => { + while (queue.length) { + const org = queue.shift() + try { + const install = installs.find(i => i.account && i.account.login && i.account.login.toLowerCase() === org.toLowerCase()) + if (!install) { + lastCommits[org] = { na: true, hasConfigRepo: false } + syncStatus[org] = false + continue + } + const githubOrg = await robot.auth(install.id) + let hasConfigRepo = false + try { + await githubOrg.rest.repos.get({ owner: org, repo: env.ADMIN_REPO }) + hasConfigRepo = true + } catch (repoErr) { + if (repoErr.status === 404) { + hasConfigRepo = false + } else { + robot.log.warn(`Repo existence check error for ${org}/${env.ADMIN_REPO}: ${repoErr.message}`) + } + } + // --- SYNC CHECK --- + let isInSync = false + if (hasConfigRepo) { + try { + const hubOrgDir = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${org}` + const hubRef = 'main' + robot.log.debug(`1. [SYNC DEBUG] Hub file path for org ${org}: ${hubOrgDir}`) + robot.log.debug(`2. [SYNC DEBUG] Hub file branch/ref for org ${org}: ${hubRef}`) + let orgFilesResp, hubFilesResp + try { + robot.log.debug(`3. [SYNC DEBUG] Org: ${org}`) + orgFilesResp = await githubOrg.rest.repos.getContent({ owner: org, repo: env.ADMIN_REPO, path: env.CONFIG_PATH }) + const orgNames = Array.isArray(orgFilesResp.data) + ? orgFilesResp.data.map(f => f.name).join(', ') + : (orgFilesResp.data && orgFilesResp.data.name ? orgFilesResp.data.name : '') + robot.log.debug(`4. [SYNC DEBUG] Org orgFilesResp file names: ${orgNames}`) + } catch (fetchErr) { + robot.log.error(`4a. [SYNC DEBUG] Error fetching org files: ${fetchErr.message}`) + orgFilesResp = { data: [] } + } + + try { + robot.log.debug(`5. [SYNC DEBUG] Hub: ${env.SAFE_SETTINGS_HUB_ORG}`) + robot.log.debug(`5a. [SYNC DEBUG] Fetching hub files for: \n owner: ${env.SAFE_SETTINGS_HUB_ORG}, \n repo: ${env.SAFE_SETTINGS_HUB_REPO}, \n path: ${hubOrgDir}, \n ref: ${hubRef}`) + hubFilesResp = await githubOrg.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: hubOrgDir, + ref: hubRef + }) + const hubNames = Array.isArray(hubFilesResp.data) + ? hubFilesResp.data.map(f => f.name).join(', ') + : (hubFilesResp.data && hubFilesResp.data.name ? hubFilesResp.data.name : '') + robot.log.debug(`6. [SYNC DEBUG] Hub hubFilesResp file names: ${hubNames}`) + } catch (fetchErr) { + if (fetchErr.status === 404) { + robot.log.debug(`6a. [SYNC DEBUG] Hub folder not found for org ${org} (expected if not yet imported): ${hubOrgDir}`) + } else { + robot.log.error(`6a. [SYNC DEBUG] Error fetching hub files: ${fetchErr}`) + } + hubFilesResp = { data: [] } + } + + const orgFiles = Array.isArray(orgFilesResp.data) ? orgFilesResp.data.filter(f => f.type === 'file') : [] + const hubFiles = Array.isArray(hubFilesResp.data) ? hubFilesResp.data.filter(f => f.type === 'file') : [] + + // Hub is source of truth - check if all hub files exist in org with matching content + if (hubFiles.length === 0) { + robot.log.debug(`6b. [SYNC DEBUG] Org ${org} not in sync - no hub content (not yet imported)`) + isInSync = false + } else { + // For each hub file, find corresponding org file and compare content + let allMatch = true + for (const hubFile of hubFiles) { + const hubFileName = hubFile.name + const orgFile = orgFiles.find(f => f.name === hubFileName) + + if (!orgFile) { + robot.log.debug(`6c. [SYNC DEBUG] Org ${org} not in sync - missing file ${hubFileName} in org`) + allMatch = false + break + } + + // Compare file contents + robot.log.debug(`7. [SYNC DEBUG] Comparing file: ${hubFileName} for org ${org}`) + let orgContentResp, hubContentResp + try { + orgContentResp = await githubOrg.rest.repos.getContent({ owner: org, repo: env.ADMIN_REPO, path: orgFile.path }) + } catch (fetchErr) { + robot.log.error(`7a. [SYNC DEBUG] Error fetching org file ${orgFile.path}: ${fetchErr.message}`) + allMatch = false + break + } + try { + hubContentResp = await githubOrg.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: hubFile.path }) + } catch (fetchErr) { + robot.log.error(`7b. [SYNC DEBUG] Error fetching hub file ${hubFile.path}: ${fetchErr.message}`) + allMatch = false + break + } + const orgContent = orgContentResp.data.content ? Buffer.from(orgContentResp.data.content, orgContentResp.data.encoding || 'base64').toString('utf8') : '' + const hubContent = hubContentResp.data.content ? Buffer.from(hubContentResp.data.content, hubContentResp.data.encoding || 'base64').toString('utf8') : '' + const orgHash = hashContent(orgContent) + const hubHash = hashContent(hubContent) + robot.log.debug(`8. [SYNC DEBUG] File: ${hubFileName}`) + robot.log.debug(`9. [SYNC DEBUG] Org hash: ${orgHash}`) + robot.log.debug(`10. [SYNC DEBUG] Hub hash: ${hubHash}`) + if (orgHash !== hubHash) { + robot.log.debug(`11. [SYNC DEBUG] Hash mismatch for file ${hubFileName} in org ${org}`) + allMatch = false + break + } + } + isInSync = allMatch + } + } catch (syncErr) { + robot.log.error(`[SYNC DEBUG] Sync check error for org ${org}: ${syncErr.message}`) + isInSync = false + } + } + syncStatus[org] = isInSync + // --- END SYNC CHECK --- + // Commit info (unchanged) + let commits + try { + const pathPrefix = `${env.CONFIG_PATH.replace(/\/$/, '')}/organizations/${org}` + commits = await githubOrg.rest.repos.listCommits({ owner: org, repo: env.ADMIN_REPO, per_page: 1, path: pathPrefix }) + } catch (err) { + if (err.status === 404) { + lastCommits[org] = { na: true, hasConfigRepo } + continue + } + if (err.status === 409) { // empty repo + lastCommits[org] = { hasConfigRepo } + continue + } + robot.log.warn(`Commit lookup error for ${org}/${env.ADMIN_REPO}: ${err.message}`) + lastCommits[org] = { hasConfigRepo } + continue + } + if (Array.isArray(commits.data) && commits.data.length) { + const c = commits.data[0] + const committedAt = (c.commit && c.commit.author && c.commit.author.date) || null + const ageSeconds = committedAt ? Math.floor((Date.now() - new Date(committedAt).getTime()) / 1000) : null + lastCommits[org] = { sha: c.sha, committed_at: committedAt, message: c.commit && c.commit.message ? c.commit.message.split('\n')[0] : null, age_seconds: ageSeconds, hasConfigRepo } + } else { + lastCommits[org] = { hasConfigRepo } + } + } catch (loopErr) { + robot.log.warn(`Unexpected error gathering commit for org ${org}: ${loopErr.message}`) + lastCommits[org] = { hasConfigRepo: false } + syncStatus[org] = false + } + } + } + for (let i = 0; i < limit; i++) runners.push(runNext()) + await Promise.all(runners) + } + + // Now that lastCommits and syncStatus are populated, build installationDtos + installationDtos = orgInstalls.map(i => { + const orgKey = i.account.login + const commitInfo = lastCommits[orgKey] || {} + return { + id: i.id, + account: orgKey, + type: i.account.type, + created_at: i.created_at, + name: orgKey, + sha: commitInfo.sha, + committed_at: commitInfo.committed_at, + message: commitInfo.message, + age_seconds: commitInfo.age_seconds, + hasConfigRepo: typeof commitInfo.hasConfigRepo === 'boolean' ? commitInfo.hasConfigRepo : false, + isInSync: typeof syncStatus[orgKey] === 'boolean' ? syncStatus[orgKey] : false + } + }) + return res.json({ updatedAt: new Date().toISOString(), installations: installationDtos }) + } catch (e) { + robot.log && robot.log.error && robot.log.error(e) + res.status(500).json({ error: e.message || 'unexpected error' }) + } + }) + + /** + * GET /api/safe-settings/hub/contents/* + * Fetches a file or directory listing from the SAFE_SETTINGS_HUB_ORG / SAFE_SETTINGS_HUB_REPO + * under the configured CONFIG_PATH (default .github). + * + * Examples: + * /api/safe-settings/hub/contents/ -> list CONFIG_PATH root + * /api/safe-settings/hub/contents/repos/foo.yml -> get specific file + * /api/safe-settings/hub/contents/repos?ref=main -> list directory at ref + * /api/safe-settings/hub/contents?recursive=true&maxDepth=2&fetchContent=false -> recursive listing without file bodies + * Note: recursive now defaults to true. Pass recursive=false for single-level listing. + */ + async function hubContent (req, res) { + let fullPath, ref + try { + // Use cached installations (TTL-based freshness) + const installs = await cacheGetInstallations(robot) + const install = installs.find(i => i.account && i.account.type === 'Organization' && i.account.login && i.account.login.toLowerCase() === env.SAFE_SETTINGS_HUB_ORG.toLowerCase()) + if (!install) { + return res.status(404).json({ error: `Installation for org ${env.SAFE_SETTINGS_HUB_ORG} not found` }) + } + + const github = await robot.auth(install.id) + const wildcardPath = req.params[0] || '' // from the * in the route + ref = req.query.ref || 'main' + fullPath = wildcardPath ? path.posix.join(env.CONFIG_PATH, wildcardPath) : env.CONFIG_PATH + // recursive defaults to true unless explicitly disabled with recursive=false + const recursive = req.query.recursive !== 'false' + let maxDepth = parseInt(req.query.maxDepth, 5) + if (isNaN(maxDepth) || maxDepth < 1) maxDepth = 5 // safety default + if (maxDepth > 8) maxDepth = 5 // hard cap to avoid abuse + // Unified flag: fetchContent (default true). No other legacy params supported. + const fetchContent = req.query.fetchContent !== 'false' + + // Commit metadata fetch with global shared cache + per-request memoization + const perRequestCommitCache = new Map() + const fetchCommitMeta = async (p) => { + if (perRequestCommitCache.has(p)) return perRequestCommitCache.get(p) + const cacheKey = `${ref}::${p}` + const cached = getCachedCommitMeta(cacheKey) + if (cached) { perRequestCommitCache.set(p, cached); return cached } + let meta + try { + const commits = await github.rest.repos.listCommits({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, per_page: 1, path: p }) + .then(r => Array.isArray(r.data) ? r.data : []) + if (commits.length) { + const c = commits[0] + const committedAt = c.commit && c.commit.author && c.commit.author.date + const ageSeconds = committedAt ? Math.floor((Date.now() - new Date(committedAt).getTime()) / 1000) : null + meta = { + lastCommitSha: c.sha, + lastCommitAt: committedAt, + lastCommitMessage: c.commit && c.commit.message ? c.commit.message.split('\n')[0] : null, + lastCommitAgeSeconds: ageSeconds + } + } else { + meta = { lastCommitSha: null, lastCommitAt: null, lastCommitMessage: null, lastCommitAgeSeconds: null } + } + } catch { + meta = { lastCommitSha: null, lastCommitAt: null, lastCommitMessage: null, lastCommitAgeSeconds: null } + } + setCachedCommitMeta(cacheKey, meta) + perRequestCommitCache.set(p, meta) + return meta + } + + // Helper to fetch a single file (returns null on failure) + const fetchFile = async (p) => { + try { + const fileResp = await github.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: p, ref }) + if (Array.isArray(fileResp.data)) return null + // file + const commitMeta = await fetchCommitMeta(fileResp.data.path) + if (fetchContent && typeof fileResp.data.content === 'string') { + const decoded = Buffer.from(fileResp.data.content, fileResp.data.encoding || 'base64').toString('utf8') + return { + type: fileResp.data.type, + name: path.posix.basename(p), + path: fileResp.data.path, + sha: fileResp.data.sha, + size: fileResp.data.size, + encoding: 'utf8', + content: decoded, + originalEncoding: fileResp.data.encoding || 'base64', + ...commitMeta + } + } + // metadata-only response + return { + type: fileResp.data.type, + name: path.posix.basename(p), + path: fileResp.data.path, + sha: fileResp.data.sha, + size: fileResp.data.size, + content: null, + originalEncoding: fileResp.data.encoding || 'base64', + ...commitMeta + } + } catch (e) { + robot.log.warn(`Failed to fetch file ${p}: ${e.message}`) + return null + } + } + + // Recursive traversal with depth limiting and basic cycle protection + const seen = new Set() + // Concurrency limiter for directory entry processing + const MAX_DIR_CONCURRENCY = parseInt(process.env.DIR_ENTRY_CONCURRENCY || '6') + async function mapWithLimit (items, mapper) { + const out = [] + let i = 0 + const running = new Set() + async function run () { + if (i >= items.length) return + const idx = i++ + const p = Promise.resolve(mapper(items[idx], idx)).then(r => { out[idx] = r; running.delete(p) }) + running.add(p) + if (running.size >= MAX_DIR_CONCURRENCY) await Promise.race(running) + return run() + } + await run() + await Promise.all([...running]) + return out + } + + const traverseDir = async (dirPath, depth = 0) => { + if (depth >= maxDepth) { + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, depth, truncated: true, entries: [], ...commitMeta } + } + if (seen.has(dirPath)) { + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, depth, cycle: true, entries: [], ...commitMeta } + } + seen.add(dirPath) + let listing + try { + const resp = await github.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: dirPath, ref }) + if (!Array.isArray(resp.data)) { + // Not a directory; fetch as file instead + const f = await fetchFile(dirPath) + return f || { type: 'file', path: dirPath, error: 'unreadable' } + } + listing = resp.data + } catch (e) { + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, error: e.status === 404 ? 'not_found' : e.message, entries: [], ...commitMeta } + } + + const entries = await mapWithLimit(listing, async (item) => { + if (item.type === 'file') { + if (fetchContent) { + const f = await fetchFile(item.path) + if (f) return f + const commitMeta = await fetchCommitMeta(item.path) + return { type: 'file', name: item.name, path: item.path, sha: item.sha, size: item.size, content: null, ...commitMeta } + } + const commitMeta = await fetchCommitMeta(item.path) + return { type: 'file', name: item.name, path: item.path, sha: item.sha, size: item.size, content: null, ...commitMeta } + } else if (item.type === 'dir') { + return traverseDir(item.path, depth + 1) + } + const commitMeta = await fetchCommitMeta(item.path) + return { type: item.type, name: item.name, path: item.path, unsupported: true, ...commitMeta } + }) + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, depth, entries, ...commitMeta } + } + + let response + try { + response = await github.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: fullPath, + ref + }) + } catch (apiError) { + robot.log.error(`GitHub API error details: status=${apiError.status}, message=${apiError.message}`) + if (apiError.response && apiError.response.data) { + robot.log.error(`GitHub API response: ${JSON.stringify(apiError.response.data)}`) + } + throw apiError + } + + const data = response.data + if (Array.isArray(data)) { + if (recursive) { + const tree = await traverseDir(fullPath, 0) + return res.json({ + recursive: true, + maxDepth, + ref, + fetchContent, + ...tree + }) + } else { + // non-recursive (original behavior) + const entries = await Promise.all(data.map(async d => { + if (d.type === 'file') { + if (fetchContent) { + const f = await fetchFile(d.path) + if (f) return f + } + return { + name: d.name, + path: d.path, + type: d.type, + sha: d.sha, + size: d.size, + content: null + } + } + return { + name: d.name, + path: d.path, + type: d.type, + sha: d.sha, + size: d.size, + content: null + } + })) + return res.json({ + type: 'dir', + path: fullPath, + entries, + ref, + fetchContent + }) + } + } + + if (typeof data.content === 'string') { + if (fetchContent) { + const decoded = Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + return res.json({ + type: data.type, + path: data.path, + sha: data.sha, + size: data.size, + encoding: 'utf8', + content: decoded, + originalEncoding: data.encoding || 'base64', + ref, + fetchContent: true + }) + } + return res.json({ + type: data.type, + path: data.path, + sha: data.sha, + size: data.size, + content: null, + ref, + fetchContent: false + }) + } + // Unsupported type (symlink, submodule, etc.) + return res.status(415).json({ error: 'Unsupported content type returned by GitHub API' }) + } catch (e) { + if (e.status === 404) { + robot.log.error(`Hub content 404: ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} path=${fullPath} ref=${ref}`) + return res.status(404).json({ + error: 'Not found', + details: { + org: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: fullPath, + ref + } + }) + } + robot.log && robot.log.error && robot.log.error(e) + return res.status(500).json({ error: e.message || 'unexpected error' }) + } + } + + router.get('/api/safe-settings/hub/content', hubContent) + router.get('/api/safe-settings/hub/content/*', hubContent) + + /** + * GET /api/safe-settings/app/env + * Returns key/value pairs parsed from the project .env file excluding + * standard GitHub App infrastructure variables. + * Query params: + * includeInfra=true -> include normally excluded infrastructure vars + */ + router.get('/api/safe-settings/app/env', (req, res) => { + try { + // Define a blacklist of sensitive environment variable keys to exclude + const ENV_BLACKLIST = ['PRIVATE_KEY_PATH']; + + // Parse .env file to extract comments + const envFilePath = path.join(rootDir, '.env'); + const commentMap = new Map(); + + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf8'); + const lines = envFileContent.split(/\r?\n/); + + lines.forEach(line => { + // Match lines like: KEY=value # comment + const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*[^#]*#\s*(.+)$/); + if (match) { + const [, key, comment] = match; + commentMap.set(key, comment.trim()); + } + }); + } catch (e) { + // If .env file doesn't exist or can't be read, continue without comments + robot.log && robot.log.debug && robot.log.debug('Could not read .env file for comments:', e.message); + } + + const variables = Object.entries(env) + .filter(([key]) => !ENV_BLACKLIST.includes(key)) + .map(([key, value]) => ({ + key, + value, + description: commentMap.get(key) || 'NA' + })) + .sort((a, b) => a.key.localeCompare(b.key)); + return res.json({ updatedAt: new Date().toISOString(), count: variables.length, variables }); + } catch (e) { + robot.log && robot.log.error && robot.log.error(e); + return res.status(500).json({ error: e.message || 'unexpected error' }); + } + }) + + + // POST /api/safe-settings/hub/import + // Body: { orgs: ['org1','org2'] } + router.post('/api/safe-settings/hub/import', async (req, res) => { + try { + const body = req.body || {} + const orgs = Array.isArray(body.orgs) ? body.orgs : (body.org ? [body.org] : null) + if (!orgs || !orgs.length) { + return res.status(400).json({ error: 'Missing orgs in request body. Expected JSON { orgs: ["org1","org2"] }' }) + } + // lazy-require to avoid circular require issues during module load + const { retrieveSettingsFromOrgs } = require('./hubSyncHandler') + const results = await retrieveSettingsFromOrgs(robot, orgs) + // Always return 200 with results, even if some/all orgs failed + return res.json({ ok: true, results }) + } catch (e) { + robot.log && robot.log.error && robot.log.error(e) + // Return 200 with error indicator for UI instead of 500 + return res.status(200).json({ ok: false, error: e.message || 'unexpected error', results: [] }) + } + }) + + + // GET /api/safe-settings/hub/log + // Returns parsed log entries (JSON): [{ timestamp, level, message }, ...] + router.get('/api/safe-settings/hub/log', async (req, res) => { + const lines = parseInt(req.query.lines || process.env.SAFE_SETTINGS_LOG_FILE_MAX_LINES || '1000', 10) + const levelsQuery = req.query.levels // comma-separated e.g. 'ERROR,WARN' + const allowedLevels = levelsQuery ? new Set(String(levelsQuery).split(',').map(s => s.trim().toUpperCase()).filter(Boolean)) : null + + const candidates = [] + if (process.env.SAFE_SETTINGS_LOG_FILE) candidates.push(process.env.SAFE_SETTINGS_LOG_FILE) + candidates.push(path.join(rootDir, 'hubSyncHandler.log')) // Primary log file for hub-sync + candidates.push(path.join(rootDir, 'safe-settings.log')) + candidates.push(path.join(rootDir, '..', 'safe-settings.log')) + candidates.push(path.join(rootDir, 'ui', 'safe-settings.log')) + + let found = null + for (const p of candidates) { + if (!p) continue + try { + const st = await fs.promises.stat(p) + if (st && st.isFile()) { found = p; break } + } catch (e) { + // ignore + } + } + if (!found) return res.status(404).json({ error: 'Log file not found' }) + + try { + const data = await fs.promises.readFile(found, 'utf8') + const arr = data.split(/\r?\n/).filter(Boolean) + const tail = arr.slice(-lines) + const parsed = tail.map(line => { + // Expecting format: 2025-09-10T12:34:56.789Z [INFO] message + const m = line.match(/^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+\[([A-Z]+)\]\s+(.*)$/) + if (m) { + return { timestamp: m[1], level: m[2], message: m[3], raw: line } + } + // fallback: try to extract level in brackets + const m2 = line.match(/\[([A-Z]+)\]\s*(.*)$/) + if (m2) return { timestamp: null, level: m2[1], message: m2[2], raw: line } + return { timestamp: null, level: 'UNKNOWN', message: line, raw: line } + }) + const filtered = allowedLevels ? parsed.filter(p => allowedLevels.has(String(p.level).toUpperCase())) : parsed + return res.json({ count: filtered.length, entries: filtered }) + } catch (err) { + return res.status(500).json({ error: err && err.message ? err.message : String(err) }) + } + }) + + // Alias for backwards compatibility + router.get('/api/safe-settings/logs', (req, res, next) => { + req.url = '/api/safe-settings/hub/log' + (req._parsedUrl.search || '') + next('route') + }) + + // GET /api/safe-settings/sync-logs + // Returns only sync-related log entries (filtered by [SYNC or containing 'sync') + router.get('/api/safe-settings/sync-logs', async (req, res) => { + const lines = parseInt(req.query.lines || process.env.SAFE_SETTINGS_LOG_FILE_MAX_LINES || '1000', 10) + const levelsQuery = req.query.levels // comma-separated e.g. 'ERROR,WARN' + const allowedLevels = levelsQuery ? new Set(String(levelsQuery).split(',').map(s => s.trim().toUpperCase()).filter(Boolean)) : null + + const candidates = [] + if (process.env.SAFE_SETTINGS_LOG_FILE) candidates.push(process.env.SAFE_SETTINGS_LOG_FILE) + candidates.push(path.join(rootDir, 'hubSyncHandler.log')) // Primary log file for hub-sync + candidates.push(path.join(rootDir, 'safe-settings.log')) + candidates.push(path.join(rootDir, '..', 'safe-settings.log')) + candidates.push(path.join(rootDir, 'ui', 'safe-settings.log')) + + let found = null + for (const p of candidates) { + if (!p) continue + try { + const st = await fs.promises.stat(p) + if (st && st.isFile()) { found = p; break } + } catch (e) { + // ignore + } + } + if (!found) return res.status(404).json({ error: 'Log file not found' }) + + try { + const data = await fs.promises.readFile(found, 'utf8') + const arr = data.split(/\r?\n/).filter(Boolean) + const tail = arr.slice(-lines * 3) // Get more lines to ensure we have enough sync entries + const parsed = tail.map(line => { + // Expecting format: 2025-09-10T12:34:56.789Z [INFO] message + const m = line.match(/^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+\[([A-Z]+)\]\s+(.*)$/) + if (m) { + return { timestamp: m[1], level: m[2], message: m[3], raw: line } + } + // fallback: try to extract level in brackets + const m2 = line.match(/\[([A-Z]+)\]\s*(.*)$/) + if (m2) return { timestamp: null, level: m2[1], message: m2[2], raw: line } + return { timestamp: null, level: 'UNKNOWN', message: line, raw: line } + }) + + // Filter for sync-related entries + const syncFiltered = parsed.filter(p => { + const msg = p.message.toLowerCase() + return msg.includes('sync') || msg.includes('[sync') + }) + + // Apply level filter if specified + const filtered = allowedLevels + ? syncFiltered.filter(p => allowedLevels.has(String(p.level).toUpperCase())) + : syncFiltered + + // Limit to requested number of lines + const final = filtered.slice(-lines) + + return res.json({ count: final.length, entries: final, syncOnly: true }) + } catch (err) { + return res.status(500).json({ error: err && err.message ? err.message : String(err) }) + } + }) + + return router +} + +module.exports = { setupRoutes } diff --git a/test/unit/lib/env.test.js b/test/unit/lib/env.test.js index bf47c33b0..d837bbee3 100644 --- a/test/unit/lib/env.test.js +++ b/test/unit/lib/env.test.js @@ -9,7 +9,8 @@ describe('env', () => { CREATE_PR_COMMENT: 'true', PR_COMMENT_SUMMARY_ENABLED: 'false', FULL_SYNC_NOP: false, - GH_ORG: undefined + GH_ORG: undefined, + SAFE_SETTINGS_HUB_URL_PREFIX: '/safe-settings' } beforeEach(() => { @@ -65,4 +66,16 @@ describe('env', () => { expect(require('../../../lib/env').PR_COMMENT_SUMMARY_ENABLED).toEqual('false') }) }) + + describe('normalize URL prefix', () => { + it.each([ + ['', ''], + ['/', ''], + ['custom-prefix', '/custom-prefix'], + ['/custom-prefix', '/custom-prefix'] + ])('normalizes %j to %j', (configuredPrefix, expectedPrefix) => { + process.env.SAFE_SETTINGS_HUB_URL_PREFIX = configuredPrefix + expect(require('../../../lib/env').SAFE_SETTINGS_HUB_URL_PREFIX).toEqual(expectedPrefix) + }) + }) }) diff --git a/test/unit/lib/hubSyncHandler-import.test.js b/test/unit/lib/hubSyncHandler-import.test.js new file mode 100644 index 000000000..650b864ee --- /dev/null +++ b/test/unit/lib/hubSyncHandler-import.test.js @@ -0,0 +1,131 @@ +const fs = require('fs') + +jest.mock('../../../lib/installationCache', () => ({ + getInstallations: jest.fn() +})) + +const env = require('../../../lib/env') +const { getInstallations } = require('../../../lib/installationCache') +const { loadManifest, retrieveSettingsFromOrgs } = require('../../../lib/hubSyncHandler') + +describe('retrieveSettingsFromOrgs', () => { + beforeEach(() => { + jest.replaceProperty(env, 'ADMIN_REPO', 'admin') + jest.replaceProperty(env, 'CONFIG_PATH', '.github') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_ORG', 'hub-org') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_REPO', 'hub-repo') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_PATH', 'safe-settings') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_REIMPORT', 'false') + jest.spyOn(fs, 'appendFile').mockImplementation((path, content, callback) => callback(null)) + getInstallations.mockResolvedValue([ + { id: 1, account: { login: 'hub-org', type: 'Organization' } }, + { id: 2, account: { login: 'source-org', type: 'Organization' } } + ]) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('reads source files through the Octokit REST namespace and creates an import PR', async () => { + const hubClient = { + rest: { + repos: { + getContent: jest.fn().mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 })) + }, + git: { + getRef: jest.fn().mockResolvedValue({ data: { object: { sha: 'base-sha' } } }), + getCommit: jest.fn().mockResolvedValue({ data: { tree: { sha: 'base-tree' } } }), + createTree: jest.fn().mockResolvedValue({ data: { sha: 'new-tree' } }), + createRef: jest.fn().mockResolvedValue({}), + createCommit: jest.fn().mockResolvedValue({ data: { sha: 'new-commit' } }), + updateRef: jest.fn().mockResolvedValue({}) + }, + pulls: { + create: jest.fn().mockResolvedValue({ data: { html_url: 'https://example.test/pr/1' } }) + } + } + } + const sourceClient = { + rest: { + git: { + getRef: jest.fn().mockResolvedValue({ data: { object: { sha: 'source-sha' } } }) + }, + repos: { + getContent: jest.fn() + .mockResolvedValueOnce({ + data: [{ type: 'file', path: '.github/settings.yml' }] + }) + .mockResolvedValueOnce({ + data: { + path: '.github/settings.yml', + content: Buffer.from('repository:\n default_branch: main\n').toString('base64'), + encoding: 'base64' + } + }) + } + } + } + const log = { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn() + } + const robot = { + log, + auth: jest.fn() + .mockResolvedValueOnce(hubClient) + .mockResolvedValueOnce(sourceClient) + } + + const results = await retrieveSettingsFromOrgs(robot, ['source-org']) + + expect(sourceClient.rest.repos.getContent).toHaveBeenCalledTimes(2) + expect(hubClient.rest.git.createTree).toHaveBeenCalledWith(expect.objectContaining({ + tree: [expect.objectContaining({ + path: '.github/safe-settings/organizations/source-org/settings.yml', + content: 'repository:\n default_branch: main\n' + })] + })) + expect(hubClient.rest.pulls.create).toHaveBeenCalled() + expect(results).toEqual([{ + org: 'source-org', + status: 'imported', + pr: 'https://example.test/pr/1' + }]) + }) +}) + +describe('loadManifest', () => { + it('loads manifest content through the Octokit REST namespace', async () => { + const octokit = { + rest: { + repos: { + getContent: jest.fn().mockResolvedValue({ + data: { + content: Buffer.from('rules: []\n').toString('base64'), + encoding: 'base64' + } + }) + } + } + } + const logger = { + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn() + } + + const manifest = await loadManifest(octokit, 'test-ref', logger) + + expect(octokit.rest.repos.getContent).toHaveBeenCalledWith({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/globals/manifest.yml`, + ref: 'test-ref' + }) + expect(manifest).toEqual({ rules: [] }) + expect(logger.error).not.toHaveBeenCalled() + }) +}) diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 000000000..97a2bb84e --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next", "next/core-web-vitals"] +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..26b002aac --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..e8de83917 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,69 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/route.ts`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## API Routes + +This directory contains example API routes for the headless API app. + +For more details, see [route.js file convention](https://nextjs.org/docs/app/api-reference/file-conventions/route). + +--- + +```mermaid + +sequenceDiagram + participant User + participant OrganizationsTable.jsx + participant HubOrgGraph.jsx + participant Next.js API Proxy + participant Backend (Express) + participant GitHub API + + User->>OrganizationsTable.jsx: Loads Organization page + OrganizationsTable.jsx->>Next.js API Proxy: GET /api/safe-settings/installation + Next.js API Proxy->>Backend (Express): GET /api/safe-settings/installation + Backend (Express)->>GitHub API: Fetch org installations, repo status, commit info, sync status + GitHub API-->>Backend (Express): Returns org data + Backend (Express)-->>Next.js API Proxy: Returns installations array + Next.js API Proxy-->>OrganizationsTable.jsx: Returns installations array + OrganizationsTable.jsx->>HubOrgGraph.jsx: Passes org data (hasConfigRepo, isInSync) + HubOrgGraph.jsx->>Next.js API Proxy: (if fetching own data) GET /api/safe-settings/installation + Next.js API Proxy->>Backend (Express): GET /api/safe-settings/installation + Backend (Express)->>GitHub API: (repeat fetch if needed) + GitHub API-->>Backend (Express): Returns org data + Backend (Express)-->>Next.js API Proxy: Returns installations array + Next.js API Proxy-->>HubOrgGraph.jsx: Returns installations array + User->>OrganizationsTable.jsx: Interacts with table/graph (tooltips, legend, etc.) +``` \ No newline at end of file diff --git a/ui/favicon.svg b/ui/favicon.svg new file mode 100644 index 000000000..17791cf9b --- /dev/null +++ b/ui/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/next.config.js b/ui/next.config.js new file mode 100644 index 000000000..c625482b2 --- /dev/null +++ b/ui/next.config.js @@ -0,0 +1,19 @@ + +// Normalize URL prefix: add leading '/' if missing, treat '/' as empty for root +const normalizePrefix = (prefix) => { + if (!prefix || prefix === '/') return ''; + return prefix.startsWith('/') ? prefix : `/${prefix}`; +}; + +const basePath = normalizePrefix(process.env.SAFE_SETTINGS_HUB_URL_PREFIX === undefined ? '/safe-settings' : process.env.SAFE_SETTINGS_HUB_URL_PREFIX); + +const nextConfig = { + output: "export", + basePath: basePath, + // Disable Next.js ESLint checks during builds + eslint: { + ignoreDuringBuilds: true, + }, +}; + +module.exports = nextConfig; diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 000000000..23c0972c1 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,7757 @@ +{ + "name": "ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.1.0", + "dependencies": { + "@primer/octicons-react": "^19.15.5", + "bootstrap": "^5.3.7", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "mermaid": "^11.14.0", + "next": "^15.4.7", + "react-markdown": "^10.1.0", + "rehype-mermaid": "^3.0.0", + "swr": "^2.3.6" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.4.7", + "tailwindcss": "^4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "license": "Apache-2.0" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-jVf75icVVgSVGf9+QWBeCHdFL35yZ06HMHl9sCa059pITTP781lOacvRazfwAmXDKiBiUdQQMWVnuiw/RaQNhQ==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, + "node_modules/@next/env": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.7.tgz", + "integrity": "sha512-PrBIpO8oljZGTOe9HH0miix1w5MUiGJ/q83Jge03mHEE0E3pyqzAy2+l5G6aJDbXoobmxPJTVhbCuwlLtjSHwg==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.4.7.tgz", + "integrity": "sha512-asj3RRiEruRLVr+k2ZC4hll9/XBzegMpFMr8IIRpNUYypG86m/a76339X2WETl1C53A512w2INOc2KZV769KPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.7.tgz", + "integrity": "sha512-2Dkb+VUTp9kHHkSqtws4fDl2Oxms29HcZBwFIda1X7Ztudzy7M6XF9HDS2dq85TmdN47VpuhjE+i6wgnIboVzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.7.tgz", + "integrity": "sha512-qaMnEozKdWezlmh1OGDVFueFv2z9lWTcLvt7e39QA3YOvZHNpN2rLs/IQLwZaUiw2jSvxW07LxMCWtOqsWFNQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.7.tgz", + "integrity": "sha512-ny7lODPE7a15Qms8LZiN9wjNWIeI+iAZOFDOnv2pcHStncUr7cr9lD5XF81mdhrBXLUP9yT9RzlmSWKIazWoDw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.7.tgz", + "integrity": "sha512-4SaCjlFR/2hGJqZLLWycccy1t+wBrE/vyJWnYaZJhUVHccpGLG5q0C+Xkw4iRzUIkE+/dr90MJRUym3s1+vO8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.7.tgz", + "integrity": "sha512-2uNXjxvONyRidg00VwvlTYDwC9EgCGNzPAPYbttIATZRxmOZ3hllk/YYESzHZb65eyZfBR5g9xgCZjRAl9YYGg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.7.tgz", + "integrity": "sha512-ceNbPjsFgLscYNGKSu4I6LYaadq2B8tcK116nVuInpHHdAWLWSwVK6CHNvCi0wVS9+TTArIFKJGsEyVD1H+4Kg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.7.tgz", + "integrity": "sha512-pZyxmY1iHlZJ04LUL7Css8bNvsYAMYOY9JRwFA3HZgpaNKsJSowD09Vg2R9734GxAcLJc2KDQHSCR91uD6/AAw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.7.tgz", + "integrity": "sha512-HjuwPJ7BeRzgl3KrjKqD2iDng0eQIpIReyhpF5r4yeAHFwWRuAhfW92rWv/r3qeQHEwHsLRzFDvMqRjyM5DI6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@primer/octicons-react": { + "version": "19.15.5", + "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.15.5.tgz", + "integrity": "sha512-JEoxBVkd6F8MaKEO1QKau0Nnk3IVroYn7uXGgMqZawcLQmLljfzua3S1fs2FQs295SYM9I6DlkESgz5ORq5yHA==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.3" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz", + "integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "postcss": "^8.4.41", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", + "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/type-utils": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.40.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", + "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", + "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.40.0", + "@typescript-eslint/types": "^8.40.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", + "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", + "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", + "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", + "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", + "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.40.0", + "@typescript-eslint/tsconfig-utils": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chevrotain": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", + "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "chevrotain": "^12.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/cytoscape": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", + "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.4.7.tgz", + "integrity": "sha512-tkKKNVJKI4zMIgTpvG2x6mmdhuOdgXUL3AaSPHwxLQkvzi4Yryqvk6B0R5Z4gkpe7FKopz3ZmlpePH3NTHy3gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.4.7", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz", + "integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==", + "license": "MIT", + "dependencies": { + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.3", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid-isomorphic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mermaid-isomorphic/-/mermaid-isomorphic-3.1.0.tgz", + "integrity": "sha512-mzrvfEVjnJIkJlEqxp3eMuR1wS0TeLCH1VK5E/T5yzWaBwI3JqjJuw70yUIThSCDJ5bRs6O3rgfp00oBAbvSeQ==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-free": "^6.0.0", + "katex": "^0.16.0", + "mermaid": "^11.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "playwright": "1" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + } + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.7.tgz", + "integrity": "sha512-OcqRugwF7n7mC8OSYjvsZhhG1AYSvulor1EIUsIkbbEbf1qoE5EbH36Swj8WhF4cHqmDgkiam3z1c1W0J1Wifg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.4.7", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.4.7", + "@next/swc-darwin-x64": "15.4.7", + "@next/swc-linux-arm64-gnu": "15.4.7", + "@next/swc-linux-arm64-musl": "15.4.7", + "@next/swc-linux-x64-gnu": "15.4.7", + "@next/swc-linux-x64-musl": "15.4.7", + "@next/swc-win32-arm64-msvc": "15.4.7", + "@next/swc-win32-x64-msvc": "15.4.7", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-mermaid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-mermaid/-/rehype-mermaid-3.0.0.tgz", + "integrity": "sha512-fxrD5E4Fa1WXUjmjNDvLOMT4XB1WaxcfycFIWiYU0yEMQhcTDElc9aDFnbDFRLxG1Cfo1I3mfD5kg4sjlWaB+Q==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "mermaid-isomorphic": "^3.0.0", + "mini-svg-data-uri": "^1.0.0", + "space-separated-tokens": "^2.0.0", + "unified": "^11.0.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "playwright": "1" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + } + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..7c35f0ace --- /dev/null +++ b/ui/package.json @@ -0,0 +1,30 @@ +{ + "name": "ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack --port 3001", + "build": "next build", + "export": "next build", + "start": "next start" + }, + "dependencies": { + "@primer/octicons-react": "^19.15.5", + "bootstrap": "^5.3.7", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "mermaid": "^11.14.0", + "next": "^15.4.7", + "react-markdown": "^10.1.0", + "rehype-mermaid": "^3.0.0", + "swr": "^2.3.6" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.4.7", + "tailwindcss": "^4" + } +} diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 000000000..e69de29bb diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 000000000..17791cf9b --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/shield.png b/ui/shield.png new file mode 100644 index 0000000000000000000000000000000000000000..93d8476ff0cf5d83c97b703afaffa0921f446cae GIT binary patch literal 4883 zcmYkAcRU+h7ss_~l+sdS1=SWcQ!9$vdsI<0W(Z|eD3|-b5F8~u`Uw>Hv<(F6_dW6mf3|3{_mit zy_k7VQuQy)C4Vzr4JzCK@D~*oYrnphx_OZOW`1zc>#^4Ec6}knyW&<8ntEzxpQs3o z#Zf`;=jA_uLJ3oeN*hzxr3CTYnBpq2M@gep1+TZGC#K4^)lpJYah%_`One3jjW= zLMm7F<6g3s2$DU$nR_@kUw~G;8otb$I58*QQFy@+> z>CZ^;W%@x6+jc*ZSR-%?izzEDs`6b=G9g69XuOJ9ac4`2$F@|laFY2}AJCEB5FF7o zOu7|1)pz#itB%;$^=cbl@wZoT)3)2YO%i~S$R^s!5xC@E^^?icC0r@vRb&YS+}law zyW8)eE=%;Z+G=giskuc-ip9eCt2TpAV-UJV;)wm$v9-h&Rn8!M%Lz9CiS|4zH*ubx+EkiKx*tAA(gcQgb3if~6pp=VTTgdPM?%_19X)+QTU=bZ45NoJm_lIBhgC8KW4Nn=<1{H*zn47bx9!$XR_4{>_W z(dud0dU5e~>w+Ow*0WynYsmh>+_K`1n7s@g;Jtfa(u)l!G&`YwE!MDVi?6RWx*_89 zR?quqh`D)^6}nKRFI(NI=lHzLXp+n zq9Xt2vHPui?Uew;^<^ihx=1civfg{dPdTeXkYYd>M~MSytWx`D;zd?XODYF6mfo+u zi{~~((_npc*u)>!n{0Tr$M7KuH%`Oj!x--Nm!S(mN7(EZMfzzWseEOB!xIZDyEzMJ}Uvk~I`5M6=G25h`M@i- z`)%Jo|8jFzsDObEHx|VKNNE@IkyV6c%WS17IpR~aPANQvGbw8e7`t$`sAMiL``n<) z&f|rtebvo)j`HaDa`SCckI&ZpR|1a0Df(gI4NaQ&iX~altzNqV21ntYL|Bgy!cpe% z#wbRls0Y>ay~R)cj)i;jtW<;v$ax>_x-{Y;$UQy) zs{VqShiaQXqUKSsp+HL}qYO{Api$KEEL^zTs79mt)_jyECXK+(K_DIxhq? zzzFP@T?sSmq2sDH%|_eRwVe#73b%&68}sG7x&M%XzA1p&Sa#=bpMqa zRo@f+kzmbcAz#?(1{a712%Di&;eXv*9jS5i8-%@LgJQ*KRAji)63I>iBCs|({$@q|f^_YT3ywTGmbkCB) zZTZTF3uZ$&dD>3py%lb}kHZZW=60<5)Hv!%mcBgRZ@(Xh2eEOCxM$0DIj98%aeDNF zF&Sa*3voq3>uo(rzs;VQ936EJg^AB+rwm&|Z=U*{WqSQL*Q8OrF+L+fyexW2!Zdb$ zmx*q&GrIleqqvWaJK%yrSENUmrnmR!xxdle6XLV5NlbmshfpK72q|T1)HOp&PpVNw z$hxEFK8%q0ogWxUZ3)U=9CO~9!(K1wBD8O-Rq6NZWIaT?Xj)FgJv>cq!+u1U6$%(Z zdlsH8hI_pzWF$AuM!G;ll$gUtXgALhevm%#rBe?@4M%Sg=MQsdO|MviD#I@ z#tpIqtHG$<@xD0=-@K37y^6KSNbd#Nnb0S>@7?>^v4M{Lsb2~~?|$E%hiPNR>T+N^ zJcq_`3ELv`F*l+ZbmjZV%x!WE<*A>%ykl1(AEY)2{PJcf{T#)ugMDUiO1F@+>S0eK zM9R6|vvfs~A3m@%Sy+_#&R+OHTOJ5SIv?&gQUrcQfw#my3JO}M(A$sd(l(sE zS`Ee8j0X#L1rWE`(_nh@S{;TU`xdV0)O?n{A4;*(Xp88|gnmOW-e~@m7WJC@Dk}CQ zqg6w8Cd&r;$5qSHJPY7m8$BUV>#1djP3zg1Jmg%z!9iu4>$OcK!-Y&m1f2J0zn-$G zxgUW)hI+PUG-};Ek~I@V-7dfxQV7}`o~Oc*%?ipilf99mM;|9Y#6xzvLOR0cWbQRW zCe!wa`W@o~76(8%aU_<9?lKJ%+hnIHC7tM?qBy9AX6VZ#Nh`!F)63=j>$R(Ahe zy*ilH2geS_y);?3{<*h*_;Og<-10TLXH9N!d7mvzqBy|LwE>1%&aT7}bJa4P_ULkC zFuAurI-JkOy4I|8YHLT0M*Y3KA#ybP*(zqxw{2fksIAHJJ`_QFAVI!D0AeFigLfOm zOz-jjX=7-g2{a$-OEn|aVYWv&0FJAA3mw>eVbJ&JN+yl!m0i?@&ozQMmcPa2f!kQT zNpVrHU;$9eH~(*>pn<^k&8)aHkZP)zU2!}ght;2laT!THca)>*X++F9X8Ul0%#_NB_S|7{S`FrM zPJ#q~Tlho1H&T3Oi!E1~Tm1%goX9fJb4G?OM=60xTbrf=AO+e(ax7AM0+-JbV|{dnQp!qfC@!%y%$ zur3OjEESiy^v1NT3F)Rf_fEq+WyBKuxje+S-0Kr=Pq0wVQ3LZNw0o(;M#ri1lM1KY zdl0Q4QRcg!St~K2PY&z5vmwYL%;M&apTC8!&uOT7DDA=-@c`z4El)ooMk+o7&{*gg zam{4$im4kWgz?hu&1O^__5YYB%%*8zoQkha5ca#X!LS-2Zd^sP)|j7PG1+BlYz{!@DWqp&{0~-Hon@s~;KH+@d@73ivvH z4gZ;U{24u9WAsMxA$x&Duh!d`em@Q|M9uyekgu99;FO&J!MY36R3Yu}2kIrpQ*9k=xotOJ~xv`;5TD+{lAxn&2#Eo1SxMn z7QCCoZB~%q)@M9hWO2I=TsgS{vmAl%N~Y7wPGKiH-iP&YEUSt)Gp5Z*emtMvYt9ly z>`qb6CeAml(pv=oM&Rcw5Y0sptO#gRuGB6SEmT@dZTdkA+w(YM)S){^j3iOERdXe) zJjh#@=QQST^5a!l`juC`>)g91u5>lKSleu^{Ikt@2oSTD+ahwPWDZ-?R^*1Q);NEA z_V$N5QuezeK}qcsNHjIi45O^YXV&p5x7mTVP`MM7Z zUwc8k6fZ6a4h66ie{?R%BI^E)u@`>9wg_&6H1N4W}^rt6HtWS2G6*GL7>)up!MYm!YUp1geie2pw?A zN&-u~ybD_XWK+pQI6Y3!2hU|6N$|+8zAf%wUsJhoq^EqNxANP2B}mc zo!3}}l1>N^6z0samV!BO!AlAtkhKk%1J{3%mjJ@OHV|{*j{k>n0tlB{Fy;V(|A)2& z5RSDcm;)sK|Kw|;c2N%o2&~U97DiPz^si}<+llJbR*$e_u|Obzj_KmNV|m@r2Ld$5 zj1jDONgYnUChC^G@PJ!%X+(88D<*7)CbJ|umYKi}7E1)$0_X%UHZK+n4Er}pI17S% z6M=aEx_?N{`2)B&7WffB_YWyMD}W6XfinQQ|5M@>=Q6NiEbwo@zv$Y9y!;|aiXZVbVc6p)~T`nqpZDTE*hC|GM0NzcLcmMzZ literal 0 HcmV?d00001 diff --git a/ui/shield.svg b/ui/shield.svg new file mode 100644 index 000000000..17791cf9b --- /dev/null +++ b/ui/shield.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/app/[slug]/route.js b/ui/src/app/[slug]/route.js new file mode 100644 index 000000000..f9803d730 --- /dev/null +++ b/ui/src/app/[slug]/route.js @@ -0,0 +1,17 @@ +const { NextResponse } = require('next/server'); + +// JS version: no type annotations +export async function GET(request, context) { + const { slug = '' } = await context.params; + return NextResponse.json({ message: `Hello ${slug}!` }); +} + +export async function generateStaticParams() { + // Replace with your actual slugs + return [ + { slug: 'example1' }, + { slug: 'example2' } + ]; +} + +export const dynamic = 'force-static'; diff --git a/ui/src/app/api/logs/route.js b/ui/src/app/api/logs/route.js new file mode 100644 index 000000000..ec873364a --- /dev/null +++ b/ui/src/app/api/logs/route.js @@ -0,0 +1,28 @@ +import fs from 'fs/promises' +import path from 'path' + +export const dynamic = 'force-static' + +async function findLogFile () { + const candidates = [] + if (process.env.SAFE_SETTINGS_LOG_FILE) candidates.push(process.env.SAFE_SETTINGS_LOG_FILE) + candidates.push(path.join(process.cwd(), 'safe-settings.log')) + candidates.push(path.join(process.cwd(), '..', 'safe-settings.log')) + candidates.push(path.join(process.cwd(), '..', '..', 'safe-settings.log')) + + for (const p of candidates) { + if (!p) continue + try { + const st = await fs.stat(p) + if (st && st.isFile()) return p + } catch (e) { + // ignore + } + } + return null +} + +export async function GET () { + const msg = 'Disabled in static export: use the backend endpoint /api/safe-settings/logs or set SAFE_SETTINGS_LOG_FILE to point at the log file.' + return new Response(msg, { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' } }) +} diff --git a/ui/src/app/components/EnvVariables.jsx b/ui/src/app/components/EnvVariables.jsx new file mode 100644 index 000000000..c68a0405d --- /dev/null +++ b/ui/src/app/components/EnvVariables.jsx @@ -0,0 +1,151 @@ +'use client'; +import { ChevronDownIcon, ChevronUpIcon, CopyIcon, SearchIcon, ShieldIcon } from '@primer/octicons-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useHydrated } from '../hooks/useHydrated'; +import { withBasePath } from '../utils/basePath'; + +const SENSITIVE_REGEX = /(secret|token|key|password|private)/i; + +export default function EnvVariables() { + const hydrated = useHydrated(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [rows, setRows] = useState([]); + const [search, setSearch] = useState(''); + const [includeInfra, setIncludeInfra] = useState(false); + const [revealAll, setRevealAll] = useState(false); + const [lastFetchedAt, setLastFetchedAt] = useState(null); + const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); + + const fetchData = () => { + if (!hydrated) return; + setLoading(true); setError(null); + fetch(withBasePath(`/api/safe-settings/app/env${includeInfra ? '?includeInfra=true' : ''}`)) + .then(r => { + if (!r.ok) { + throw new Error(`Unable to retrieve environment variables (HTTP ${r.status}). Please try again later.`); + } + return r.json(); + }) + .then(json => { + setRows(json.variables || []); + setLastFetchedAt(new Date(json.updatedAt || Date.now())); + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchData(); /* eslint-disable-next-line */ }, [hydrated, includeInfra]); + + const filtered = useMemo(() => { + if (!search) return rows; + const q = search.toLowerCase(); + return rows.filter(r => + r.key.toLowerCase().includes(q) || + (r.value + '').toLowerCase().includes(q) || + (r.description || '').toLowerCase().includes(q) + ); + }, [rows, search]); + + const sorted = useMemo(() => { + if (!sortConfig.key || !sortConfig.direction) return filtered; + const list = [...filtered]; + list.sort((a, b) => { + let av = a[sortConfig.key]; + let bv = b[sortConfig.key]; + if (av == null) av = ''; + if (bv == null) bv = ''; + av = (av + '').toLowerCase(); + bv = (bv + '').toLowerCase(); + if (av < bv) return sortConfig.direction === 'asc' ? -1 : 1; + if (av > bv) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + return list; + }, [filtered, sortConfig]); + + const cycleSort = (key) => { + setSortConfig(prev => { + if (prev.key === key) { + if (prev.direction === 'asc') return { key, direction: 'desc' }; + if (prev.direction === 'desc') return { key: null, direction: null }; + } + return { key, direction: 'asc' }; + }); + }; + + const renderSortIcon = (key) => { + if (sortConfig.key !== key) return ↕; + if (sortConfig.direction === 'asc') return ; + if (sortConfig.direction === 'desc') return ; + return ↕; + }; + + const maskedValue = (k, v) => { + if (revealAll) return v; + if (!SENSITIVE_REGEX.test(k)) return v; + if (!v) return v; + if (v.length <= 4) return '*'.repeat(v.length); + return v.slice(0, 2) + '***' + v.slice(-2); + }; + + const copyToClipboard = (text) => { + try { navigator.clipboard.writeText(text); } catch(_) {} + } + + return ( +
+
+
+ +
+ + setSearch(e.target.value)} /> +
+
+ {/* Removed options and buttons section for a cleaner environment page UI */} +
+ + {loading &&
Loading…
} + {error && !loading &&
Error: {error}
} + {!loading && !error && filtered.length === 0 &&
No variables
} + + {!loading && !error && filtered.length > 0 && ( +
+ + + + + + + + + + + + {sorted.map(r => { + const sensitive = SENSITIVE_REGEX.test(r.key); + return ( + + + + + + + + ); + })} + +
cycleSort('key')} className="theme-text-primary user-select-none" style={{ width: '20%', cursor: 'pointer' }}>Key {renderSortIcon('key')} cycleSort('value')} className="theme-text-primary user-select-none" style={{ width: '30%', cursor: 'pointer' }}>Value {renderSortIcon('value')} cycleSort('description')} className="theme-text-primary user-select-none" style={{ cursor: 'pointer' }}>Description {renderSortIcon('description')}
{r.key} + {maskedValue(r.key, r.value)} + {r.description || 'NA'}{sensitive && } + +
+
+ )} +
+ {sorted.length} shown / {rows.length} total +
+
+ ); +} diff --git a/ui/src/app/components/HubOrgGraph.jsx b/ui/src/app/components/HubOrgGraph.jsx new file mode 100644 index 000000000..4788a9878 --- /dev/null +++ b/ui/src/app/components/HubOrgGraph.jsx @@ -0,0 +1,144 @@ +'use client'; +import { useEffect, useRef } from "react"; +import useSWR from "swr"; +import { withBasePath } from "../utils/basePath"; + +const fetcher = (...args) => fetch(...args).then(res => { + if (!res.ok) throw new Error(`Request failed (HTTP ${res.status})`); + return res.json(); +}); + +export default function HubOrgGraph({ width = 640, height = 320 }) { + const vizRef = useRef(null); + const { data, error } = useSWR(withBasePath("/api/safe-settings/installation"), fetcher); + const orgs = Array.isArray(data?.installations) + ? data.installations.filter(i => i.type === "Organization") + : []; + const orgCount = orgs.length; + + useEffect(() => { + if (typeof window === "undefined" || !data) return; + Promise.all([ + import("d3-selection"), + import("d3-force"), + import("d3-drag") + ]).then(([d3Selection, d3Force, d3Drag]) => { + const select = d3Selection.select; + const forceSimulation = d3Force.forceSimulation; + const forceLink = d3Force.forceLink; + const forceManyBody = d3Force.forceManyBody; + const forceCenter = d3Force.forceCenter; + const drag = d3Drag.drag; + // Dynamic graph data: 1 HUB, N ORGs + const nodes = [ { id: "Hub", group: 1, label: "Hub", color: "#0a2540" } ]; + if (orgs.length > 0) { + orgs.forEach((org, i) => { + const orgKey = org.account; + const hasConfigRepo = org.hasConfigRepo === true; + nodes.push({ id: orgKey, group: 2, label: "ORG", color: hasConfigRepo ? "#2ea44f" : "#6a737d", tooltip: org.account }); + }); + } else { + for (let i = 1; i <= orgCount; i++) { + nodes.push({ id: `ORG${i}`, group: 2, label: "ORG", color: "#6a737d", tooltip: `ORG${i}` }); + } + } + const links = []; + if (orgs.length > 0) { + orgs.forEach((org, i) => { + const orgKey = org.account; + links.push({ source: "Hub", target: orgKey }); + }); + } else { + for (let i = 1; i <= orgCount; i++) { + links.push({ source: "Hub", target: `ORG${i}` }); + } + } + select(vizRef.current).selectAll("svg").remove(); + const svg = select(vizRef.current) + .append("svg") + .attr("width", width) + .attr("height", height); + const simulation = forceSimulation(nodes) + .force("link", forceLink(links).id(d => d.id).distance(120)) + .force("charge", forceManyBody().strength(-400)) + .force("center", forceCenter(width / 2, height / 2)); + const link = svg.append("g") + .attr("stroke", "#999") + .attr("stroke-opacity", 0.6) + .selectAll("line") + .data(links) + .join("line") + .attr("stroke-width", 2); + const node = svg.append("g") + .attr("stroke", "#fff") + .attr("stroke-width", 2) + .selectAll("circle") + .data(nodes) + .join("circle") + .attr("r", 24) + .attr("fill", d => d.group === 1 ? d.color : d.color || "#6f42c1") + .call(drag() + .on("start", (event, d) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; d.fy = d.y; + }) + .on("drag", (event, d) => { + d.fx = event.x; d.fy = event.y; + }) + .on("end", (event, d) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; d.fy = null; + }) + ); + node.append("title") + .text(d => d.group === 2 ? d.tooltip : "Hub"); + const label = svg.append("g") + .selectAll("text") + .data(nodes) + .join("text") + .attr("text-anchor", "middle") + .attr("dy", ".35em") + .attr("font-size", 16) + .attr("font-family", "sans-serif") + .attr("fill", d => d.group === 1 ? "#fff" : "#fff") + .text(d => d.label) + .each(function(d) { + d3Selection.select(this) + .append("title") + .text(d.group === 2 ? d.tooltip : "Hub"); + }); + simulation.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + node + .attr("cx", d => d.x) + .attr("cy", d => d.y); + label + .attr("x", d => d.x) + .attr("y", d => d.y); + }); + }); + }, [width, height, orgCount, data]); + + if (error) return
Error loading organization graph.
; + if (!data) return
Loading organization graph...
; + + return ( +
+
+
+ + + Has safe-settings admin repo + + + + No safe-settings admin repo + +
+
+ ); +} diff --git a/ui/src/app/components/OrganizationsTable.jsx b/ui/src/app/components/OrganizationsTable.jsx new file mode 100644 index 000000000..4a8f621b0 --- /dev/null +++ b/ui/src/app/components/OrganizationsTable.jsx @@ -0,0 +1,583 @@ +"use client"; + +import { + ChevronDownIcon, + ChevronUpIcon, + InfoIcon, + SearchIcon, +} from "@primer/octicons-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useHydrated } from "../hooks/useHydrated"; +import { withBasePath } from "../utils/basePath"; + +// Mock organizations used when /api/safe-settings/installation returns 404 +const MOCK_ORGS = [ + { + id: 1, + name: "mock-org-one", + lastSyncDate: new Date(Date.now() - 3600 * 1000).toISOString(), + lastSyncMessage: "Initial mock sync", + lastSyncSha: "abcdef1", + ageSeconds: 3600, + }, + { + id: 2, + name: "example-inc", + lastSyncDate: new Date(Date.now() - 7200 * 1000).toISOString(), + lastSyncMessage: "Second mock sync", + lastSyncSha: "abcdef2", + ageSeconds: 7200, + }, + { + id: 3, + name: "demo-labs", + lastSyncDate: null, + lastSyncMessage: null, + lastSyncSha: null, + ageSeconds: null, + na: true, + }, +]; + +const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { + const [searchTerm, setSearchTerm] = useState(""); + const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [fetched, setFetched] = useState([]); + const hydrated = useHydrated(); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const headerCheckboxRef = useRef(null); + const [retrievingFiles, setRetrievingFiles] = useState(false); + const [retrieveMessage, setRetrieveMessage] = useState(null); + const [retrieveError, setRetrieveError] = useState(null); + const [retrieveResults, setRetrieveResults] = useState(null); + + // Fetch real organizations from backend API on client hydration + useEffect(() => { + if (!hydrated) return; // avoid SSR mismatch + let cancelled = false; + setLoading(true); + + fetch(withBasePath("/api/safe-settings/installation")) + .then((r) => { + if (!r.ok) { + throw new Error( + `Unable to retrieve organizations (HTTP ${r.status}). Please try again later.` + ); + } + return r.json(); + }) + .then((json) => { + if (!json || cancelled) return; + const mapped = (json.installations || []).map((i) => ({ + id: i.id, + name: i.account, + lastSyncDate: i.committed_at || null, + lastSyncSha: i.sha || null, + lastSyncMessage: i.message || null, + ageSeconds: typeof i.age_seconds === "number" ? i.age_seconds : null, + hasConfigRepo: + typeof i.hasConfigRepo === "boolean" ? i.hasConfigRepo : false, + isInSync: typeof i.isInSync === "boolean" ? i.isInSync : false, + })); + setFetched(mapped); + setError(null); + }) + .catch((e) => { + if (!cancelled) setError(e.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [hydrated]); + + const data = + fetched.length > 0 + ? fetched + : propOrganizations.length > 0 + ? propOrganizations + : []; + + // Format date for display with hydration-safe approach + const formatLastSync = (org) => { + if (!org.lastSyncDate) return —; + const dateObj = new Date(org.lastSyncDate); + let ageSec = org.ageSeconds; + if (hydrated && ageSec == null) { + ageSec = Math.floor((Date.now() - dateObj.getTime()) / 1000); + } + const rel = (() => { + if (ageSec == null) return ""; + if (ageSec < 60) return "0m"; + const mTotal = Math.floor(ageSec / 60); + if (mTotal < 60) return `${mTotal}m`; + const hTotal = Math.floor(mTotal / 60); + if (hTotal < 24) { + const remM = mTotal % 60; + return remM ? `${hTotal}h ${remM}m` : `${hTotal}h`; + } + const dTotal = Math.floor(hTotal / 24); + const remH = hTotal % 24; + return remH ? `${dTotal}d ${remH}h` : `${dTotal}d`; + })(); + const fullStamp = `${dateObj.getFullYear()}-${String( + dateObj.getMonth() + 1 + ).padStart(2, "0")}-${String(dateObj.getDate()).padStart(2, "0")} ${String( + dateObj.getHours() + ).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart( + 2, + "0" + )}:${String(dateObj.getSeconds()).padStart(2, "0")}`; + const tooltip = [ + fullStamp, + org.lastSyncMessage, + org.lastSyncSha ? `SHA: ${org.lastSyncSha.slice(0, 7)}` : null, + ] + .filter(Boolean) + .join("\n"); + return ( + + {rel} + + ); + }; + const lastSyncColStyle = { + width: "170px", + fontVariantNumeric: "tabular-nums", + }; + + // Filter organizations based on search term + const filteredData = useMemo(() => { + return data.filter((org) => + org.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [data, searchTerm]); + + // Sort organizations + const sortedData = useMemo(() => { + if (!sortConfig.key) return filteredData; + + return [...filteredData].sort((a, b) => { + let aValue = a[sortConfig.key]; + let bValue = b[sortConfig.key]; + + // Convert dates to timestamps for comparison + if (sortConfig.key === "lastSyncDate") { + aValue = new Date(aValue).getTime(); + bValue = new Date(bValue).getTime(); + } + + if (aValue < bValue) { + return sortConfig.direction === "asc" ? -1 : 1; + } + if (aValue > bValue) { + return sortConfig.direction === "asc" ? 1 : -1; + } + return 0; + }); + }, [filteredData, sortConfig]); + + // Handle column sorting + const handleSort = (key) => { + setSortConfig((prevConfig) => { + if (prevConfig.key === key) { + if (prevConfig.direction === "asc") { + return { key, direction: "desc" }; + } else if (prevConfig.direction === "desc") { + return { key: null, direction: null }; + } + } + return { key, direction: "asc" }; + }); + }; + + // Render sort icon + const renderSortIcon = (columnKey) => { + if (sortConfig.key !== columnKey) { + return ( + + ↕ + + ); + } + if (sortConfig.direction === "asc") { + return ; + } + if (sortConfig.direction === "desc") { + return ; + } + return ( + + ↕ + + ); + }; + + // Keep header checkbox indeterminate when some but not all rows are selected + useEffect(() => { + if (!headerCheckboxRef || !headerCheckboxRef.current) return; + const selectableCount = sortedData.filter((o) => !o.synced).length; + headerCheckboxRef.current.indeterminate = + selectedIds.size > 0 && selectedIds.size < selectableCount; + }, [selectedIds, sortedData]); + + // Prune selection when the displayed dataset changes (remove ids that no longer exist) + useEffect(() => { + setSelectedIds((prev) => { + const allowed = new Set( + sortedData.filter((o) => !o.synced).map((o) => o.id) + ); + const next = new Set([...prev].filter((id) => allowed.has(id))); + if (next.size === prev.size) return prev; + return next; + }); + }, [sortedData]); + + // Retrieve files for selected organizations + const retrieveFilesForSelected = async () => { + if (selectedIds.size === 0) return; + // map selected ids back to organization names using the current sorted/filtered dataset + const orgNames = sortedData + .filter((o) => selectedIds.has(o.id)) + .map((o) => o.name); + if (orgNames.length === 0) return; + setRetrieveResults(null); + setRetrieveMessage(null); + setRetrieveError(null); + setRetrievingFiles(true); + try { + const res = await fetch(withBasePath("/api/safe-settings/hub/import"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orgs: orgNames }), + }); + if (!res.ok) throw new Error(`Request failed (HTTP ${res.status})`); + const json = await res.json().catch(() => ({})); + if (Array.isArray(json.results)) { + setRetrieveResults(json.results); + const created = json.results.filter((r) => r.pr).length; + const skipped = json.results.filter((r) => r.skipped).map((r) => r.org); + const errors = json.results.filter((r) => r.error).length; + const parts = []; + if (created) + parts.push(`${created} PR${created > 1 ? "s" : ""} created`); + if (skipped.length) parts.push(`Skipped: ${skipped.join(", ")}`); + if (errors) parts.push(`${errors} error${errors > 1 ? "s" : ""}`); + setRetrieveMessage(parts.join(" • ") || "Retrieval completed"); + } else { + setRetrieveMessage(json.message || "Retrieval requested"); + } + } catch (e) { + setRetrieveError(e.message || String(e)); + } finally { + setRetrievingFiles(false); + } + }; + + return ( +
+ {/* Search Bar */} +
+
+
+
+ + + + setSearchTerm(e.target.value)} + /> +
+
+
+
+ + + + +
+
+
+
+ + {/* Reserved message area: keeps layout stable when messages appear */} +
+ {retrieveResults ? ( + retrieveResults.map((r) => ( +
+ {r.pr ? ( +
+ Imported {r.org}:{" "} + + {r.pr} + +
+ ) : r.skipped ? ( +
+ Skipping {r.org}: already present in hub +
+ ) : r.error ? ( +
+ {r.org}: {r.error} +
+ ) : null} +
+ )) + ) : ( + <> + {retrieveMessage && ( +
{retrieveMessage}
+ )} + {retrieveError && ( +
{retrieveError}
+ )} + + )} +
+ + {/* Table */} +
+ + + + + + + + + + + + {loading && ( + + + + )} + {!loading && error && ( + + + + )} + {!loading && !error && sortedData.length > 0 + ? sortedData.map((org) => { + return ( + + + + + + + + ); + }) + : !loading && + !error && ( + + + + )} + +
+ {/* compute selectable rows so header/select-all ignores already-imported orgs */} + { + const selectableCount = sortedData.filter( + (o) => !o.synced + ).length; + return ( + selectableCount > 0 && + selectedIds.size === selectableCount + ); + }, [sortedData, selectedIds])} + onChange={() => { + // toggle all selectable (non-synced) rows + setSelectedIds((prev) => { + const selectable = sortedData + .filter((o) => !o.synced) + .map((o) => o.id); + if (prev.size === selectable.length) return new Set(); + return new Set(selectable); + }); + }} + aria-label="Select all organizations" + /> + handleSort("name")} + > +
+
Organization Name
+
{renderSortIcon("name")}
+ + Showing {sortedData.length} of {data.length} organizations + +
+
+ Config Repo + + In Sync + handleSort("lastSyncDate")} + > + Last Sync + {renderSortIcon("lastSyncDate")} +
+ Loading organizations… +
+
+ {error} +
+
+ + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(org.id)) next.delete(org.id); + else next.add(org.id); + return next; + }) + } + aria-label={`Select ${org.name}`} + disabled={org.synced === true} + style={ + org.synced + ? { opacity: 0.45, cursor: "not-allowed" } + : {} + } + /> + + {org.name} + {org.synced && ( + Imported + )} + + {org.hasConfigRepo ? ( + + ✓ + + ) : ( + + NA + + )} + + {org.isInSync ? ( + + ✓ + + ) : ( + + ✗ + + )} + + {formatLastSync(org)} +
+ {searchTerm + ? `No organizations found matching "${searchTerm}"` + : "No organizations available"} +
+
+ + {/* Table Footer Info */} + {sortedData.length > 0 && ( +
+ + {searchTerm && `Filtered by: "${searchTerm}"`} + {sortConfig.key && ( + + • Sorted by:{" "} + {sortConfig.key === "name" + ? "Organization Name" + : "Last Safe-settings Sync"} + ({sortConfig.direction === "asc" ? "A-Z" : "Z-A"}) + + )} + +
+ )} +
+ ); +}; + +export default OrganizationsTable; diff --git a/ui/src/app/components/Safe-settings-hubContent.jsx b/ui/src/app/components/Safe-settings-hubContent.jsx new file mode 100644 index 000000000..0c0610890 --- /dev/null +++ b/ui/src/app/components/Safe-settings-hubContent.jsx @@ -0,0 +1,279 @@ +'use client'; +import { ChevronDownIcon, ChevronRightIcon, FileDirectoryIcon, FileIcon, SearchIcon } from '@primer/octicons-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useHydrated } from '../hooks/useHydrated'; +import { withBasePath } from '../utils/basePath'; + +// Match the left index width and reuse for the search input +const LEFT_COL_WIDTH = 320; + + +export default function SafeSettingsHubContent3b() { + const hydrated = useHydrated(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [rootTree, setRootTree] = useState(null); + const [search, setSearch] = useState(''); + const [expandedPaths, setExpandedPaths] = useState(() => new Set()); + const [selectedPath, setSelectedPath] = useState(null); + const [lastFetchedAt, setLastFetchedAt] = useState(null); + + const fetchData = () => { + if (!hydrated) return; + setLoading(true); setError(null); + fetch(withBasePath('/api/safe-settings/hub/content?fetchContent=true')) + .then(r => { + if (!r.ok) throw new Error(`Unable to retrieve safe-settings hub content (HTTP ${r.status})`); + return r.json(); + }) + .then(json => { setRootTree(json); setLastFetchedAt(new Date()); }) + .catch((error) => { setError("Unable to load content. Please try again later."); setRootTree(null); }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchData(); }, [hydrated]); + + const filterTree = useCallback((node) => { + if (!node) return null; + const term = search.toLowerCase(); + const matches = (n) => !term || (n.name && n.name.toLowerCase().includes(term)) || (n.path && n.path.toLowerCase().includes(term)); + if (node.type === 'file') { + return matches(node) ? node : null; + } + if (node.type === 'dir') { + const filteredEntries = (node.entries || []) + .map(child => filterTree(child)) + .filter(Boolean); + if (matches(node) || filteredEntries.length > 0) { + return { ...node, entries: filteredEntries }; + } + return null; + } + return null; + }, [search]); + + const filteredTree = useMemo(() => filterTree(rootTree), [rootTree, filterTree]); + + const displayTree = useMemo(() => { + if (!filteredTree) return null; + if (filteredTree.type === 'dir') { + const nameMatch = (n) => n && n.type === 'dir' && n.name && n.name.toLowerCase().includes('safe-settings'); + const immediate = (filteredTree.entries || []).find(nameMatch); + if (immediate) return immediate; + const findDescendant = (node, depth = 0, maxDepth = 3) => { + if (!node || node.type !== 'dir' || depth >= maxDepth) return null; + for (const child of node.entries || []) if (nameMatch(child)) return child; + for (const child of node.entries || []) if (child.type === 'dir') { + const found = findDescendant(child, depth + 1, maxDepth); + if (found) return found; + } + return null; + }; + const found = findDescendant(filteredTree, 0, 3); + if (found) return found; + } + return filteredTree; + }, [filteredTree]); + + useEffect(() => { if (!displayTree) return; setSelectedPath(prev => prev || displayTree.path); }, [displayTree]); + + const findNodeByPath = useCallback((node, path) => { + if (!node) return null; + if (node.path === path) return node; + if (node.type === 'dir') { + for (const child of node.entries || []) { + const found = findNodeByPath(child, path); + if (found) return found; + } + } + return null; + }, []); + + const selectedNode = useMemo(() => { + if (!displayTree || !selectedPath) return null; + return findNodeByPath(displayTree, selectedPath); + }, [displayTree, selectedPath, findNodeByPath]); + + const toggleDir = (path) => { setExpandedPaths(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; + + const formatTimeAgo = (iso) => { + if (!iso) return '—'; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return iso; + const diffSec = Math.floor((Date.now() - dt.getTime()) / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH} hour${diffH === 1 ? '' : 's'} ago`; + const diffD = Math.floor(diffH / 24); + if (diffD < 30) return `${diffD} day${diffD === 1 ? '' : 's'} ago`; + const diffM = Math.floor(diffD / 30); + if (diffM < 12) return diffM === 1 ? '1 month ago' : `${diffM} months ago`; + const diffY = Math.floor(diffD / 365); + if (diffY === 1) return 'last year'; + return `${diffY} years ago`; + }; + + const repoCount = useMemo(() => { + if (!rootTree) return '—'; + const rp = rootTree.reposProcessed || rootTree.repos || null; + if (!rp) return '—'; + if (Array.isArray(rp)) return rp.length; + if (typeof rp === 'object') return Object.keys(rp).length; + return '—'; + }, [rootTree]); + + const renderTree = (node, depth = 0) => { + if (!node) return null; + if (node.type === 'file') { + const selected = selectedPath === node.path; + return ( +
setSelectedPath(node.path)}> + + {node.name} +
+ ); + } + const expanded = expandedPaths.has(node.path); + const selected = selectedPath === node.path; + return ( +
+
+
{ toggleDir(node.path); setSelectedPath(node.path); }} className="d-inline-flex align-items-center"> + {expanded ? : } + + {node.name} +
+
+ {expanded && (node.entries || []).map(child => renderTree(child, depth + 1))} +
+ ); + }; + + const childrenForSelected = useMemo(() => { if (!selectedNode) return []; if (selectedNode.type === 'dir') return selectedNode.entries || []; return []; }, [selectedNode]); + + const fileContent = useMemo(() => { if (!selectedNode || selectedNode.type !== 'file') return null; return selectedNode.content || selectedNode.body || selectedNode.text || selectedNode.preview || null; }, [selectedNode]); + + const fileLines = useMemo(() => fileContent ? fileContent.split('\n') : [], [fileContent]); + const lineCount = fileLines.length; + const locCount = fileLines.filter(l => l.trim()).length; + const byteCount = useMemo(() => { + if (!fileContent) return 0; + try { return new TextEncoder().encode(fileContent).length; } catch (e) { return fileContent.length; } + }, [fileContent]); + + return ( +
+
+
+
+
+ + setSearch(e.target.value)} /> +
+ {selectedNode &&
{selectedNode.path}
} +
+
+
+
+ {/* edit button intentionally removed */} +
+
+
+ + {loading &&
Loading…
} + {error &&
{error}
} + {!loading && !displayTree &&
No entries
} + + {!loading && displayTree && ( +
+
+ {/* left tree */} + {displayTree.type === 'dir' && displayTree.name && displayTree.name.toLowerCase().includes('safe-settings') + ? (displayTree.entries || []).map(child => renderTree(child, 0)) + : renderTree(displayTree) + } +
+ +
+ {/* right content (dir/file view) */} + {selectedNode && selectedNode.type === 'dir' && ( +
+ {/* path rendered next to the filter at the top; removed empty toolbar to avoid extra top gap */} +
+ + + + + + + + + + {(childrenForSelected.length === 0) && ( + + )} + {childrenForSelected.map(child => ( + setSelectedPath(child.path)}> + + + + + ))} + +
NameCommit-MessageLast commit date
No entries
+ + {child.type === 'dir' ? : } + {child.name} + + {child.lastCommitMessage || '—'}{child.lastCommitAt ? formatTimeAgo(child.lastCommitAt) : '—'}
+
+
+ )} + + {selectedNode && selectedNode.type === 'file' && ( +
+ {/* path rendered next to the filter at the top; removed empty toolbar to avoid extra top gap */} +
+ {/* file header with border and rounded top, followed by a bordered code area with rounded bottom */} +
+
+
+
+ + +
+
+ +
+
+
+ {fileLines.map((_, i) =>
{i + 1}
)} +
+
+ {fileLines.length === 0 ? ( +
No content available
+ ) : ( + fileLines.map((ln, i) =>
{ln || ' '}
) + )} +
+
+
+
+
+
+ )} + + {!selectedNode && ( +
Select a folder or file from the left to view contents.
+ )} + +
+
+ )} + + {/* footer (items shown) removed */} +
+ ); +} diff --git a/ui/src/app/components/ThemeContext.jsx b/ui/src/app/components/ThemeContext.jsx new file mode 100644 index 000000000..75470143d --- /dev/null +++ b/ui/src/app/components/ThemeContext.jsx @@ -0,0 +1,71 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect } from 'react'; + +const ThemeContext = createContext(); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +export const ThemeProvider = ({ children }) => { + // Always start with 'light' for SSR consistency + const [theme, setTheme] = useState('light'); + const [mounted, setMounted] = useState(false); + + // Only run on client side + useEffect(() => { + setMounted(true); + + // Migrate old theme key if it exists + const oldTheme = localStorage.getItem('safe-settings-theme'); + if (oldTheme && !localStorage.getItem('theme')) { + localStorage.setItem('theme', oldTheme); + localStorage.removeItem('safe-settings-theme'); + } + + // Get theme from localStorage + const savedTheme = localStorage.getItem('theme') || 'light'; + + // Set state (will trigger re-render with correct theme) + setTheme(savedTheme); + + // Apply to DOM immediately to prevent flash + document.documentElement.setAttribute('data-theme', savedTheme); + }, []); + + // Apply theme changes to DOM + useEffect(() => { + if (mounted) { + document.documentElement.setAttribute('data-theme', theme); + } + }, [theme, mounted]); + + const toggleTheme = () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + const setSpecificTheme = (themeName) => { + setTheme(themeName); + localStorage.setItem('theme', themeName); + }; + + // Render children immediately - no hiding + return ( + + {children} + + ); +}; diff --git a/ui/src/app/components/ThemeToggle.jsx b/ui/src/app/components/ThemeToggle.jsx new file mode 100644 index 000000000..803c9d997 --- /dev/null +++ b/ui/src/app/components/ThemeToggle.jsx @@ -0,0 +1,17 @@ +'use client'; + +import { useTheme } from './ThemeContext'; + +export default function ThemeToggle() { + const { theme, toggleTheme, isDark } = useTheme(); + + return ( + + ); +} diff --git a/ui/src/app/components/TitleBar.css b/ui/src/app/components/TitleBar.css new file mode 100644 index 000000000..3da65c8b2 --- /dev/null +++ b/ui/src/app/components/TitleBar.css @@ -0,0 +1,174 @@ +/* TitleBar Component-Specific Styles */ + +/* Header styles */ +.title-header { + background: #333; + color: #fff; + min-height: 40px; /* Ensure consistent height */ +} + +/* Theme-specific header styles */ +[data-theme="light"] .title-header, +body.light-theme .title-header { + background: #333; + color: #fff; +} + +[data-theme="dark"] .title-header, +body.dark-theme .title-header { + background: #161b22; + color: #f0f6fc; +} + +/* Navigation bar - consistent height and styling */ +.title-nav { + min-height: 40px; /* Consistent nav height */ + border-bottom: 1px solid var(--border-color, #dee2e6) !important; + background: var(--bg-secondary, #f6f8fa); /* Default light theme background */ +} + +/* Data-theme selectors for immediate theme application */ +[data-theme="light"] .title-nav, +body.light-theme .title-nav { + background: #f6f8fa; + color: #24292f; + border-bottom: 1px solid #dee2e6 !important; +} + +[data-theme="dark"] .title-nav, +body.dark-theme .title-nav { + background: #22272e; + color: #f6f8fa; + border-bottom: 1px solid #666a6e !important; +} + +/* Theme toggle button */ +.theme-toggle-btn { + border: none !important; + background: transparent !important; + cursor: pointer !important; + padding: 2px !important; + border-radius: 6px !important; +} + +.theme-toggle-btn .theme-toggle-icon { + transition: color 0.15s; + color: lightgray; /* Default icon color */ +} + +/* Theme-specific toggle button styles */ +[data-theme="light"] .theme-toggle-btn .theme-toggle-icon, +body.light-theme .theme-toggle-btn .theme-toggle-icon { + color: #fff; +} + +[data-theme="dark"] .theme-toggle-btn .theme-toggle-icon, +body.dark-theme .theme-toggle-btn .theme-toggle-icon { + color: #f0f6fc; +} + +[data-theme="light"] .theme-toggle-btn:hover, +body.light-theme .theme-toggle-btn:hover { + background: rgba(255, 255, 255, 0.1) !important; +} + +[data-theme="dark"] .theme-toggle-btn:hover, +body.dark-theme .theme-toggle-btn:hover { + background: rgba(240, 246, 252, 0.1) !important; +} + +.theme-toggle-btn:hover .theme-toggle-icon, +.theme-toggle-btn:focus .theme-toggle-icon { + color: yellow; /* Hover icon color */ +} + +/* Navigation links */ +.nav-link-custom { + border: none; +} + +/* Light theme nav links */ +[data-theme="light"] .nav-link-custom, +body.light-theme .nav-link-custom { + color: #24292f !important; +} + +/* Dark theme nav links */ +[data-theme="dark"] .nav-link-custom, +body.dark-theme .nav-link-custom { + color: #f6f8fa; +} + +/* Navigation menu items */ +.nav-link.menu-hover { + border-radius: 5px !important; + margin: 10px 10px 9px 10px !important; + padding: 5px 10px !important; + transition: background 0.15s, color 0.15s; + border: 1px solid transparent !important; /* Invisible border to maintain box model */ +} + +.nav-link.menu-hover:hover { + background: var(--bg-accent) !important; + border-radius: 5px !important; + border: 1px solid transparent !important; /* Keep same border width */ +} + +/* Theme-specific hover colors */ +[data-theme="light"] .nav-link.menu-hover:hover, +body.light-theme .nav-link.menu-hover:hover { + background-color: #eaecef !important; +} + +[data-theme="dark"] .nav-link.menu-hover:hover, +body.dark-theme .nav-link.menu-hover:hover { + background-color: #30363d !important; +} + +/* Override Bootstrap's default nav-tabs border-radius */ +.nav-tabs .nav-link { + border-radius: 5px !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs .nav-link:hover { + border-radius: 5px !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs .nav-link:focus-visible { + outline: 2px solid currentColor !important; + outline-offset: 2px; + box-shadow: none !important; +} + +.nav-tabs .nav-link:active { + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs { + border-top: none; + border-bottom: none; +} + +.menu-hover.active { + background: transparent; + border: none !important; +} + +/* Active menu indicator */ +.menu-active-indicator { + position: absolute; + left: 0; + right: 0; + bottom: -10px; + height: 2px; + background: rgb(253, 140, 115); /* Orange-red underline color */ + border-radius: 1px; +} diff --git a/ui/src/app/components/TitleBar.jsx b/ui/src/app/components/TitleBar.jsx new file mode 100644 index 000000000..f3751ca8f --- /dev/null +++ b/ui/src/app/components/TitleBar.jsx @@ -0,0 +1,148 @@ +"use client"; +import { + GearIcon, + GlobeIcon, + ListUnorderedIcon, + MoonIcon, + NoteIcon, + SunIcon, +} from "@primer/octicons-react"; +import { usePathname } from "next/navigation"; +import { withBasePath } from "../utils/basePath"; +import { useTheme } from "./ThemeContext"; +import "./TitleBar.css"; + +export default function TitleBar() { + const pathname = usePathname(); + const { isDark, toggleTheme } = useTheme(); + + // Always render the TitleBar structure to prevent layout shift + return ( + <> +
+
+ + + + + + Safe-Settings Hub Dashboard + + +
+ +
+
+
+ + + ); +} diff --git a/ui/src/app/dashboard/env/page.jsx b/ui/src/app/dashboard/env/page.jsx new file mode 100644 index 000000000..6022b0b96 --- /dev/null +++ b/ui/src/app/dashboard/env/page.jsx @@ -0,0 +1,23 @@ +import TitleBar from "../../components/TitleBar"; +import EnvVariables from "../../components/EnvVariables"; + +export default function EnvVarsPage() { + return ( +
+ +
+
+

App Environment Settings

+

+ These are the current settings used by the app. Some values are hidden or + masked for security. +

+
+
+
+ +
+
+
+ ); +} diff --git a/ui/src/app/dashboard/help/page.jsx b/ui/src/app/dashboard/help/page.jsx new file mode 100644 index 000000000..866aa7fae --- /dev/null +++ b/ui/src/app/dashboard/help/page.jsx @@ -0,0 +1,35 @@ +'use client'; + +import TitleBar from "../../components/TitleBar"; +import Link from "next/link"; +import HubOrgGraph from "../../components/HubOrgGraph"; + +export default function HelpPage() { + return ( +
+ +
+

Dashboard & Hub - Help

+

Quick guidance for the Safe-Settings Dashboard and Hub.

+ +

+

What is the Safe-Settings Dashboard

+

+ This UI provides status information for the Safe-Settings Hub feature. It is a read-first reporting and status tool that displays configuration state and import/sync status. +

+

How to get started

+

+ The Organizations page lists every Org where the Safe-Settings Hub is installed. You can use the Retrieve Settings button to perform an initial import from the selected organizations' config repositories. It reads files from the configured CONFIG_PATH in each organization's config repo and commits them into a single branch in the hub repository, then opens a pull request for review. This is intended for initial population or one-time imports — the action will skip organizations that already have content in the hub path. +

+

How to edit configuration

+

+ The dashboard is not a content editor. To change configuration you should edit files in your admin repository and follow the normal GitHub workflow: commit changes, open a pull request, get required approvers to review, and merge. After the PR is merged the dashboard will reflect the updated state. +

+
+

+ If you need more help, check the repository documentation or contact the maintainers. +

+
+
+ ); +} diff --git a/ui/src/app/dashboard/logs/page.jsx b/ui/src/app/dashboard/logs/page.jsx new file mode 100644 index 000000000..dc2bf1e3f --- /dev/null +++ b/ui/src/app/dashboard/logs/page.jsx @@ -0,0 +1,146 @@ +"use client" +import { useEffect, useState } from 'react' +import TitleBar from '../../components/TitleBar' +import { withBasePath } from '../../utils/basePath' + +export default function LogsPage () { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const logLevels = ['INFO', 'WARN', 'DEBUG', 'ERROR'] + const [selectedLevels, setSelectedLevels] = useState(new Set(logLevels)) + const [search, setSearch] = useState('') + const [syncOnly, setSyncOnly] = useState(false) + + useEffect(() => { + async function fetchLogs() { + try { + setLoading(true) + const response = await fetch(withBasePath('/api/safe-settings/hub/log?lines=100')) + if (!response.ok) throw new Error('Failed to fetch logs') + const data = await response.json() + setEntries(data.entries || []) + setError(null) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + fetchLogs() + }, []) + + const toggleLevel = (lvl) => { + const next = new Set(selectedLevels) + if (next.has(lvl)) next.delete(lvl) + else next.add(lvl) + setSelectedLevels(next) + } + + const filtered = entries.filter(e => { + // Filter by log level + if (!selectedLevels.has(e.level.toUpperCase())) return false + + // Filter by search term + if (search.trim() !== '' && !e.message.toLowerCase().includes(search.trim().toLowerCase())) return false + + // Filter by sync-only if enabled + if (syncOnly && !e.message.toLowerCase().includes('sync')) return false + + return true + }) + + return ( + <> + +
+
+
+
+

Safe Settings Hub-Sync Log

+

Last 100 entries from hubSyncHandler.log

+ {loading &&
Loading logs...
} + {error &&
Error: {error}
} +
+
+
+
+
+
+
Filter Options
+
+ Log Levels: +
+ {logLevels.map(lvl => ( + + ))} +
+
+
+ +
+
+ Search Message: + setSearch(e.target.value)} + style={{ maxWidth: 300 }} + /> +
+
+
+
+
+
+
+
Log Entries
+
+ + + + + + + + + + {filtered.map((row, i) => { + let levelClass = '' + if (row.level === 'ERROR') levelClass = 'log-error' + else if (row.level === 'WARN') levelClass = 'log-warn' + return ( + + + + + + ) + })} + +
TimestampLevelMessage
{row.timestamp || '-'}{row.level || 'UNKNOWN'}{row.message}
+ {filtered.length === 0 &&
No log entries match your filters.
} +
+
+
+
+
+ + ) +} diff --git a/ui/src/app/dashboard/organizations/page.jsx b/ui/src/app/dashboard/organizations/page.jsx new file mode 100644 index 000000000..596f9299f --- /dev/null +++ b/ui/src/app/dashboard/organizations/page.jsx @@ -0,0 +1,24 @@ +import TitleBar from "../../components/TitleBar"; +import OrganizationsTable from "../../components/OrganizationsTable"; + +export default function OrganizationsPage() { + return ( +
+ +
+
+

+ Organizations +

+

+ List all the Organizations where the Safe-Settings App is installed and the last time Safe-settings configurations were synced. +

+
+ +
+ +
+
+
+ ); +} diff --git a/ui/src/app/dashboard/page.jsx b/ui/src/app/dashboard/page.jsx new file mode 100644 index 000000000..7e4787fc5 --- /dev/null +++ b/ui/src/app/dashboard/page.jsx @@ -0,0 +1,25 @@ +import TitleBar from "../components/TitleBar"; +import { AlertIcon, ArrowRightIcon, CheckCircleIcon, GitCommitIcon, GitPullRequestIcon, GitMergeIcon, EyeIcon } from "@primer/octicons-react"; + +export default function DashboardPage() { + return ( +
+ +
+

Welcome to the Safe-Settings Hub Dashboard

+

Select a menu item above to get started.

+

+ This dashboard is a read-first reporting interface that displays configuration state and sync activity status for the Safe-Settings Hub.
+
It is not intended as the workflow for editing Safe-Settings Hub configuration content.

+ +
To make changes, please use the standard GitHub process for content updates:


+ Commit        + Pull Request        + Approve        + Merge         + +

+
+
+ ); +} diff --git a/ui/src/app/dashboard/safe-settings-hub/page.jsx b/ui/src/app/dashboard/safe-settings-hub/page.jsx new file mode 100644 index 000000000..56a4ef355 --- /dev/null +++ b/ui/src/app/dashboard/safe-settings-hub/page.jsx @@ -0,0 +1,25 @@ +import TitleBar from "../../components/TitleBar"; +import MasterAdminContents from "../../components/Safe-settings-hubContent"; + +export default function SafeSettingsHubConfigPage() { + return ( +
+ +
+
+

+ Safe-Settings Hub Content +

+

+ Listing files maintained by the Safe-Settings Global configuration (all ORG's). + Files are retrieved from `/api/safe-settings/hub/content`. +

+
+
+
+ +
+
+
+ ); +} diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css new file mode 100644 index 000000000..0dbf38c60 --- /dev/null +++ b/ui/src/app/globals.css @@ -0,0 +1,277 @@ +/* Global Theme Variables */ +/* Default theme variables (light theme as default) */ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-accent: #eaecef; + --text-primary: #24292f; + --text-secondary: #6c757d; + --border-color: #dee2e6; +} + +/* Theme variables based on data-theme attribute */ +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-accent: #eaecef; + --text-primary: #24292f; + --text-secondary: #6c757d; + --border-color: #dee2e6; +} + +[data-theme="dark"] { + --bg-primary: rgb(13, 17, 22); + --bg-secondary: #444444; + --bg-accent: #30363d; + --text-primary: #f0f6fc; + --text-secondary: #dddddd; + --border-color: #4d4d4d; +} + +/* Legacy support for body classes */ +body.light-theme { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-accent: #eaecef; + --text-primary: #24292f; + --text-secondary: #6c757d; + --border-color: #dee2e6; +} + +body.dark-theme { + --bg-primary: #161b22; + --bg-secondary: #444444; + --bg-accent: #30363d; + --text-primary: #f0f6fc; + --text-secondary: #b3b3b3; +} + +/* Global Theme Styles */ +/* Default body styling (light theme as default) */ +body { + background: var(--bg-primary, #fff) !important; + color: var(--text-primary, #24292f) !important; +} + +/* Theme-specific body styles using data-theme */ +[data-theme="light"] body, +body.light-theme { + background: #fff !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] body, +body.dark-theme { + background: rgb(24, 24, 24) !important; + color: var(--text-primary) !important; +} + +/* Global Main Element Theme */ +[data-theme="light"] main, +body.light-theme main { + background: #fff !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] main, +body.dark-theme main { + /* background: #161b22; */ + color: var(--text-primary) !important; +} + +[data-theme="light"] .nav-link, +body.light-theme .nav-link { + color: var(--text-primary) !important; +} + +[data-theme="dark"] .nav-link, +body.dark-theme .nav-link { + /* color: #f6f8fa !important; */ + color: #6c757d !important; +} + +/* title bar nav tabs */ +[data-theme="dark"] .nav-tabs, +body.dark-theme .nav-tabs { + background: #22272e; + border: none !important; +} + +[data-theme="light"] .nav-tabs, +body.light-theme .nav-tabs { + border: none !important; +} + +/* Apply theme variables to main element */ +main { + color: var(--text-primary) !important; + /* padding: 1rem; */ + border-radius: 12px !important; + /* margin-top: 1rem; */ +} + +/* Theme Utility Classes */ +.theme-bg-primary { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.theme-bg-secondary { + background-color: var(--bg-secondary); + color: var(--text-primary) !important; +} + +.theme-bg-accent { + background-color: var(--bg-accent); + color: var(--text-primary) !important; +} + +.theme-text-primary { + color: var(--text-primary) !important; +} + +.theme-text-secondary { + color: var(--text-secondary) !important; +} + +.theme-border { + border-color: var(--border-color) !important; + /* override bootstrap .border */ +} + +.border.theme-border, +.theme-border.border { + border-color: var(--border-color) !important; +} + +/* Global Font Utility Classes */ +.dark-font { + color: var(--text-primary) !important; +} + +.light-font { + color: var(--text-primary) !important; +} + +/* Organizations Table Styles */ +.ui-table .table { + background-color: var(--bg-primary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .table thead th { + background-color: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; + font-weight: 600; +} + +.ui-table .table tbody td { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .table tbody tr:hover { + background-color: var(--bg-accent) !important; +} + +.ui-table .sortable-header:hover { + background-color: var(--bg-accent) !important; +} + +.ui-table .input-group-text { + background-color: var(--bg-secondary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .form-control { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .form-control:focus { + background-color: var(--bg-primary); + color: var(--text-primary) !important; + border-color: var(--border-color) !important; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.ui-table .form-control::placeholder { + color: var(--text-secondary) !important; +} + +.list-group-item { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.list-group-item:hover { + background-color: var(--bg-accent) !important; +} + +.text-muted { + color: var(--text-secondary) !important; +} + +span.text-muted { + color: var(--text-secondary) !important; +} + +code { + color: var(--text-primary) !important; +} + +element { + color: var(--text-primary) !important; + background-color: var(--bg-secondary) !important; + border: 1px solid var(--border-color) !important; + margin-left: 10px !important; + gap: 1.5rem !important; +} + +.input-group-text { + background-color: var(--bg-secondary) !important; + border: 1px solid var(--border-color) !important; + color: var(--text-primary) !important; +} + +.table { + border-radius: 12px !important; + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-color) !important; +} + +/* Env vars table dark mode override */ +[data-theme="dark"] .env-vars table, +body.dark-theme .env-vars table { + background-color: var(--bg-primary) !important; +} + +[data-theme="dark"] .env-vars thead th, +body.dark-theme .env-vars thead th { + background-color: var(--bg-secondary) !important; +} + +th { + font-weight: 600; + background-color: var(--bg-secondary) !important; +} + +tr td { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.log-error { + color: #c00 !important; +} + +.log-warn { + color: #b8860b !important; +} \ No newline at end of file diff --git a/ui/src/app/hooks/useClientSafe.js b/ui/src/app/hooks/useClientSafe.js new file mode 100644 index 000000000..fabb0a510 --- /dev/null +++ b/ui/src/app/hooks/useClientSafe.js @@ -0,0 +1,45 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * Custom hook to handle client-side mounting + * Helps prevent hydration mismatches by ensuring client-specific code + * only runs after the component has mounted on the client + */ +export const useIsClient = () => { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return isClient; +}; + +/** + * Custom hook for client-safe date formatting + * Returns a consistent format between server and client to prevent hydration issues + */ +export const useClientSafeDate = () => { + const isClient = useIsClient(); + + const formatDate = (dateString) => { + if (!isClient) { + // Server-side: return a simple format that matches potential client output + return new Date(dateString).toISOString().split('T')[0]; + } + + // Client-side: full formatting + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return { formatDate, isClient }; +}; diff --git a/ui/src/app/hooks/useHydrated.js b/ui/src/app/hooks/useHydrated.js new file mode 100644 index 000000000..7ab514a7f --- /dev/null +++ b/ui/src/app/hooks/useHydrated.js @@ -0,0 +1,18 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * Hook that ensures consistent rendering between server and client + * Prevents hydration mismatches by showing a simple version first, + * then upgrading to the full version after hydration + */ +export const useHydrated = () => { + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + }, []); + + return hydrated; +}; diff --git a/ui/src/app/layout.jsx b/ui/src/app/layout.jsx new file mode 100644 index 000000000..1104d2572 --- /dev/null +++ b/ui/src/app/layout.jsx @@ -0,0 +1,41 @@ +import './globals.css'; +import { ThemeProvider } from './components/ThemeContext'; + +// (Optional) Next.js App Router metadata API – safe to add +export const metadata = { + title: 'Safe Settings', + description: 'Safe Settings dashboard', + icons: { + icon: [ + { url: '/favicon.svg', type: 'image/svg+xml' }, + { url: '/favicon.ico', sizes: 'any' } + ], + apple: '/apple-touch-icon.png', + shortcut: '/favicon.ico' + } +}; + +export default function RootLayout({ children }) { + return ( + + + {/* Existing Bootstrap CSS */} + + {/* Favicon / icons */} + + + {/* Optional apple-touch-icon (provide file or remove link) */} + {/* */} + + + + + {children} + + + + ); +} \ No newline at end of file diff --git a/ui/src/app/not-found.jsx b/ui/src/app/not-found.jsx new file mode 100644 index 000000000..1ced869a6 --- /dev/null +++ b/ui/src/app/not-found.jsx @@ -0,0 +1,16 @@ +"use client"; +import TitleBar from "./components/TitleBar"; +import { withBasePath } from "./utils/basePath"; + +export default function NotFound() { + return ( +
+ +
+

404

+

Sorry, the page you are looking for does not exist.

+ Go to Dashboard +
+
+ ); +} diff --git a/ui/src/app/utils/basePath.js b/ui/src/app/utils/basePath.js new file mode 100644 index 000000000..59ffb55e1 --- /dev/null +++ b/ui/src/app/utils/basePath.js @@ -0,0 +1,36 @@ +/** + * URL prefix utility for handling deployment behind proxies + * Reads from Next.js basePath configuration + */ + +// Normalize URL prefix: add leading '/' if missing, treat '/' as empty for root +const normalizePrefix = (prefix) => { + if (!prefix || prefix === '/') return ''; + return prefix.startsWith('/') ? prefix : `/${prefix}`; +}; + +// Next.js automatically handles basePath for routing and asset loading +// We expose this for manual link construction (URL prefix in browser) +export const BASE_PATH = typeof window !== 'undefined' + ? normalizePrefix(window.__NEXT_DATA__?.basePath ?? '/safe-settings') + : normalizePrefix(process.env.NEXT_PUBLIC_SAFE_SETTINGS_HUB_URL_PREFIX ?? process.env.SAFE_SETTINGS_HUB_URL_PREFIX ?? '/safe-settings'); + +/** + * Prepend base path to a URL + * Note: Next.js Link component and router.push already handle basePath automatically + * This is primarily for tags and manual URL construction + * @param {string} path - The path to prepend base path to + * @returns {string} The full path with base path + */ +export function withBasePath(path) { + // Ensure path starts with / + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + // If no base path, return as-is + if (!BASE_PATH) return normalizedPath; + + // Remove trailing slash from base path if present + const cleanBasePath = BASE_PATH.endsWith('/') ? BASE_PATH.slice(0, -1) : BASE_PATH; + + return `${cleanBasePath}${normalizedPath}`; +} From 4b641ab051de1189c13b176dd80f559a82e0e174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Tue, 13 Oct 2026 16:12:24 -0400 Subject: [PATCH 2/9] fixed checks --- index.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index ca82fc99b..3ddaaa07e 100644 --- a/index.js +++ b/index.js @@ -24,15 +24,27 @@ let deploymentConfig module.exports = (robot, { addHandler }, Settings = require('./lib/settings')) => { let appSlug = 'safe-settings' + let httpRoutesRegistered = false // Initialize all routes (static UI + API) via centralized module // Full-sync runs without Probot's HTTP server; only initialize routes when // the runtime provides one. if (typeof addHandler === 'function') { - setupRoutes(robot, addHandler) + try { + setupRoutes(robot, addHandler) + httpRoutesRegistered = true + } catch (error) { + if (error && error.message === 'No server instance') { + robot.log.debug('HTTP server is unavailable; skipping UI and API route registration') + } else { + throw error + } + } + } + // Prefetch for the dashboard; other runtimes refresh the cache lazily. + if (httpRoutesRegistered) { + initCache(robot) } - // Initialize installation cache (env-controlled prefetch) - initCache(robot) // Cache of enterprise slug → enterprise installation id. Keyed by slug so a // cached id is never reused for a different enterprise (e.g. when the app From 6e7e08cb8f016210e4d9e717ad14aa893efaf96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Mon, 2 Nov 2026 15:42:50 -0500 Subject: [PATCH 3/9] Refactor routes, improve API handling and UI components Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/hubSyncHandler/README.md | 8 ++++---- index.js | 2 +- lib/routes.js | 12 ++++++------ ui/src/app/components/EnvVariables.jsx | 6 +++--- ui/src/app/components/OrganizationsTable.jsx | 10 +++++----- ui/src/app/components/Safe-settings-hubContent.jsx | 4 ++-- ui/src/app/globals.css | 2 +- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/hubSyncHandler/README.md b/docs/hubSyncHandler/README.md index bdec791d0..7e3fbf272 100644 --- a/docs/hubSyncHandler/README.md +++ b/docs/hubSyncHandler/README.md @@ -252,9 +252,9 @@ rules: ##### `mergeStrategy` - **Type:** String (`merge`, `overwrite`, `preserve`) -- **Description:** Determines how files are synced: - - `merge`: use a PR to sync files - - `overwrite`: Sync all files, replacing existing ones (direct commit, no PR) +- **Description:** This field is retained in normalized rules but does not currently control delivery. + - PR versus direct-push delivery is controlled globally by `SAFE_SETTINGS_HUB_DIRECT_PUSH`. + - Per-rule `merge`, `overwrite`, and `preserve` behavior is not currently applied. - **Example:** ```yaml mergeStrategy: merge @@ -276,7 +276,7 @@ rules: |------|---------|---------| | `SAFE_SETTINGS_HUB_REPO` | Repo for master safe-settings contents | admin-master | | `SAFE_SETTINGS_HUB_ORG` | Organization that hold the Repo | admin-master-org | -| `SAFE_SETTINGS_HUB_PATH` | source folder | .github/safe-settings | +| `SAFE_SETTINGS_HUB_PATH` | source folder relative to `CONFIG_PATH` | safe-settings | | `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Use a PR or direct commit | false | diff --git a/index.js b/index.js index 3ddaaa07e..67cf7e846 100644 --- a/index.js +++ b/index.js @@ -911,7 +911,7 @@ module.exports = (robot, { addHandler }, Settings = require('./lib/settings')) = const baseRef = pull_request.base.ref || repository.default_branch - if (hubSyncFilesChanged.length > 0) { + if (hubMasterRepo && hubSyncFilesChanged.length > 0) { robot.log.info(`Hub-sync master files detected: ${hubSyncFilesChanged.join(', ')}`) const { validateAndReportHubSync } = require('./lib/hubSyncHandler') return validateAndReportHubSync(robot, context, payload, pull_request, hubSyncFilesChanged, baseRef) diff --git a/lib/routes.js b/lib/routes.js index 8d4564884..6b1e58737 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -357,7 +357,7 @@ function setupRoutes (robot, addHandler) { fullPath = wildcardPath ? path.posix.join(env.CONFIG_PATH, wildcardPath) : env.CONFIG_PATH // recursive defaults to true unless explicitly disabled with recursive=false const recursive = req.query.recursive !== 'false' - let maxDepth = parseInt(req.query.maxDepth, 5) + let maxDepth = parseInt(req.query.maxDepth, 10) if (isNaN(maxDepth) || maxDepth < 1) maxDepth = 5 // safety default if (maxDepth > 8) maxDepth = 5 // hard cap to avoid abuse // Unified flag: fetchContent (default true). No other legacy params supported. @@ -372,7 +372,7 @@ function setupRoutes (robot, addHandler) { if (cached) { perRequestCommitCache.set(p, cached); return cached } let meta try { - const commits = await github.rest.repos.listCommits({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, per_page: 1, path: p }) + const commits = await github.rest.repos.listCommits({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, sha: ref, per_page: 1, path: p }) .then(r => Array.isArray(r.data) ? r.data : []) if (commits.length) { const c = commits[0] @@ -607,7 +607,7 @@ function setupRoutes (robot, addHandler) { } router.get('/api/safe-settings/hub/content', hubContent) - router.get('/api/safe-settings/hub/content/*', hubContent) + router.get(/^\/api\/safe-settings\/hub\/content\/(.*)$/, hubContent) /** * GET /api/safe-settings/app/env @@ -729,9 +729,9 @@ function setupRoutes (robot, addHandler) { }) // Alias for backwards compatibility - router.get('/api/safe-settings/logs', (req, res, next) => { - req.url = '/api/safe-settings/hub/log' + (req._parsedUrl.search || '') - next('route') + router.get('/api/safe-settings/logs', (req, res) => { + const search = new URL(req.originalUrl, 'http://localhost').search + res.redirect(307, `${basePath}/api/safe-settings/hub/log${search}`) }) // GET /api/safe-settings/sync-logs diff --git a/ui/src/app/components/EnvVariables.jsx b/ui/src/app/components/EnvVariables.jsx index c68a0405d..847e687d6 100644 --- a/ui/src/app/components/EnvVariables.jsx +++ b/ui/src/app/components/EnvVariables.jsx @@ -115,9 +115,9 @@ export default function EnvVariables() { - - - + + + diff --git a/ui/src/app/components/OrganizationsTable.jsx b/ui/src/app/components/OrganizationsTable.jsx index 4a8f621b0..d435855a5 100644 --- a/ui/src/app/components/OrganizationsTable.jsx +++ b/ui/src/app/components/OrganizationsTable.jsx @@ -359,9 +359,9 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { {r.pr} - ) : r.skipped ? ( + ) : r.status === "skipped" || r.status === "N/A" ? (
- Skipping {r.org}: already present in hub + {r.org}: {r.reason || r.status}
) : r.error ? (
@@ -417,11 +417,11 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => {
diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 0dbf38c60..0d78771c8 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -87,7 +87,7 @@ body.light-theme .nav-link { [data-theme="dark"] .nav-link, body.dark-theme .nav-link { /* color: #f6f8fa !important; */ - color: #6c757d !important; + color: var(--text-primary) !important; } /* title bar nav tabs */ From 7e47fe72d8b18b94cecc077001072070cc27445a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Tue, 3 Nov 2026 12:43:29 -0500 Subject: [PATCH 4/9] Enhance pull request check and update scripts Update pull request handling and modify package scripts. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- index.js | 2 +- ui/package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 67cf7e846..644661234 100644 --- a/index.js +++ b/index.js @@ -833,7 +833,7 @@ module.exports = (robot, { addHandler }, Settings = require('./lib/settings')) = * @param {Object} context - The context object provided by Probot */ robot.on('pull_request.closed', async context => { - if (!context.payload.pull_request?.merged) return null + if (!context.payload.pull_request?.merged || context.payload.pull_request.base.ref !== context.payload.repository.default_branch) return null try { await hubSyncHandler(robot, context) } catch (err) { diff --git a/ui/package.json b/ui/package.json index 7c35f0ace..70c2860c8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,8 +5,7 @@ "scripts": { "dev": "next dev --turbopack --port 3001", "build": "next build", - "export": "next build", - "start": "next start" + "export": "next build" }, "dependencies": { "@primer/octicons-react": "^19.15.5", From fccd16e03bc63983b3064ba5d084a655fab20981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Wed, 4 Nov 2026 15:01:31 -0500 Subject: [PATCH 5/9] addressed PR comments and added hub-smoke-test --- .gitignore | 3 + docs/hubSyncHandler/README.md | 27 + docs/hubSyncHandler/usecase-merge-behavior.md | 109 ++ index.js | 7 +- lib/hubSyncHandler.js | 290 +++- lib/routes.js | 141 +- package.json | 6 +- smoke-test-hub-sync.js | 1083 +++++++++++++ test/unit/index-config-errors.test.js | 60 +- .../lib/hubSyncHandler-default-branch.test.js | 183 +++ test/unit/lib/hubSyncHandler-import.test.js | 6 + .../lib/hubSyncHandler-inheritance.test.js | 185 +++ test/unit/lib/hubSyncHandler-merge.test.js | 102 ++ test/unit/routes-sync-status.test.js | 423 ++++++ ui/package-lock.json | 1333 ++++++++++++++++- ui/src/app/components/OrganizationsTable.jsx | 19 +- .../components/Safe-settings-hubContent.jsx | 26 +- 17 files changed, 3841 insertions(+), 162 deletions(-) create mode 100644 smoke-test-hub-sync.js create mode 100644 test/unit/lib/hubSyncHandler-default-branch.test.js create mode 100644 test/unit/lib/hubSyncHandler-inheritance.test.js create mode 100644 test/unit/lib/hubSyncHandler-merge.test.js create mode 100644 test/unit/routes-sync-status.test.js diff --git a/.gitignore b/.gitignore index c4fcc6909..bce6ff0e3 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ samconfig.toml # test file to be ignored test.log reports + +# Hub-Sync smoke test teardown registry +.smoke-hub-sync-state.json diff --git a/docs/hubSyncHandler/README.md b/docs/hubSyncHandler/README.md index 7e3fbf272..6658d82d5 100644 --- a/docs/hubSyncHandler/README.md +++ b/docs/hubSyncHandler/README.md @@ -134,6 +134,24 @@ Any settings file in the `organizations/` directory will be synced to the s These two options only require that you provide the files you would like to sync, in the correct sub-directory. +#### How `settings.yml` is merged (globals + organization) + +For `settings.yml`, the spoke file is **generated**: `globals/settings.yml` is merged with `organizations//settings.yml`, and the organization file takes precedence. Because of this: + +- **Deleting a section from the organization file does not remove it from the spoke.** If globals defines the same section, the organization inherits it again. The Hub-Sync PR check warns about this. +- **To stop inheriting a section from globals, set it to `null` in the organization file:** + + ```yaml + # organizations/my-org/settings.yml + branches: null # my-org does not inherit globals' branch protection settings + ``` + + Hub-Sync drops the section from the generated spoke file, and the PR check lists it under *Inheritance Overrides*. Safe-Settings then no longer manages that section for the organization; it does **not** delete existing settings on GitHub. +- An empty list (`branches: []`) does **not** opt out. Lists are merged, so the globals entries remain. +- To **actively remove** something (for example, branch protection), configure it explicitly. For example, `protection: null` on a branch entry in `branches` deletes that branch's protection. +- Only top-level sections can be opted out this way. Removing individual list entries (a single label or ruleset, for example) that globals defines requires changing globals. +- List entries are matched by identity (`name`, `username`, `login`, `title`, `key_prefix`, `context`, `actor_type`+`actor_id`, `type`+`id`, or `type`). Matching entries are merged, with the organization's values winning; nested lists are merged the same way. Exact duplicates are not added, so repeating a globals entry in the organization file is harmless. + #### ✅ Step 5.1: Configure the `manifest.yml` in the 'Master' Safe-Settings Admin Config Repo (Hub) The `manifest.yml` defines the sync rules for global settings distribution. @@ -308,6 +326,15 @@ The following table summarizes the Safe Settings API endpoints: | `/api/safe-settings/hub/import` | POST | Import settings from orgs into the hub | Import org settings | | `/api/safe-settings/app/env` | GET | App environment/config variables | Get env vars | +When activity is enabled, installation sync status compares the hub's +organization configuration tree, including nested `repos/` and `suborgs/` +directories, against the corresponding relative paths under the organization's +`CONFIG_PATH`. The root `settings.yml` is compared with the generated output +from the hub's global and organization settings, including Hub-Sync's generated +header. Missing or differing managed files, or errors reading managed content, +prevent `isInSync` from being `true`. Extra files that exist only in the +organization repository do not affect the comparison. + **Examples:** - Fetch org installation status: ```http diff --git a/docs/hubSyncHandler/usecase-merge-behavior.md b/docs/hubSyncHandler/usecase-merge-behavior.md index 7ecd57fd6..4bc8c4f4c 100644 --- a/docs/hubSyncHandler/usecase-merge-behavior.md +++ b/docs/hubSyncHandler/usecase-merge-behavior.md @@ -1,2 +1,111 @@ # Github Safe-Settings HubSync Usecases +This page lists what happens when you change the Hub repo, as **Given / When / Then** cases. Paths are relative to the Hub folder (`//`, for example `.github/safe-settings/`). The spoke is `/`, and files land under `CONFIG_PATH` (for example `.github/`). + +Every sync is triggered by a Hub PR that is **merged into the Hub repo's default branch**. Before the merge, the Hub PR check (section F) validates the PR. + +**Smoke test** column: whether the case can be automated in [`smoke-test-hub-sync.js`](../../smoke-test-hub-sync.js). + +- **Yes**: it only touches the sandbox target org. +- **Partial**: needs extra setup, or only part of it can be verified. +- **No**: it changes other orgs, or needs an app restart or a different environment. Cover these with unit tests or manual tests. + +Automated today (`HUB_SMOKE_TARGET_ORG= node smoke-test-hub-sync.js [--phase N,...]`): + +| Phase | Cases | +|-------|-------| +| 1 | H1 | +| 2 | F2, E2 | +| 3 | A1, A2, A4, A5, C1, C3–C8, F1, F3 | +| 4 | C2, F3 | +| 5 | E3 | +| 6 | H2 (skipped when the app runs with `SAFE_SETTINGS_HUB_REIMPORT=true`) | + +Known gaps in the current implementation are marked ⚠️. + +--- + +## A. Organization sync (`organizations//`) + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| A1 | `organizations//settings.yml` exists in the Hub | A Hub PR that changes it is merged | `/` gets a sync PR (`safe-settings-sync/pr---`). Its `settings.yml` is **generated** by merging `globals/settings.yml` with the org file, with org values winning (see section C). | Yes | +| A2 | A non-settings file, such as `organizations//repos/my-repo.yml` or `suborgs/team.yml` | A Hub PR that adds or changes it is merged | The file is copied **as is** (no merge) to the same relative path under `CONFIG_PATH` in the spoke. | Yes | +| A3 | A Hub PR changes files for several orgs | It is merged | Each org gets its **own** sync PR, containing only its own files. | Partial (needs a second sandbox org) | +| A4 | A file sits directly in `organizations/`, not in an org subfolder | A Hub PR that changes it is merged | Nothing is synced. | Yes | +| A5 | A Hub PR changes both the org's `settings.yml` and other files | It is merged | One sync PR contains the merged `settings.yml` and the copied files. | Yes | + +## B. Globals sync (`globals/`) + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| B1 | `globals/settings.yml` exists | A Hub PR that changes it is merged | Every org that has an `organizations//` folder **and** matches the manifest `targets` gets a sync PR (`safe-settings-sync/globals-`) with a regenerated, merged `settings.yml`. | No (changes every targeted org) | +| B2 | An org matches the manifest but has **no** `organizations//` folder | `globals/settings.yml` changes | That org does **not** receive `settings.yml`. The org list for `settings.yml` comes from the `organizations/` folders. | No (changes every targeted org) | +| B3 | Another globals file exists, such as `globals/suborgs/x.yml` | A Hub PR that changes it is merged | The file is copied to **every org where the app is installed** that matches the manifest and has `ADMIN_REPO`. | No (changes every targeted org) | +| B4 | `globals/manifest.yml` changes | The Hub PR is merged | `manifest.yml` is never synced; it exists only in the Hub. | No (changes Hub sync behavior for all orgs) | + +## C. `settings.yml` merge behavior (globals + organization) + +These cases are triggered through the org file (A1), so they only affect the sandbox org. + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| C1 | Globals and the org file both define `repository` with different values | The org file is synced | The org's values win for overlapping keys, and keys that only globals sets are kept. | Yes | +| C2 | Globals defines `branches`, and the org file used to as well | `branches` is **deleted** from the org file | The org **still inherits** `branches` from globals. The Hub PR check warns: "`branches` was removed … but `globals/settings.yml` still defines it". | Yes | +| C3 | Globals defines `branches` | The org file sets `branches: null` | `branches` is left out of the spoke `settings.yml`, and the header lists it under "Not inherited from globals". The Hub PR check lists it under **ℹ️ Inheritance Overrides**. Existing branch protection on GitHub is **not** deleted. | Yes | +| C4 | Globals defines `branches` | The org file sets `branches: []` | This does **not** opt out. Lists are merged, so the globals entries stay. | Yes | +| C5 | The org file has a branch entry with `protection: null` | It is synced | The nested `null` is **kept**, so Safe-Settings removes that branch's protection. | Yes | +| C6 | Globals and the org file both define a list entry with the same identity (for example label `bug`, ruleset `main-protect`, or collaborator `octocat`) | The org file is synced | One merged entry, with the org's values winning, and **no duplicate**. Nested lists inside the entry are merged the same way. | Yes | +| C7 | The org file repeats a globals list entry exactly | It is synced | No duplicate is added. | Yes | +| C8 | The org file adds a new list entry, such as a label `docs` | It is synced | The entry is added next to the globals entries. | Yes | +| C9 | No `organizations//settings.yml` exists | `globals/settings.yml` changes | The org is skipped for `settings.yml`. This is the same as B2. | No | + +List identity fields, in order: `name`, `username`, `login`, `title`, `key_prefix`, `context`, then `actor_type` with `actor_id`, `type` with `id`, and finally `type`. + +## D. Manifest control (`globals/manifest.yml`) + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| D1 | The manifest `targets` do **not** match the org | An org file changes | The org is not synced. The Hub PR check lists it as excluded. | Partial (check only, on an unmerged PR) | +| D2 | The manifest `files` patterns do not match a changed file | The Hub PR is merged | That file is skipped. The Hub PR check lists it as excluded. | Partial (check only, on an unmerged PR) | +| D3 | A rule has `enabled: false` | The rule would otherwise match | The rule is ignored. | Partial (check only, on an unmerged PR) | +| D4 | Wildcard targets (`"*"`, `"acme-*"`) | An org name matches | The org is included. | Partial (check only, on an unmerged PR) | + +## E. Delivery and triggers + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| E1 | `SAFE_SETTINGS_HUB_DIRECT_PUSH=true` | A Hub PR is merged | Changes are committed straight to the spoke's `main` and no sync PR is opened. | No (needs an app restart with a different env) | +| E2 | A Hub PR is **closed without merging** | — | Nothing is synced. | Yes | +| E3 | A Hub PR is merged into a branch that is **not** the default branch | — | Nothing is synced. | Yes | +| E4 | A merged PR in a repo that is **not** the configured Hub repo | — | Hub-Sync ignores it. | No | + +## F. Hub PR check (before merge) + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| F1 | A Hub PR changes files under `globals/` or `organizations/` | The PR is opened or updated | A check run completes with a summary of the target orgs, files, warnings and inheritance overrides. The summary is also posted as a PR comment. | Yes | +| F2 | A changed file contains **invalid YAML** | The PR is opened or updated | The check concludes **failure**. | Yes | +| F3 | Only warnings, such as C2 or excluded orgs or files | The PR is opened or updated | The check concludes **success**, with the warnings listed. | Yes | + +## G. Failure and edge cases + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| G1 | The app is **not installed** on the target org | A sync is triggered | The org is skipped with a log message, and the other orgs still sync. The smoke test checks this before setup. | No (pre-flight only) | +| G2 | The target org has **no `ADMIN_REPO`** | A globals sync is triggered | The org is skipped (`config repo '' does not exist`). | Partial | +| G3 | ⚠️ A file is **deleted** under `organizations//` | The Hub PR is merged | The spoke copy is **not** deleted. Reading the removed file fails, so that org's sync stops, which can leave an empty sync branch behind. Expected: the deletion is synced, or the file is skipped. | Yes (documents the gap) | +| G4 | The spoke repo's default branch is **not `main`** (for example `master`) | A sync or import is triggered | Hub-Sync looks up the repo's default branch: sync branches and PRs are based on it, direct pushes go to it, and the import reads from it. Import PRs into the Hub target the Hub repo's default branch. | Partial (sandbox repo with a different default branch) | +| G5 | The org's `settings.yml` in the Hub has invalid YAML and the PR was merged anyway | A sync is triggered | The merge fails and that org is not synced. The F2 check is meant to prevent this. | No | + +## H. Import (spoke → Hub) + +Triggered with `POST /api/safe-settings/hub/import`, or from the dashboard. + +| ID | Given | When | Then | Smoke test | +|----|-------|------|------|------------| +| H1 | The org has `` with files under `CONFIG_PATH`, and the Hub has no `organizations//` | Import is run | A Hub PR copies the org's config into `organizations//`. | Yes (through the API) | +| H2 | The org was already imported, and `SAFE_SETTINGS_HUB_REIMPORT=false` (the default) | Import is run again | The org is skipped (`already_imported`). | Yes (through the API) | +| H3 | `SAFE_SETTINGS_HUB_REIMPORT=true` | Import is run again | The Hub files are updated through a PR, or `no_changes` is returned if they're identical. | No (needs an app restart with a different env) | +| H4 | The org has no `ADMIN_REPO`, or no files at `CONFIG_PATH` | Import is run | The result is `N/A` (`admin_repo_not_found` or `no_files_at_config_path`). | Partial | + diff --git a/index.js b/index.js index 644661234..914f28976 100644 --- a/index.js +++ b/index.js @@ -896,8 +896,11 @@ module.exports = (robot, { addHandler }, Settings = require('./lib/settings')) = params = Object.assign(context.repo(), { pull_number: pull_request.number }) - const changes = await context.octokit.rest.pulls.listFiles(params) - const files = changes.data.map(f => { return f.filename }) + const changes = await context.octokit.paginate( + context.octokit.rest.pulls.listFiles, + { ...params, per_page: 100 } + ) + const files = changes.map(f => f.filename) const settingsModified = files.includes(Settings.FILE_PATH) const repoChanges = getChangedRepoConfigName(files, context.repo().owner) diff --git a/lib/hubSyncHandler.js b/lib/hubSyncHandler.js index d36299c81..d7cc7576b 100644 --- a/lib/hubSyncHandler.js +++ b/lib/hubSyncHandler.js @@ -6,7 +6,6 @@ const path = require('path') const fs = require('fs') const os = require('os') const util = require('util') -const mergeBy = require('./mergeArrayBy') /** * Attach a file-backed logger to robot.log that mirrors all log calls to a file. @@ -74,6 +73,26 @@ async function getOrgInstallation (robot, orgName) { return await robot.auth(install.id) } +/** + * Get a repository's default branch. Errors (e.g. 404) propagate to the caller. + * @param {import('@octokit/rest').Octokit} githubClient + * @param {string} owner + * @param {string} repo + * @returns {Promise} + */ +async function getDefaultBranch (githubClient, owner, repo) { + const { data } = await githubClient.rest.repos.get({ owner, repo }) + return defaultBranchOf(data, `${owner}/${repo}`) +} + +function defaultBranchOf (repoData, fullName) { + const branch = repoData && repoData.default_branch + if (!branch) { + throw new Error(`Could not determine the default branch for ${fullName || (repoData && repoData.full_name) || 'repository'}`) + } + return branch +} + // Helper to create a branch if not direct push async function createBranchIfNeeded(githubClient, owner, repo, baseBranch, branchName, directPush, logger) { @@ -131,12 +150,14 @@ async function loadManifest(octokit, ref, logger) { const manifestPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/globals/manifest.yml` try { - const resp = await octokit.rest.repos.getContent({ + // Without a ref, GitHub reads the hub repo's default branch + const params = { owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, - path: manifestPath, - ref: ref || 'main' - }) + path: manifestPath + } + if (ref) params.ref = ref + const resp = await octokit.rest.repos.getContent(params) if (Array.isArray(resp.data)) { logger.warn(`Expected manifest file but got directory at ${manifestPath}`) @@ -455,13 +476,13 @@ async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationF const destOwner = orgName const destBase = (destinationFolder || env.CONFIG_PATH || '.github').replace(/\/$/, '') - const destBaseBranch = 'main' const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') const githubDest = await getOrgInstallation(robot, destOwner) if (!githubDest) { robot.log.warn(`Installation for destination org ${destOwner} not found; cannot sync`) return } + const destBaseBranch = await getDefaultBranch(githubDest, destOwner, destRepo) robot.log.info(`Syncing from ${srcOwner}/${srcRepo} PR #${pull_number} to ${destOwner}/${destRepo}@${destBaseBranch} under ${destBase} (directPush=${directPush})`) const timestamp = Date.now() const branchName = directPush ? destBaseBranch : `safe-settings-sync/pr-${pull_number}-${orgName}-${timestamp}` @@ -647,7 +668,11 @@ async function syncHubGlobalsUpdate (robot, context, files) { // Get PR head SHA for loading manifest and files const pr = context.payload.pull_request - const prHeadSha = pr ? pr.head.sha : 'main' + if (!pr || !pr.head || !pr.head.sha) { + robot.log.warn('No pull_request payload found; aborting sync') + return + } + const prHeadSha = pr.head.sha // Load manifest with new filtering logic const manifest = await loadManifest(context.octokit, prHeadSha, robot.log) @@ -725,8 +750,11 @@ async function syncHubGlobalsUpdate (robot, context, files) { // Check if destination repo exists let repoExists = false + let destBaseBranch + let destRepoData try { - await githubDest.rest.repos.get({ owner: orgName, repo: destRepo }) + const destRepoResp = await githubDest.rest.repos.get({ owner: orgName, repo: destRepo }) + destRepoData = destRepoResp && destRepoResp.data repoExists = true } catch (err) { if (err.status === 404) { @@ -738,6 +766,12 @@ async function syncHubGlobalsUpdate (robot, context, files) { } if (!repoExists) continue + try { + destBaseBranch = defaultBranchOf(destRepoData, `${orgName}/${destRepo}`) + } catch (branchErr) { + robot.log.error(`Skipping org ${orgName}: ${branchErr.message}`) + continue + } // Load and merge settings const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, prHeadSha) @@ -755,7 +789,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { owner: orgName, repo: destRepo, path: destPath, - ref: 'main' + ref: destBaseBranch }) if (!Array.isArray(destGet.data)) { existingSha = destGet.data.sha @@ -769,11 +803,11 @@ async function syncHubGlobalsUpdate (robot, context, files) { // Determine push strategy const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') const timestamp = Date.now() - const branchName = directPush ? 'main' : `safe-settings-sync/globals-${timestamp}` + const branchName = directPush ? destBaseBranch : `safe-settings-sync/globals-${timestamp}` if (!directPush) { try { - const baseRef = await githubDest.rest.git.getRef({ owner: orgName, repo: destRepo, ref: 'heads/main' }) + const baseRef = await githubDest.rest.git.getRef({ owner: orgName, repo: destRepo, ref: `heads/${destBaseBranch}` }) const baseSha = baseRef.data.object.sha await githubDest.rest.git.createRef({ owner: orgName, repo: destRepo, ref: `refs/heads/${branchName}`, sha: baseSha }) robot.log.info(`Created branch ${branchName} in ${orgName}/${destRepo}`) @@ -811,7 +845,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { repo: destRepo, title: prTitle, head: branchName, - base: 'main', + base: destBaseBranch, body: prBody }) robot.log.info(`Created PR ${created.data.html_url} in ${orgName}/${destRepo}`) @@ -842,8 +876,11 @@ async function syncHubGlobalsUpdate (robot, context, files) { // Check if destination repo exists let repoExists = false + let destBaseBranch + let destRepoData try { - await githubDest.rest.repos.get({ owner: orgName, repo: destRepo }) + const destRepoResp = await githubDest.rest.repos.get({ owner: orgName, repo: destRepo }) + destRepoData = destRepoResp && destRepoResp.data repoExists = true } catch (err) { if (err.status === 404) { @@ -855,6 +892,12 @@ async function syncHubGlobalsUpdate (robot, context, files) { } if (!repoExists) continue + try { + destBaseBranch = defaultBranchOf(destRepoData, `${orgName}/${destRepo}`) + } catch (branchErr) { + robot.log.error(`Skipping org ${orgName}: ${branchErr.message}`) + continue + } const destPath = `${env.CONFIG_PATH}/${fileName}` let exists = false @@ -865,7 +908,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { owner: orgName, repo: destRepo, path: destPath, - ref: 'main' + ref: destBaseBranch }); if (!Array.isArray(resp.data)) { robot.log.debug(`Found ${destPath} in ${orgName}/${destRepo}`); @@ -901,7 +944,6 @@ async function syncHubGlobalsUpdate (robot, context, files) { } const fileContent = Buffer.from(data.content, data.encoding).toString('utf8') const encoded = Buffer.from(fileContent, 'utf8').toString('base64') - const destBaseBranch = 'main' const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') const timestamp = Date.now() const branchName = directPush ? destBaseBranch : `safe-settings-globals-sync/${orgName}-${fileName}-${timestamp}` @@ -943,7 +985,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { * @param {import('probot').Probot} robot * @param {Array} orgNames Array of organization names to retrieve settings from * @param {Object} options Options for the operation - * @param {string} options.baseBranch Base branch to create new branches from (default: 'main') + * @param {string} options.baseBranch Hub branch to create import branches from (default: the hub repo's default branch) * @returns {Promise>} Results of the operation for each organization */ async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { @@ -964,7 +1006,7 @@ async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { if (!hubInstall) throw new Error(`Installation for hub org ${env.SAFE_SETTINGS_HUB_ORG} not found`) const githubHub = await robot.auth(hubInstall.id) - const baseBranch = options.baseBranch || 'main' + let baseBranch = options.baseBranch // Read REIMPORT mode from environment variable (default: false for safe mode) const allowReimport = (env.SAFE_SETTINGS_HUB_REIMPORT || 'false').toLowerCase() === 'true' @@ -973,19 +1015,20 @@ async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { // Resolve the base sha for creating branches let baseRef, baseSha try { + if (!baseBranch) baseBranch = await getDefaultBranch(githubHub, env.SAFE_SETTINGS_HUB_ORG, hubRepoName) baseRef = await githubHub.rest.git.getRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `heads/${baseBranch}` }) baseSha = baseRef.data && baseRef.data.object && baseRef.data.object.sha } catch (refErr) { if (refErr && refErr.status === 404) { // Hub repo doesn't exist - return N/A for all requested orgs - robot.log.warn(`Hub repository ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName} or branch '${baseBranch}' not found`) + robot.log.warn(`Hub repository ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName} or branch '${baseBranch || '(default)'}' not found`) return orgNames.map(org => ({ org, status: 'N/A', reason: `hub_repo_not_found: ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName}` })) } throw refErr } // Helper: collect all files under a path in a repo (recursively) - async function collectFilesFromRepo (githubClient, owner, repo, dirPath, ref = 'main') { + async function collectFilesFromRepo (githubClient, owner, repo, dirPath, ref) { const out = [] // First verify the repo exists by checking for the ref @@ -1105,7 +1148,8 @@ async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { // collect files from the source admin repo under CONFIG_PATH let files try { - files = await collectFilesFromRepo(githubSrc, orgName, adminRepo, sourceBase, 'main') + const srcBranch = await getDefaultBranch(githubSrc, orgName, adminRepo) + files = await collectFilesFromRepo(githubSrc, orgName, adminRepo, sourceBase, srcBranch) } catch (collectErr) { if (collectErr && collectErr.status === 404) { robot.log.info(`Skipping ${orgName}: admin repo '${adminRepo}' not found`) @@ -1199,8 +1243,29 @@ async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { } } -// Properties used to identify matching items in arrays -const NAME_FIELDS = ['name', 'username', 'actor_id', 'login', 'type', 'key_prefix', 'context'] +// Fields that identify an array entry (in priority order) when merging globals with org settings. +// `actor_id`, `id` and `type` are handled separately in arrayItemIdentity(). +const IDENTITY_FIELDS = ['name', 'username', 'login', 'title', 'key_prefix', 'context'] + +/** + * Top-level sections explicitly set to `null` in an org settings file. + * Hub-Sync treats `section: null` as "do not inherit this section from globals" + * and drops it from the generated spoke file (core plugins cannot handle a + * `null` section). + * + * @param {object} config - Parsed settings object + * @returns {string[]} Section names set to null + */ +function getNullSections (config) { + if (!config || typeof config !== 'object' || Array.isArray(config)) return [] + return Object.keys(config).filter(key => config[key] === null) +} + +function omitSections (config, sections) { + const result = { ...config } + for (const section of sections) delete result[section] + return result +} /** * Load and merge global settings with org-specific settings (in-memory only). @@ -1275,7 +1340,17 @@ async function loadAndMergeSettings (robot, githubHub, orgName, prHeadSha) { # DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} ` - return header + orgContent + let orgOnly + try { + orgOnly = yaml.load(orgContent) + } catch (e) { + return header + orgContent + } + const nullSections = getNullSections(orgOnly) + if (nullSections.length === 0) { + return header + orgContent + } + return header + yaml.dump(omitSections(orgOnly, nullSections), { lineWidth: -1 }) } if (!orgContent) { robot.log.info(`Using pure globals (no org-specific settings found)`) @@ -1291,13 +1366,20 @@ async function loadAndMergeSettings (robot, githubHub, orgName, prHeadSha) { robot.log.info(`Merging globals with organizations/${orgName}/settings.yml (org takes precedence)`) try { const merged = mergeConfigs(globalsContent, orgContent, false) // false = smart merge, org overrides globals - const mergedYaml = yaml.dump(merged, { lineWidth: -1 }) + const nullSections = getNullSections(yaml.load(orgContent)) + if (nullSections.length > 0) { + robot.log.info(`organizations/${orgName}/settings.yml opts out of inherited section(s): ${nullSections.join(', ')}`) + } + const mergedYaml = yaml.dump(omitSections(merged, nullSections), { lineWidth: -1 }) + const optOutLine = nullSections.length > 0 + ? `# Not inherited from globals (set to null in org file): ${nullSections.join(', ')}\n` + : '' // Add header comment indicating auto-generation const header = `# Auto-generated by Safe-Settings Hub-Sync # Merged from globals/settings.yml + organizations/${orgName}/settings.yml # Organization settings take precedence over globals -# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} +${optOutLine}# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} ` @@ -1444,16 +1526,45 @@ function deepMerge (target, source, replaceArrays) { } /** - * Smart merge two arrays: deduplicates primitives, or matches objects by name/username/etc and deep-merges them. - * - * **Primitive arrays**: ['a', 'b'] + ['b', 'c'] → ['a', 'b', 'c'] - * - * **Object arrays** (via mergeBy): - * - Matches by NAME_FIELDS (name, username, actor_id, login, type, key_prefix, context) - * - Deep-merges matching objects (source properties override target, nested props preserved) - * - Appends non-matching source objects - * - Preserves target-only objects - * + * Identity of an array entry, used to match entries between globals and org files. + * Returns null when the entry has no identifying field. + * + * - `actor_id` and `id` are paired with their type (`actor_type` / `type`) because + * ids are only unique per type (e.g. a Team and an Integration can share an id). + * - `type` is only used on its own when nothing more specific exists (ruleset rules). + */ +function arrayItemIdentity (item) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return null + const has = field => item[field] !== undefined && item[field] !== null + if (has('actor_id')) return `actor_id:${item.actor_type ?? ''}:${item.actor_id}` + for (const field of IDENTITY_FIELDS) { + if (has(field)) return `${field}:${item[field]}` + } + if (has('id')) return `id:${item.type ?? ''}:${item.id}` + if (has('type')) return `type:${item.type}` + return null +} + +function isDeepEqual (a, b) { + if (a === b) return true + if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false + if (Array.isArray(a) !== Array.isArray(b)) return false + const keysA = Object.keys(a) + const keysB = Object.keys(b) + if (keysA.length !== keysB.length) return false + return keysA.every(key => Object.prototype.hasOwnProperty.call(b, key) && isDeepEqual(a[key], b[key])) +} + +/** + * Smart merge two arrays (globals = target, org = source) without creating duplicates. + * + * - Entries with the same identity (see `arrayItemIdentity`) are deep-merged, with + * the org entry taking precedence. Nested arrays are merged the same way, so lists + * like `include`/`exclude`, `bypass_actors` and `rules` are not duplicated. + * - Entries without an identity (primitives, or objects without an identifying field) + * are added only if an identical entry does not already exist. + * - Target-only entries are preserved; new source entries are appended. + * * Example (labels): * ``` * target: [{ name: 'bug', color: 'red' }, { name: 'feature', color: 'blue' }] @@ -1462,29 +1573,76 @@ function deepMerge (target, source, replaceArrays) { * { name: 'feature', color: 'blue' }, // preserved * { name: 'docs', color: 'yellow' }] // added * ``` - * + * * @param {Array} target - Base array (primitives or objects) * @param {Array} source - Overlay array (primitives or objects) * @returns {Array} Merged array */ function smartMergeArrays (target, source) { - // Check if arrays contain objects or primitives - const hasObjects = source.some(item => item && typeof item === 'object' && !Array.isArray(item)) - - if (!hasObjects) { - // Primitives: deduplicate - const result = [...target] - source.forEach(item => { - if (!result.includes(item)) { - result.push(item) - } - }) - return result + const result = [...target] + const indexByIdentity = new Map() + result.forEach((item, index) => { + const identity = arrayItemIdentity(item) + if (identity !== null && !indexByIdentity.has(identity)) indexByIdentity.set(identity, index) + }) + + for (const item of source) { + const identity = arrayItemIdentity(item) + if (identity !== null && indexByIdentity.has(identity)) { + const index = indexByIdentity.get(identity) + result[index] = deepMerge(result[index], item, false) + } else if (!result.some(existing => isDeepEqual(existing, item))) { + if (identity !== null) indexByIdentity.set(identity, result.length) + result.push(item) + } } - - // Objects: match by NAME_FIELDS and deep-merge - // mergeBy(key, configvalidator, overridevalidator, properties, target, source, options, githubContext) - return mergeBy(null, null, null, NAME_FIELDS, target, source, undefined, undefined) + return result +} + +/** + * Read and parse a YAML file from the hub repo. Returns null when the file does not exist. + */ +async function readHubYaml (octokit, owner, repo, filePath, ref) { + try { + const resp = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref }) + if (Array.isArray(resp.data) || !resp.data.content) return null + return yaml.load(Buffer.from(resp.data.content, resp.data.encoding || 'base64').toString('utf8')) || {} + } catch (err) { + if (err.status === 404) return null + throw err + } +} + +/** + * Compares an org settings file between the PR base and head to find + * top-level sections that were deleted but are still provided by globals + * (and will therefore keep being inherited), plus sections the org opts + * out of via `section: null`. + * + * @returns {Promise<{stillInherited: string[], optedOut: string[]}>} + */ +async function checkInheritedSections (octokit, owner, repo, orgName, baseRef, headSha) { + const root = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}`.replace(/\/+/g, '/') + const orgPath = `${root}/organizations/${orgName}/settings.yml` + const globalsPath = `${root}/globals/settings.yml` + + const [baseOrg, headOrg, headGlobals] = await Promise.all([ + baseRef ? readHubYaml(octokit, owner, repo, orgPath, baseRef) : null, + readHubYaml(octokit, owner, repo, orgPath, headSha), + readHubYaml(octokit, owner, repo, globalsPath, headSha) + ]) + + const isObject = v => v && typeof v === 'object' && !Array.isArray(v) + const head = isObject(headOrg) ? headOrg : {} + const globals = isObject(headGlobals) ? headGlobals : {} + const definedInGlobals = key => key in globals && globals[key] !== null + + const stillInherited = isObject(baseOrg) + ? Object.keys(baseOrg).filter(key => !(key in head) && definedInGlobals(key)) + : [] + const optedOut = getNullSections(head).filter(definedInGlobals) + + return { stillInherited, optedOut } } /** @@ -1507,6 +1665,7 @@ async function validateAndReportHubSync (robot, context, payload, pullRequest, c const mergeErrors = [] const mergeSuccesses = [] const warnings = [] + const notices = [] const hubPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}`.replace(/\/+/g, '/') const settingsPattern = new RegExp(`^${hubPath}/(globals|organizations/[^/]+)/settings\\.ya?ml$`) @@ -1595,6 +1754,25 @@ async function validateAndReportHubSync (robot, context, payload, pullRequest, c } catch (e) { mergeErrors.push(`Organization \`${orgName}\` (settings.yml): ${e.message}`) } + + try { + const { stillInherited, optedOut } = await checkInheritedSections( + context.octokit, + context.repo().owner, + context.repo().repo, + orgName, + baseRef, + pullRequest.head.sha + ) + for (const section of stillInherited) { + warnings.push(`\`${section}\` was removed from \`organizations/${orgName}/settings.yml\`, but \`globals/settings.yml\` still defines it, so \`${orgName}\` will keep inheriting it. To stop inheriting it, set \`${section}: null\` in the org file.`) + } + if (optedOut.length > 0) { + notices.push(`Organization \`${orgName}\` will not inherit from globals: ${optedOut.map(s => `\`${s}\``).join(', ')} (set to \`null\`)`) + } + } catch (e) { + robot.log.warn(`Could not compare inherited sections for ${orgName}: ${e.message}`) + } } } @@ -1666,6 +1844,12 @@ async function validateAndReportHubSync (robot, context, payload, pullRequest, c } } + // Inheritance notices (sections opted out of via null) + if (notices.length > 0) { + commentBody += `#### ℹ️ Inheritance Overrides (${notices.length})\n\n` + commentBody += notices.map(n => `- ${n}`).join('\n') + '\n\n' + } + // Warnings section (if any) if (warnings.length > 0) { commentBody += `#### ⚠️ Warnings (${warnings.length})\n\n` @@ -1711,6 +1895,8 @@ module.exports = { getOrgInstallation, mergeConfigs, loadAndMergeSettings, + checkInheritedSections, + getDefaultBranch, validateAndReportHubSync, // Manifest-based filtering functions loadManifest, diff --git a/lib/routes.js b/lib/routes.js index 6b1e58737..d727dde5c 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -22,6 +22,7 @@ const fs = require('fs') const express = require('express') const env = require('./env') const { getInstallations: cacheGetInstallations, getOrgLogins, getLastFetchedAt } = require('./installationCache') +const { loadAndMergeSettings } = require('./hubSyncHandler') // Lightweight commit metadata cache (path+ref -> meta) with TTL to avoid // repeated GitHub commit lookups across requests. @@ -37,6 +38,11 @@ function setCachedCommitMeta (key, meta) { _commitMetaCache.set(key, { meta, expiresAt: Date.now() + COMMIT_META_TTL_MS }) } +// env.js supplies defaults, so check the raw environment to know whether Hub-Sync was configured +function isHubConfigured () { + return Boolean(process.env.SAFE_SETTINGS_HUB_ORG && process.env.SAFE_SETTINGS_HUB_REPO) +} + function setupRoutes (robot, addHandler) { // Support for URL prefix (e.g., /safe-settings) when behind a proxy const basePath = env.SAFE_SETTINGS_HUB_URL_PREFIX || '' @@ -135,8 +141,25 @@ function setupRoutes (robot, addHandler) { const lastCommits = {} const syncStatus = {} let installationDtos + let hubSyncStatus = null if (includeActivity && env.ADMIN_REPO) { + let hubInstall = null + if (!isHubConfigured()) { + hubSyncStatus = 'not_configured' + robot.log.debug('Hub-Sync is not configured; skipping hub sync comparisons') + } else { + hubInstall = orgInstalls.find( + i => i.account.login && + i.account.login.toLowerCase() === env.SAFE_SETTINGS_HUB_ORG.toLowerCase() + ) + hubSyncStatus = hubInstall ? 'available' : 'hub_not_installed' + if (!hubInstall) { + robot.log.warn(`Installation for hub org ${env.SAFE_SETTINGS_HUB_ORG} not found; skipping hub sync comparisons`) + } + } + const githubHub = hubInstall ? await robot.auth(hubInstall.id) : null + const orgs = orgLogins const limit = 1 // reduce concurrency for API rate safety const queue = [...orgs] @@ -167,7 +190,7 @@ function setupRoutes (robot, addHandler) { } // --- SYNC CHECK --- let isInSync = false - if (hasConfigRepo) { + if (hasConfigRepo && githubHub) { try { const hubOrgDir = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${org}` const hubRef = 'main' @@ -189,7 +212,7 @@ function setupRoutes (robot, addHandler) { try { robot.log.debug(`5. [SYNC DEBUG] Hub: ${env.SAFE_SETTINGS_HUB_ORG}`) robot.log.debug(`5a. [SYNC DEBUG] Fetching hub files for: \n owner: ${env.SAFE_SETTINGS_HUB_ORG}, \n repo: ${env.SAFE_SETTINGS_HUB_REPO}, \n path: ${hubOrgDir}, \n ref: ${hubRef}`) - hubFilesResp = await githubOrg.rest.repos.getContent({ + hubFilesResp = await githubHub.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: hubOrgDir, @@ -208,21 +231,51 @@ function setupRoutes (robot, addHandler) { hubFilesResp = { data: [] } } - const orgFiles = Array.isArray(orgFilesResp.data) ? orgFilesResp.data.filter(f => f.type === 'file') : [] - const hubFiles = Array.isArray(hubFilesResp.data) ? hubFilesResp.data.filter(f => f.type === 'file') : [] - - // Hub is source of truth - check if all hub files exist in org with matching content - if (hubFiles.length === 0) { + const orgEntries = Array.isArray(orgFilesResp.data) ? orgFilesResp.data : [] + const hubFiles = [] + const collectHubFiles = async entries => { + for (const entry of entries) { + if (entry.type === 'file') { + hubFiles.push(entry) + } else if (entry.type === 'dir') { + const response = await githubHub.rest.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: entry.path, + ref: hubRef + }) + if (!Array.isArray(response.data)) { + throw new Error(`Expected a directory listing for hub path ${entry.path}`) + } + await collectHubFiles(response.data) + } + } + } + await collectHubFiles(Array.isArray(hubFilesResp.data) ? hubFilesResp.data : []) + const hubSettingsFile = hubFiles.find( + file => path.posix.relative(hubOrgDir, file.path) === 'settings.yml' + ) + const mergedSettings = await loadAndMergeSettings(robot, githubHub, org, hubRef) + const filesToCompare = hubFiles.filter( + file => path.posix.relative(hubOrgDir, file.path) !== 'settings.yml' + ) + + // Hub is source of truth; root settings.yml is compared to its generated merge. + if (filesToCompare.length === 0 && !mergedSettings) { robot.log.debug(`6b. [SYNC DEBUG] Org ${org} not in sync - no hub content (not yet imported)`) isInSync = false } else { - // For each hub file, find corresponding org file and compare content let allMatch = true - for (const hubFile of hubFiles) { - const hubFileName = hubFile.name - const orgFile = orgFiles.find(f => f.name === hubFileName) - - if (!orgFile) { + if (hubSettingsFile && !mergedSettings) { + robot.log.error(`6b. [SYNC DEBUG] Could not generate expected settings.yml for org ${org}`) + allMatch = false + } + for (const hubFile of filesToCompare) { + const hubFileName = path.posix.relative(hubOrgDir, hubFile.path) + const [rootName, ...nestedParts] = hubFileName.split('/') + const orgEntry = orgEntries.find(f => f.name === rootName && f.type === (nestedParts.length ? 'dir' : 'file')) + + if (!orgEntry) { robot.log.debug(`6c. [SYNC DEBUG] Org ${org} not in sync - missing file ${hubFileName} in org`) allMatch = false break @@ -230,16 +283,20 @@ function setupRoutes (robot, addHandler) { // Compare file contents robot.log.debug(`7. [SYNC DEBUG] Comparing file: ${hubFileName} for org ${org}`) + const orgFilePath = path.posix.join(env.CONFIG_PATH, hubFileName) let orgContentResp, hubContentResp try { - orgContentResp = await githubOrg.rest.repos.getContent({ owner: org, repo: env.ADMIN_REPO, path: orgFile.path }) + orgContentResp = await githubOrg.rest.repos.getContent({ owner: org, repo: env.ADMIN_REPO, path: orgFilePath }) + if (Array.isArray(orgContentResp.data) || typeof orgContentResp.data.content !== 'string') { + throw new Error(`Expected readable file content at ${orgFilePath}`) + } } catch (fetchErr) { - robot.log.error(`7a. [SYNC DEBUG] Error fetching org file ${orgFile.path}: ${fetchErr.message}`) + robot.log.error(`7a. [SYNC DEBUG] Error fetching org file ${orgFilePath}: ${fetchErr.message}`) allMatch = false break } try { - hubContentResp = await githubOrg.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: hubFile.path }) + hubContentResp = await githubHub.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: hubFile.path }) } catch (fetchErr) { robot.log.error(`7b. [SYNC DEBUG] Error fetching hub file ${hubFile.path}: ${fetchErr.message}`) allMatch = false @@ -258,6 +315,37 @@ function setupRoutes (robot, addHandler) { break } } + + if (allMatch && mergedSettings) { + const orgSettings = orgEntries.find(f => f.name === 'settings.yml' && f.type === 'file') + if (!orgSettings) { + robot.log.debug(`6c. [SYNC DEBUG] Org ${org} not in sync - missing generated settings.yml`) + allMatch = false + } else { + const orgSettingsPath = path.posix.join(env.CONFIG_PATH, 'settings.yml') + try { + const orgSettingsResp = await githubOrg.rest.repos.getContent({ + owner: org, + repo: env.ADMIN_REPO, + path: orgSettingsPath + }) + if (Array.isArray(orgSettingsResp.data) || typeof orgSettingsResp.data.content !== 'string') { + throw new Error(`Expected readable file content at ${orgSettingsPath}`) + } + const deployedSettings = Buffer.from( + orgSettingsResp.data.content, + orgSettingsResp.data.encoding || 'base64' + ).toString('utf8') + if (hashContent(deployedSettings) !== hashContent(mergedSettings)) { + robot.log.debug(`11. [SYNC DEBUG] Generated settings.yml hash mismatch for org ${org}`) + allMatch = false + } + } catch (fetchErr) { + robot.log.error(`7a. [SYNC DEBUG] Error fetching generated settings file ${orgSettingsPath}: ${fetchErr.message}`) + allMatch = false + } + } + } isInSync = allMatch } } catch (syncErr) { @@ -322,7 +410,13 @@ function setupRoutes (robot, addHandler) { isInSync: typeof syncStatus[orgKey] === 'boolean' ? syncStatus[orgKey] : false } }) - return res.json({ updatedAt: new Date().toISOString(), installations: installationDtos }) + const body = { updatedAt: new Date().toISOString(), installations: installationDtos } + if (hubSyncStatus) { + body.hubSync = hubSyncStatus === 'hub_not_installed' + ? { status: hubSyncStatus, org: env.SAFE_SETTINGS_HUB_ORG } + : { status: hubSyncStatus } + } + return res.json(body) } catch (e) { robot.log && robot.log.error && robot.log.error(e) res.status(500).json({ error: e.message || 'unexpected error' }) @@ -344,11 +438,22 @@ function setupRoutes (robot, addHandler) { async function hubContent (req, res) { let fullPath, ref try { + if (!isHubConfigured()) { + return res.status(404).json({ + code: 'HUB_NOT_CONFIGURED', + error: 'Safe-Settings Hub is not set up yet', + message: 'This page lists the configuration files kept in your Safe-Settings Hub repository. Once Hub-Sync is configured with the organization and repository that hold your Hub configuration (SAFE_SETTINGS_HUB_ORG and SAFE_SETTINGS_HUB_REPO), restart the app and the files will appear here.' + }) + } // Use cached installations (TTL-based freshness) const installs = await cacheGetInstallations(robot) const install = installs.find(i => i.account && i.account.type === 'Organization' && i.account.login && i.account.login.toLowerCase() === env.SAFE_SETTINGS_HUB_ORG.toLowerCase()) if (!install) { - return res.status(404).json({ error: `Installation for org ${env.SAFE_SETTINGS_HUB_ORG} not found` }) + return res.status(404).json({ + code: 'HUB_INSTALLATION_NOT_FOUND', + error: 'Safe-Settings Hub organization is not reachable', + message: `The Safe-Settings app does not appear to be installed on the Hub organization '${env.SAFE_SETTINGS_HUB_ORG}'. Install the app on that organization, or double-check the Hub-Sync configuration, then reload this page.` + }) } const github = await robot.auth(install.id) diff --git a/package.json b/package.json index 8a15e9704..582085426 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:unit:ci": "npm run test:unit --reporters=default --reporters=github-actions", "test:me": "jest ", "test:unit:watch": "npm run test:unit -- --watch", + "test:unit:hub": "jest test/unit/lib/hubSyncHandler test/unit/routes-sync-status test/unit/index-config-errors", "test:integration": "node --test --test-reporter=spec \"test/integration/**/*.test.js\"", "test:integration:ci": "mkdir -p reports && node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/integration-junit.xml \"test/integration/**/*.test.js\"", "test:pagination": "node --test test/integration/transport/variables.test.js", @@ -27,7 +28,10 @@ "integration:live:comment-markup": "node test/live/comment-markup.js", "smoke-test": "node smoke-test.js", "smoke-test:interactive": "node smoke-test.js --interactive", - "smoke-test:phase": "node smoke-test.js --phase" + "smoke-test:phase": "node smoke-test.js --phase", + "smoke-test:hub": "node smoke-test-hub-sync.js", + "smoke-test:hub:interactive": "node smoke-test-hub-sync.js --interactive", + "smoke-test:hub:teardown": "node smoke-test-hub-sync.js --teardown-only" }, "author": "Yadhav Jayaraman", "license": "ISC", diff --git a/smoke-test-hub-sync.js b/smoke-test-hub-sync.js new file mode 100644 index 000000000..f266dff28 --- /dev/null +++ b/smoke-test-hub-sync.js @@ -0,0 +1,1083 @@ +#!/usr/bin/env node + +/** + * Hub-Sync Smoke Test for safe-settings + * + * Exercises Hub-Sync end-to-end through an ALREADY RUNNING safe-settings app + * that is configured for Hub-Sync. The test reuses the app's own settings + * (from `.env` / the environment) instead of defining its own: + * + * Hub repo SAFE_SETTINGS_HUB_ORG / SAFE_SETTINGS_HUB_REPO (must exist) + * Hub path CONFIG_PATH / SAFE_SETTINGS_HUB_PATH + * Target repo ADMIN_REPO — Hub-Sync always writes to this repo in each target org + * App auth APP_ID + PRIVATE_KEY (or PRIVATE_KEY_PATH) + * + * Smoke-test specific settings: + * HUB_SMOKE_TARGET_ORG (required) sandbox target (spoke) org. The GitHub App + * must be installed there (checked before setup), and + * ADMIN_REPO must NOT exist there yet — setup creates + * it, teardown deletes it. + * HUB_SMOKE_APP_URL URL of the running app (default: + * http://localhost:$PORT$SAFE_SETTINGS_HUB_URL_PREFIX) + * + * Usage: + * node smoke-test-hub-sync.js setup → phases 1-6 → teardown + * node smoke-test-hub-sync.js --interactive pause after each phase + * node smoke-test-hub-sync.js --phase 1,2 setup → selected phases → teardown + * node smoke-test-hub-sync.js --teardown-only clean up after an aborted/crashed run + * + * Phases (case IDs from docs/hubSyncHandler/usecase-merge-behavior.md): + * 1 Import the target org into the Hub; the PR is closed, not merged (H1) + * 2 Invalid YAML fails the check; closing the PR syncs nothing (F2, E2) + * 3 Merge an org change and inspect the sync PR (A1/A2/A4/A5, C1/C3-C8, F1/F3) + * 4 Remove an inherited section: check warning, still inherited (C2) + * 5 Merge into a non-default Hub branch syncs nothing (E3) + * 6 Import again is skipped as already_imported (H2) + * Phases 4 and 6 need phase 3, which is added automatically. + * A full run takes about 5-8 minutes (negative cases wait QUIET_WAIT_MS each). + * + * Hub requirements: the app must run with SAFE_SETTINGS_HUB_DIRECT_PUSH off, and + * the Hub must not have an organizations/ folder yet. + * Phases 3-5 squash-merge test PRs into the Hub; teardown restores the changed + * files with direct commits (which do not trigger Hub-Sync). Sync PRs in the + * target repo are never merged and are removed with the target repo. + * + * Safety: + * - The target repo is created by the test and tagged with a marker in its + * description. Setup aborts if ADMIN_REPO already exists in the target org + * without the marker, and teardown only deletes a marked repo. + * - The Hub repo is never created or deleted. Test phases must register every + * Hub change (trackHubFile / trackHubBranch / trackHubPullRequest) so that + * teardown can revert it. The registry is persisted to STATE_FILE so + * `--teardown-only` can clean up after a crash. + */ + +const fs = require('fs') +const path = require('path') +const readline = require('readline') +const yaml = require('js-yaml') + +// ─── Configuration ─────────────────────────────────────────────────────────── + +function loadEnv () { + const envPath = path.join(__dirname, '.env') + if (!fs.existsSync(envPath)) throw new Error('.env file not found') + const lines = fs.readFileSync(envPath, 'utf8').split('\n') + let currentKey = null + let currentValue = '' + let inMultiline = false + + for (const line of lines) { + if (inMultiline) { + currentValue += '\n' + line + if (line.includes('"') || line.includes("'")) { + const val = currentValue.replace(/^["']|["']$/g, '') + if (!(currentKey in process.env)) process.env[currentKey] = val + inMultiline = false + } + continue + } + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + currentKey = trimmed.slice(0, eqIdx).trim() + currentValue = trimmed.slice(eqIdx + 1).trim() + if ((currentValue.startsWith('"') && !currentValue.endsWith('"')) || + (currentValue.startsWith("'") && !currentValue.endsWith("'"))) { + inMultiline = true + continue + } + const val = currentValue.replace(/^["']|["']$/g, '') + if (!(currentKey in process.env)) process.env[currentKey] = stripInlineComment(currentValue, val) + } +} + +// Like dotenv: unquoted values end at an inline " #" comment. +function stripInlineComment (raw, val) { + if (/^["']/.test(raw)) return val + return val.replace(/\s+#.*$/, '').trim() +} + +loadEnv() + +// Loaded after loadEnv() so they see the same settings as the running app. +const appEnv = require('./lib/env') +const { normalizeManifestRules, matchesOrgTargets } = require('./lib/hubSyncHandler') + +const HUB_SMOKE_HUB_ORG = (process.env.SAFE_SETTINGS_HUB_ORG || '').trim() +const HUB_SMOKE_HUB_REPO = (process.env.SAFE_SETTINGS_HUB_REPO || '').trim() +const HUB_SMOKE_HUB_PATH = appEnv.SAFE_SETTINGS_HUB_PATH.trim() +const HUB_SMOKE_CONFIG_PATH = appEnv.CONFIG_PATH.trim() +const HUB_SMOKE_HUB_ROOT = `${HUB_SMOKE_CONFIG_PATH}/${HUB_SMOKE_HUB_PATH}`.replace(/\/+/g, '/') +const HUB_SMOKE_TARGET_ORG = (process.env.HUB_SMOKE_TARGET_ORG || '').trim() +const HUB_SMOKE_TARGET_REPO = appEnv.ADMIN_REPO.trim() +const HUB_SMOKE_APP_URL = (process.env.HUB_SMOKE_APP_URL || + `http://localhost:${process.env.PORT || 3000}${appEnv.SAFE_SETTINGS_HUB_URL_PREFIX}`).replace(/\/+$/, '') +const HUB_SMOKE_APP_ID = process.env.APP_ID +const HUB_SMOKE_PRIVATE_KEY = (() => { + if (process.env.PRIVATE_KEY) return process.env.PRIVATE_KEY.replace(/\\n/g, '\n') + const keyPath = path.resolve(__dirname, appEnv.PRIVATE_KEY_PATH) + return fs.existsSync(keyPath) ? fs.readFileSync(keyPath, 'utf8') : '' +})() + +// Repos created by this test carry this marker; teardown only deletes marked repos. +const SMOKE_MARKER = '[safe-settings hub-sync smoke test]' +// Persisted registry of Hub changes to revert during teardown. +const STATE_FILE = path.join(__dirname, '.smoke-hub-sync-state.json') + +const POLL_INTERVAL_MS = 5000 +const MAX_POLL_MS = 120000 +// How long negative tests wait to confirm that nothing was synced. +const QUIET_WAIT_MS = 60000 +const CHECK_NAME = 'Safe-setting validator' +const RUN_ID = Date.now().toString(36) + +const INTERACTIVE = process.argv.includes('--interactive') +const TEARDOWN_ONLY = process.argv.includes('--teardown-only') + +const PHASE_ARG_IDX = process.argv.indexOf('--phase') +function parsePhaseSet (raw) { + if (!raw) return null + const nums = new Set() + for (const part of raw.split(',')) { + const range = part.match(/^(\d+)-(\d+)$/) + if (range) { + for (let i = parseInt(range[1], 10); i <= parseInt(range[2], 10); i++) nums.add(i) + } else if (/^\d+$/.test(part.trim())) { + nums.add(parseInt(part.trim(), 10)) + } + } + return nums.size > 0 ? nums : null +} +const ONLY_PHASES = PHASE_ARG_IDX !== -1 ? parsePhaseSet(process.argv[PHASE_ARG_IDX + 1]) : null +// Phases 4 (removed section) and 6 (re-import) build on the files merged in phase 3. +if (ONLY_PHASES && (ONLY_PHASES.has(4) || ONLY_PHASES.has(6))) ONLY_PHASES.add(3) + +class InteractiveExit extends Error { + constructor (action) { + super(`interactive:${action}`) + this.action = action + } +} + +// ─── Octokit clients (initialized in main) ─────────────────────────────────── + +let hubOctokit = null +let targetOctokit = null +let hubDefaultBranch = null +// Settings reported by the running app (GET /api/safe-settings/app/env). +let appSettings = {} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +let passCount = 0 +let failCount = 0 +const failures = [] + +function log (msg) { console.log(`\x1b[36m[hub-smoke]\x1b[0m ${msg}`) } +function logPass (msg) { passCount++; console.log(`\x1b[32m ✓ ${msg}\x1b[0m`) } +function logFail (msg) { failCount++; failures.push(msg); console.log(`\x1b[31m ✗ ${msg}\x1b[0m`) } +function logWarn (msg) { console.log(`\x1b[33m ⚠ ${msg}\x1b[0m`) } +function logPhase (msg) { console.log(`\n\x1b[35m═══ ${msg} ═══\x1b[0m`) } + +function assert (condition, msg) { + if (condition) logPass(msg) + else logFail(msg) + return condition +} + +function sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms)) } + +async function poll (fn, { timeout = MAX_POLL_MS, interval = POLL_INTERVAL_MS, desc = 'condition' } = {}) { + const start = Date.now() + while (Date.now() - start < timeout) { + const result = await fn() + if (result) return result + await sleep(interval) + } + logWarn(`Timed out waiting for ${desc}`) + return null +} + +// ─── Interactive mode ──────────────────────────────────────────────────────── + +let skipNext = false + +async function pause (phaseName) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + process.stdout.write( + `\n\x1b[33m[interactive] "${phaseName}" complete.\x1b[0m\n` + + ' \x1b[90mPress Enter to continue, \'s\' skip next, \'q\' quit+teardown, \'a\' abort (no teardown): \x1b[0m' + ) + rl.once('line', (answer) => { + const input = answer.trim().toLowerCase() + if (input === 's') resolve('skip') + else if (input === 'q') resolve('quit') + else if (input === 'a') resolve('abort') + else resolve('continue') + rl.close() + }) + rl.once('close', () => resolve('continue')) + }) +} + +async function runPhase (label, fn) { + if (skipNext) { + log(`\x1b[33m[interactive] Skipping ${label}\x1b[0m`) + skipNext = false + return 'skipped' + } + await fn() + if (!INTERACTIVE) return 'continue' + const action = await pause(label) + if (action === 'skip') skipNext = true + return action +} + +// ─── GitHub API helpers ────────────────────────────────────────────────────── + +async function getRepo (octokit, owner, repo) { + try { + return (await octokit.rest.repos.get({ owner, repo })).data + } catch (e) { + if (e.status === 404) return null + throw e + } +} + +const isSmokeRepo = repo => Boolean(repo && (repo.description || '').includes(SMOKE_MARKER)) + +async function getFileText (octokit, owner, repo, filePath, ref) { + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref }) + if (Array.isArray(data)) return null + return Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + } catch (e) { + if (e.status === 404) return null + throw e + } +} + +async function createOrUpdateFile (octokit, owner, repo, filePath, content, branch, message) { + let sha + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref: branch }) + sha = data.sha + } catch { /* file doesn't exist */ } + const params = { owner, repo, path: filePath, message, content: Buffer.from(content).toString('base64'), branch } + if (sha) params.sha = sha + return (await octokit.rest.repos.createOrUpdateFileContents(params)).data +} + +async function deleteFileIfExists (octokit, owner, repo, filePath, branch, message) { + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref: branch }) + await octokit.rest.repos.deleteFile({ owner, repo, path: filePath, message, sha: data.sha, branch }) + } catch (e) { + if (e.status !== 404) throw e + } +} + +// ─── Hub change registry (reverted during teardown) ────────────────────────── + +function loadState () { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) + } catch { + return { hubFiles: [], hubBranches: [], hubPullRequests: [] } + } +} + +const state = loadState() + +function saveState () { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) +} + +/** + * Snapshot a Hub file's current content on the default branch before a test + * changes it (directly or via a merged PR). Teardown restores the snapshot, + * or deletes the file if it did not exist. Only the first snapshot is kept. + */ +async function trackHubFile (filePath) { + if (state.hubFiles.some(f => f.path === filePath)) return + const original = await getFileText(hubOctokit, HUB_SMOKE_HUB_ORG, HUB_SMOKE_HUB_REPO, filePath, hubDefaultBranch) + state.hubFiles.push({ path: filePath, original }) + saveState() +} + +function trackHubBranch (branch) { + if (!state.hubBranches.includes(branch)) state.hubBranches.push(branch) + saveState() +} + +function trackHubPullRequest (number) { + if (!state.hubPullRequests.includes(number)) state.hubPullRequests.push(number) + saveState() +} + +// ─── Running app checks ────────────────────────────────────────────────────── + +/** + * Verify the running app is reachable and uses the same Hub-Sync settings as + * this test (both read the same `.env`, but the app may have been started + * with a different environment). + */ +async function verifyRunningApp () { + const url = `${HUB_SMOKE_APP_URL}/api/safe-settings/app/env` + let body + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10000) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + body = await res.json() + } catch (e) { + throw new Error(`safe-settings app is not reachable at ${url} (${e.message}). ` + + 'Start it with `npm start`, or set HUB_SMOKE_APP_URL.') + } + const appValues = Object.fromEntries((body.variables || []).map(v => [v.key, String(v.value ?? '').trim()])) + const expected = { + SAFE_SETTINGS_HUB_ORG: HUB_SMOKE_HUB_ORG, + SAFE_SETTINGS_HUB_REPO: HUB_SMOKE_HUB_REPO, + SAFE_SETTINGS_HUB_PATH: HUB_SMOKE_HUB_PATH, + CONFIG_PATH: HUB_SMOKE_CONFIG_PATH, + ADMIN_REPO: HUB_SMOKE_TARGET_REPO + } + const mismatches = Object.entries(expected) + .filter(([key, value]) => appValues[key] !== value) + .map(([key, value]) => `${key} (app: '${appValues[key]}', test: '${value}')`) + if (mismatches.length > 0) { + throw new Error(`The running app uses different Hub-Sync settings: ${mismatches.join(', ')}. Restart the app after updating .env.`) + } + if (appValues.CRON) { + logWarn('The running app has CRON set; scheduled syncs may run during the test') + } + if (/^(true|1)$/i.test(appValues.SAFE_SETTINGS_HUB_DIRECT_PUSH || '')) { + throw new Error('The running app has SAFE_SETTINGS_HUB_DIRECT_PUSH enabled. Hub-Sync would commit straight to the target ' + + 'repo\'s default branch, and safe-settings would then apply the merged settings to the target org. ' + + 'Restart the app with SAFE_SETTINGS_HUB_DIRECT_PUSH=false to run this smoke test.') + } + appSettings = appValues + logPass(`Running app at ${HUB_SMOKE_APP_URL} uses the same Hub-Sync settings`) +} + +/** + * Verify the Hub repo exists and its manifest lets Hub-Sync reach the target org. + */ +async function verifyHubRepo () { + const hub = await getRepo(hubOctokit, HUB_SMOKE_HUB_ORG, HUB_SMOKE_HUB_REPO) + if (!hub) throw new Error(`Hub repo ${HUB_SMOKE_HUB_ORG}/${HUB_SMOKE_HUB_REPO} not found (or the app cannot access it)`) + hubDefaultBranch = hub.default_branch + logPass(`Hub repo ${HUB_SMOKE_HUB_ORG}/${HUB_SMOKE_HUB_REPO} found (default branch: ${hubDefaultBranch})`) + + const manifestPath = `${HUB_SMOKE_HUB_ROOT}/globals/manifest.yml` + const manifestText = await getFileText(hubOctokit, HUB_SMOKE_HUB_ORG, HUB_SMOKE_HUB_REPO, manifestPath, hubDefaultBranch) + if (manifestText === null) { + logWarn(`No ${manifestPath} in the Hub repo — Hub-Sync will sync to all orgs`) + return + } + const manifest = yaml.load(manifestText) + if (!manifest || !Array.isArray(manifest.rules)) { + throw new Error(`${manifestPath} has no valid 'rules' list`) + } + const quietLogger = { debug () {}, info () {} } + if (!matchesOrgTargets(HUB_SMOKE_TARGET_ORG, normalizeManifestRules(manifest), quietLogger)) { + throw new Error(`No enabled rule in ${manifestPath} targets '${HUB_SMOKE_TARGET_ORG}', so Hub-Sync would skip it`) + } + logPass(`Hub manifest targets ${HUB_SMOKE_TARGET_ORG}`) + + // The test adds organizations// and removes it in teardown, so it must not hold real data. + const orgFolder = `${HUB_SMOKE_HUB_ROOT}/organizations/${HUB_SMOKE_TARGET_ORG}` + try { + await hubOctokit.rest.repos.getContent({ owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, path: orgFolder, ref: hubDefaultBranch }) + throw new Error(`The Hub repo already has ${orgFolder}. The smoke test creates and removes this folder, ` + + 'so use a target org that has no organization folder in the Hub.') + } catch (e) { + if (e.status !== 404) throw e + } + logPass(`Hub has no ${orgFolder} folder yet`) +} + +// ─── Target repo ───────────────────────────────────────────────────────────── + +const EMPTY_SETTINGS_YML = `# empty ${SMOKE_MARKER}\n` + +async function createTargetRepo () { + const existing = await getRepo(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO) + if (existing) { + if (!isSmokeRepo(existing)) { + throw new Error(`${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO} already exists and was not created by this smoke test. ` + + 'Use a sandbox org without an ADMIN_REPO for HUB_SMOKE_TARGET_ORG.') + } + log(' Removing leftover target repo from a previous run...') + await targetOctokit.rest.repos.delete({ owner: HUB_SMOKE_TARGET_ORG, repo: HUB_SMOKE_TARGET_REPO }) + await poll(async () => (await getRepo(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO)) === null, + { desc: 'leftover target repo to be deleted', timeout: 30000, interval: 2000 }) + } + + const { data } = await targetOctokit.rest.repos.createInOrg({ + org: HUB_SMOKE_TARGET_ORG, + name: HUB_SMOKE_TARGET_REPO, + private: true, + auto_init: true, + description: `Hub-Sync smoke test target (spoke) config repo. ${SMOKE_MARKER}` + }) + // auto_init commits asynchronously; wait until the default branch exists. + await poll(async () => { + try { + await targetOctokit.rest.repos.getBranch({ owner: HUB_SMOKE_TARGET_ORG, repo: HUB_SMOKE_TARGET_REPO, branch: data.default_branch }) + return true + } catch { return null } + }, { desc: 'target repo default branch', timeout: 30000, interval: 2000 }) + + await createOrUpdateFile(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO, + `${HUB_SMOKE_CONFIG_PATH}/settings.yml`, EMPTY_SETTINGS_YML, data.default_branch, 'Seed empty settings.yml for smoke test') + assert(isSmokeRepo(await getRepo(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO)), + `Target repo ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO} created`) +} + +async function deleteTargetRepo () { + const repo = await getRepo(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO) + if (!repo) { + log(` ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO} does not exist, nothing to delete`) + return + } + if (!isSmokeRepo(repo)) { + logWarn(`${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO} is not marked as a smoke-test repo — NOT deleting it`) + return + } + await targetOctokit.rest.repos.delete({ owner: HUB_SMOKE_TARGET_ORG, repo: HUB_SMOKE_TARGET_REPO }) + log(` Deleted ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO}`) +} + +// ─── Hub-Sync test helpers ─────────────────────────────────────────────────── + +const hubPaths = { + orgDir: () => `${HUB_SMOKE_HUB_ROOT}/organizations/${HUB_SMOKE_TARGET_ORG}`, + orgSettings: () => `${hubPaths.orgDir()}/settings.yml`, + orgRepoFile: () => `${hubPaths.orgDir()}/repos/smoke-repo.yml`, + strayFile: () => `${HUB_SMOKE_HUB_ROOT}/organizations/smoke-stray-${RUN_ID}.yml`, + globalsSettings: () => `${HUB_SMOKE_HUB_ROOT}/globals/settings.yml` +} +const targetSettingsPath = () => `${HUB_SMOKE_CONFIG_PATH}/settings.yml` +const targetRepoFilePath = () => `${HUB_SMOKE_CONFIG_PATH}/repos/smoke-repo.yml` + +const isDeepEqual = (a, b) => require('util').isDeepStrictEqual(a, b) +// labels may be a plain list or { include: [...], exclude: [...] } +const labelList = labels => Array.isArray(labels) ? labels : (Array.isArray(labels?.include) ? labels.include : []) + +/** + * Create a Hub branch from `base` and commit the given files to it + * ({ path: content }). The branch is registered for teardown. + */ +async function commitHubBranch (branch, files, base = hubDefaultBranch) { + const owner = HUB_SMOKE_HUB_ORG + const repo = HUB_SMOKE_HUB_REPO + const { data: ref } = await hubOctokit.rest.git.getRef({ owner, repo, ref: `heads/${base}` }) + trackHubBranch(branch) + await hubOctokit.rest.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha: ref.object.sha }) + for (const [filePath, content] of Object.entries(files)) { + await createOrUpdateFile(hubOctokit, owner, repo, filePath, content, branch, `Smoke test (${RUN_ID}): update ${path.basename(filePath)}`) + } +} + +async function openHubPr (branch, title, base = hubDefaultBranch) { + const { data: pr } = await hubOctokit.rest.pulls.create({ + owner: HUB_SMOKE_HUB_ORG, + repo: HUB_SMOKE_HUB_REPO, + head: branch, + base, + title: `${title} ${SMOKE_MARKER}`, + body: `Created by the Hub-Sync smoke test (run ${RUN_ID}). Teardown closes or reverts it.` + }) + trackHubPullRequest(pr.number) + log(` Opened Hub PR #${pr.number} (${branch} → ${base})`) + return pr +} + +// Wait until every safe-settings check run on the commit has completed; returns the latest one. +async function waitForHubCheck (sha) { + return poll(async () => { + const { data } = await hubOctokit.rest.checks.listForRef({ + owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, ref: sha, check_name: CHECK_NAME + }) + const runs = data.check_runs || [] + if (runs.length === 0 || runs.some(r => r.status !== 'completed')) return null + return runs.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at))[0] + }, { desc: `'${CHECK_NAME}' check on ${sha.slice(0, 7)}` }) +} + +async function mergeHubPr (number) { + const merged = await poll(async () => { + try { + await hubOctokit.rest.pulls.merge({ owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, pull_number: number, merge_method: 'squash' }) + return true + } catch (e) { + // 405: not mergeable yet (mergeability still being computed) + if (e.status === 405) return null + throw e + } + }, { desc: `Hub PR #${number} to be mergeable`, timeout: 60000, interval: 3000 }) + if (!merged) throw new Error(`Could not merge Hub PR #${number}`) + log(` Merged Hub PR #${number} (squash)`) +} + +const syncBranchPrefix = hubPrNumber => `safe-settings-sync/pr-${hubPrNumber}-` + +async function listSyncPrs (hubPrNumber) { + const prs = await targetOctokit.paginate(targetOctokit.rest.pulls.list, { + owner: HUB_SMOKE_TARGET_ORG, repo: HUB_SMOKE_TARGET_REPO, state: 'all', per_page: 100 + }) + return prs.filter(pr => pr.head.ref.startsWith(syncBranchPrefix(hubPrNumber))) +} + +async function waitForSyncPr (hubPrNumber) { + const prs = await poll(async () => { + const found = await listSyncPrs(hubPrNumber) + return found.length > 0 ? found : null + }, { desc: `sync PR for Hub PR #${hubPrNumber} in ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO}` }) + return prs || [] +} + +// Negative check: wait QUIET_WAIT_MS, then confirm no sync branch exists for the Hub PR. +async function expectNoSync (hubPrNumber, caseLabel) { + log(` Waiting ${QUIET_WAIT_MS / 1000}s to confirm nothing is synced...`) + await sleep(QUIET_WAIT_MS) + const { data: refs } = await targetOctokit.rest.git.listMatchingRefs({ + owner: HUB_SMOKE_TARGET_ORG, repo: HUB_SMOKE_TARGET_REPO, ref: `heads/${syncBranchPrefix(hubPrNumber)}` + }) + assert(refs.length === 0, `${caseLabel}: no sync branch created for Hub PR #${hubPrNumber}` + + (refs.length ? ` (found: ${refs.map(r => r.ref).join(', ')})` : '')) +} + +async function callImport () { + const url = `${HUB_SMOKE_APP_URL}/api/safe-settings/hub/import` + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ orgs: [HUB_SMOKE_TARGET_ORG] }), + signal: AbortSignal.timeout(60000) + }) + if (!res.ok) throw new Error(`POST ${url} returned HTTP ${res.status}`) + const body = await res.json() + if (!body.ok) throw new Error(`Import failed: ${body.error}`) + return (body.results || []).find(r => r.org === HUB_SMOKE_TARGET_ORG) || {} +} + +async function readTargetSettings (ref) { + const text = await getFileText(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO, targetSettingsPath(), ref) + return { text: text || '', parsed: text ? (yaml.load(text) || {}) : {} } +} + +async function loadGlobals () { + const text = await getFileText(hubOctokit, HUB_SMOKE_HUB_ORG, HUB_SMOKE_HUB_REPO, hubPaths.globalsSettings(), hubDefaultBranch) + return text ? (yaml.load(text) || {}) : {} +} + +/** + * Build the org settings.yml used by phase 3 from what globals actually + * defines. Cases whose prerequisites are missing in globals are skipped. + */ +function buildOrgSettings (globals) { + const org = {} + const cases = {} + const isObject = v => v && typeof v === 'object' && !Array.isArray(v) + + // C1: override one key, keep the rest + if (isObject(globals.repository)) { + org.repository = { description: `Hub-Sync smoke test ${RUN_ID}` } + cases.c1 = true + } + + // C6 / C7 / C8: labels matched by name (plain list or { include: [...] }) + const labelsNested = isObject(globals.labels) + const globalLabels = labelList(globals.labels).filter(l => l && l.name) + const orgLabels = [] + if (globalLabels[0]) { + const color = String(globalLabels[0].color || '').replace('#', '').toLowerCase() === 'b60205' ? '0e8a16' : 'b60205' + orgLabels.push({ ...globalLabels[0], color }) + cases.c6 = { name: globalLabels[0].name, color } + } + if (globalLabels[1]) { + orgLabels.push({ ...globalLabels[1] }) + cases.c7 = { name: globalLabels[1].name } + } + const smokeLabel = { name: `smoke-${RUN_ID}`, color: 'ededed', description: 'Hub-Sync smoke test' } + orgLabels.push(smokeLabel) + cases.c8 = smokeLabel + org.labels = labelsNested ? { include: orgLabels } : orgLabels + + // C5: nested null is preserved + const smokeBranch = `smoke-${RUN_ID}` + org.branches = [{ name: smokeBranch, protection: null }] + cases.c5 = { name: smokeBranch } + + // C4: an empty list does not opt out + const used = new Set(['repository', 'labels', 'branches']) + const listSection = ['milestones', ...Object.keys(globals)] + .find(k => !used.has(k) && Array.isArray(globals[k]) && globals[k].length > 0) + if (listSection) { + org[listSection] = [] + used.add(listSection) + cases.c4 = { section: listSection } + } + + // C3: null opts a whole section out + const nullSection = ['autolinks', ...Object.keys(globals)] + .find(k => !used.has(k) && globals[k] !== undefined && globals[k] !== null) + if (nullSection) { + org[nullSection] = null + cases.c3 = { section: nullSection } + } + + return { org, cases } +} + +// ─── Phases ────────────────────────────────────────────────────────────────── + +async function setup () { + logPhase('Phase 0: Setup') + + log('Checking the running safe-settings app...') + await verifyRunningApp() + + log('Checking the Hub repo...') + await verifyHubRepo() + + log(`Creating target repo ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO}...`) + await createTargetRepo() + + log('Setup complete') +} + +// H1: import the target org's config into the Hub (PR is closed, never merged). +async function phaseImport () { + logPhase('Phase 1: Import (H1)') + const result = await callImport() + if (!assert(result.status === 'imported' && result.pr, `H1: import returned 'imported' with a Hub PR (got: ${JSON.stringify(result)})`)) return + + const number = parseInt(String(result.pr).split('/').pop(), 10) + trackHubPullRequest(number) + const { data: pr } = await hubOctokit.rest.pulls.get({ owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, pull_number: number }) + trackHubBranch(pr.head.ref) + + const files = await hubOctokit.paginate(hubOctokit.rest.pulls.listFiles, { + owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, pull_number: number, per_page: 100 + }) + assert(files.some(f => f.filename === hubPaths.orgSettings()), + `H1: import PR #${number} adds ${hubPaths.orgSettings()}`) + assert(pr.base.ref === hubDefaultBranch, `H1: import PR targets the Hub default branch (${pr.base.ref})`) + + await hubOctokit.rest.pulls.update({ owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, pull_number: number, state: 'closed' }) + log(` Closed import PR #${number} without merging`) +} + +// F2 + E2: invalid YAML fails the check; closing the PR without merging syncs nothing. +async function phaseInvalidYaml () { + logPhase('Phase 2: Invalid YAML check + closed PR (F2, E2)') + const branch = `smoke-hub/${RUN_ID}-invalid` + await commitHubBranch(branch, { [hubPaths.orgSettings()]: 'repository:\n description: [unclosed\n' }) + const pr = await openHubPr(branch, 'Smoke test: invalid YAML') + + const check = await waitForHubCheck(pr.head.sha) + if (assert(check, `F2: '${CHECK_NAME}' check completed on Hub PR #${pr.number}`)) { + assert(check.conclusion === 'failure', `F2: check concluded 'failure' (got: ${check.conclusion})`) + assert((check.output?.summary || '').includes('YAML Syntax Validation Failed'), 'F2: check summary reports the YAML syntax error') + } + + await hubOctokit.rest.pulls.update({ owner: HUB_SMOKE_HUB_ORG, repo: HUB_SMOKE_HUB_REPO, pull_number: pr.number, state: 'closed' }) + log(` Closed Hub PR #${pr.number} without merging`) + await expectNoSync(pr.number, 'E2') +} + +// A1/A2/A4/A5, C1/C3–C8, F1/F3: merge an org change and inspect the sync PR. +async function phaseOrgSync () { + logPhase('Phase 3: Organization sync + merge behavior (A, C, F1, F3)') + const globals = await loadGlobals() + if (Object.keys(globals).length === 0) logWarn('globals/settings.yml is missing or empty; merge cases (C) are limited') + const { org, cases } = buildOrgSettings(globals) + const repoFileContent = `# ${SMOKE_MARKER}\nrepository:\n description: smoke repo ${RUN_ID}\n` + + for (const p of [hubPaths.orgSettings(), hubPaths.orgRepoFile(), hubPaths.strayFile()]) await trackHubFile(p) + + const branch = `smoke-hub/${RUN_ID}-org-sync` + await commitHubBranch(branch, { + [hubPaths.orgSettings()]: `# ${SMOKE_MARKER}\n${yaml.dump(org, { lineWidth: -1 })}`, + [hubPaths.orgRepoFile()]: repoFileContent, + [hubPaths.strayFile()]: `# ${SMOKE_MARKER} file directly under organizations/\nfoo: bar\n` + }) + const pr = await openHubPr(branch, 'Smoke test: organization sync') + + const check = await waitForHubCheck(pr.head.sha) + if (assert(check, `F1: '${CHECK_NAME}' check completed on Hub PR #${pr.number}`)) { + const summary = check.output?.summary || '' + assert(check.conclusion === 'success', `F1: check concluded 'success' (got: ${check.conclusion})`) + assert(new RegExp(`will receive updates:\\*\\* .*\\b${HUB_SMOKE_TARGET_ORG}\\b`).test(summary), `F1: check lists ${HUB_SMOKE_TARGET_ORG} as receiving updates`) + if (cases.c3) { + assert(summary.includes('Inheritance Overrides') && summary.includes(`\`${cases.c3.section}\``), + `F3/C3: check lists '${cases.c3.section}: null' under Inheritance Overrides`) + } + } + + await mergeHubPr(pr.number) + const syncPrs = await waitForSyncPr(pr.number) + if (!assert(syncPrs.length === 1, `A1/A5: exactly one sync PR opened in ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO} (found ${syncPrs.length})`)) return + const syncPr = syncPrs[0] + log(` Sync PR: ${syncPr.html_url}`) + + const syncFiles = (await targetOctokit.paginate(targetOctokit.rest.pulls.listFiles, { + owner: HUB_SMOKE_TARGET_ORG, repo: HUB_SMOKE_TARGET_REPO, pull_number: syncPr.number, per_page: 100 + })).map(f => f.filename) + assert(syncFiles.includes(targetSettingsPath()), `A1: sync PR updates ${targetSettingsPath()}`) + assert(syncFiles.includes(targetRepoFilePath()), `A5: the same sync PR adds ${targetRepoFilePath()}`) + assert(!syncFiles.some(f => f.includes('smoke-stray')), 'A4: file directly under organizations/ is not synced') + + const syncedRepoFile = await getFileText(targetOctokit, HUB_SMOKE_TARGET_ORG, HUB_SMOKE_TARGET_REPO, targetRepoFilePath(), syncPr.head.ref) + assert(syncedRepoFile === repoFileContent, 'A2: repos/smoke-repo.yml is copied unchanged') + + const { text, parsed: s } = await readTargetSettings(syncPr.head.ref) + assert(text.startsWith('# Auto-generated by Safe-Settings Hub-Sync'), 'A1: synced settings.yml is generated (merge header)') + + if (cases.c1) { + const others = Object.keys(globals.repository).filter(k => k !== 'description') + assert(s.repository?.description === org.repository.description, 'C1: org repository.description overrides globals') + assert(others.every(k => isDeepEqual(s.repository?.[k], globals.repository[k])), `C1: other globals repository keys are kept (${others.length})`) + } else logWarn('C1 skipped: globals has no repository section') + + if (cases.c3) { + assert(!(cases.c3.section in s), `C3: '${cases.c3.section}' (null in org file) is not in the synced settings`) + assert(text.includes(`# Not inherited from globals (set to null in org file): ${cases.c3.section}`), 'C3: header lists the opted-out section') + } else logWarn('C3 skipped: globals has no section left to opt out of') + + if (cases.c4) { + assert(isDeepEqual(s[cases.c4.section], globals[cases.c4.section]), `C4: '${cases.c4.section}: []' keeps the globals entries`) + } else logWarn('C4 skipped: globals has no non-empty list section') + + const branchEntry = (s.branches || []).find(b => b && b.name === cases.c5.name) + assert(branchEntry && 'protection' in branchEntry && branchEntry.protection === null, 'C5: nested protection: null is preserved') + + const labels = labelList(s.labels) + if (cases.c6) { + const matches = labels.filter(l => l && l.name === cases.c6.name) + assert(matches.length === 1 && String(matches[0].color).replace('#', '') === cases.c6.color, + `C6: label '${cases.c6.name}' merged into one entry with the org color (found ${matches.length})`) + } else logWarn('C6 skipped: globals has no labels') + if (cases.c7) { + assert(labels.filter(l => l && l.name === cases.c7.name).length === 1, `C7: repeated globals label '${cases.c7.name}' is not duplicated`) + } else logWarn('C7 skipped: globals has fewer than two labels') + assert(labels.some(l => l && l.name === cases.c8.name), `C8: new label '${cases.c8.name}' is added`) +} + +// C2 + F3: removing a section that globals defines keeps it inherited, with a warning. +async function phaseRemovedSection () { + logPhase('Phase 4: Removed section is still inherited (C2, F3)') + const globals = await loadGlobals() + const current = yaml.load(await getFileText(hubOctokit, HUB_SMOKE_HUB_ORG, HUB_SMOKE_HUB_REPO, hubPaths.orgSettings(), hubDefaultBranch) || '') || {} + if (!current.repository || !globals.repository) { + logWarn('C2 skipped: needs a repository section in both globals and the org file from phase 3') + return + } + const rest = { ...current } + delete rest.repository + const branch = `smoke-hub/${RUN_ID}-remove-section` + await commitHubBranch(branch, { [hubPaths.orgSettings()]: `# ${SMOKE_MARKER}\n${yaml.dump(rest, { lineWidth: -1 })}` }) + const pr = await openHubPr(branch, 'Smoke test: remove inherited section') + + const check = await waitForHubCheck(pr.head.sha) + if (assert(check, `F3: '${CHECK_NAME}' check completed on Hub PR #${pr.number}`)) { + assert(check.conclusion === 'success', `F3: check still concludes 'success' with warnings (got: ${check.conclusion})`) + assert((check.output?.summary || '').includes('`repository` was removed from'), 'C2: check warns that repository is still inherited') + } + + await mergeHubPr(pr.number) + const syncPrs = await waitForSyncPr(pr.number) + if (!assert(syncPrs.length === 1, `C2: sync PR opened for Hub PR #${pr.number} (found ${syncPrs.length})`)) return + const { parsed: s } = await readTargetSettings(syncPrs[0].head.ref) + assert(isDeepEqual(s.repository, globals.repository), 'C2: synced settings still contain the globals repository section') +} + +// E3: merging into a non-default Hub branch syncs nothing. +async function phaseNonDefaultBase () { + logPhase('Phase 5: Merge into a non-default branch (E3)') + const base = `smoke-hub/${RUN_ID}-base` + await commitHubBranch(base, {}) + const branch = `smoke-hub/${RUN_ID}-non-default` + await commitHubBranch(branch, { [hubPaths.orgSettings()]: `# ${SMOKE_MARKER}\nlabels:\n - name: smoke-e3-${RUN_ID}\n color: ededed\n` }, base) + const pr = await openHubPr(branch, 'Smoke test: merge into non-default branch', base) + await mergeHubPr(pr.number) + await expectNoSync(pr.number, 'E3') +} + +// H2: importing an org that is already in the Hub is skipped. +async function phaseReimport () { + logPhase('Phase 6: Import again (H2)') + if (/^true$/i.test(appSettings.SAFE_SETTINGS_HUB_REIMPORT || '')) { + logWarn('H2 skipped: the running app has SAFE_SETTINGS_HUB_REIMPORT=true') + return + } + const result = await callImport() + if (result.pr) { + const number = parseInt(String(result.pr).split('/').pop(), 10) + trackHubPullRequest(number) + } + assert(result.status === 'skipped' && result.reason === 'already_imported', + `H2: import is skipped as already_imported (got: ${JSON.stringify(result)})`) +} + +async function teardown () { + logPhase('Teardown') + const owner = HUB_SMOKE_HUB_ORG + const repo = HUB_SMOKE_HUB_REPO + + const hasHubChanges = state.hubPullRequests.length + state.hubBranches.length + state.hubFiles.length > 0 + if (hasHubChanges && !hubDefaultBranch) { + hubDefaultBranch = (await getRepo(hubOctokit, owner, repo))?.default_branch + } + + for (const number of [...state.hubPullRequests]) { + try { + const { data: pr } = await hubOctokit.rest.pulls.get({ owner, repo, pull_number: number }) + if (pr.state === 'open') { + await hubOctokit.rest.pulls.update({ owner, repo, pull_number: number, state: 'closed' }) + log(` Closed Hub PR #${number}`) + } + state.hubPullRequests = state.hubPullRequests.filter(n => n !== number) + saveState() + } catch (e) { + logFail(`Could not close Hub PR #${number}: ${e.message}`) + } + } + + for (const branch of [...state.hubBranches]) { + try { + await hubOctokit.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` }) + log(` Deleted Hub branch ${branch}`) + } catch (e) { + if (e.status !== 422 && e.status !== 404) { + logFail(`Could not delete Hub branch ${branch}: ${e.message}`) + continue + } + } + state.hubBranches = state.hubBranches.filter(b => b !== branch) + saveState() + } + + // Restore in reverse order. Direct commits do not trigger Hub-Sync (only merged PRs do). + for (const file of [...state.hubFiles].reverse()) { + try { + const current = await getFileText(hubOctokit, owner, repo, file.path, hubDefaultBranch) + if (current !== file.original) { + if (file.original === null) { + await deleteFileIfExists(hubOctokit, owner, repo, file.path, hubDefaultBranch, `Smoke test teardown: remove ${file.path}`) + log(` Removed Hub file ${file.path}`) + } else { + await createOrUpdateFile(hubOctokit, owner, repo, file.path, file.original, hubDefaultBranch, `Smoke test teardown: restore ${file.path}`) + log(` Restored Hub file ${file.path}`) + } + } + state.hubFiles = state.hubFiles.filter(f => f.path !== file.path) + saveState() + } catch (e) { + logFail(`Could not restore Hub file ${file.path}: ${e.message}`) + } + } + + log('Deleting target repo...') + try { + await deleteTargetRepo() + } catch (e) { + logFail(`Could not delete ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO}: ${e.message}`) + } + + const leftovers = state.hubPullRequests.length + state.hubBranches.length + state.hubFiles.length + if (leftovers === 0) { + fs.rmSync(STATE_FILE, { force: true }) + } else { + logWarn(`${leftovers} Hub change(s) could not be reverted; see ${path.basename(STATE_FILE)} and re-run with --teardown-only`) + } + log('Teardown complete') +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +function validateConfig () { + const missing = [] + if (!HUB_SMOKE_HUB_ORG) missing.push('SAFE_SETTINGS_HUB_ORG') + if (!HUB_SMOKE_HUB_REPO) missing.push('SAFE_SETTINGS_HUB_REPO') + if (!HUB_SMOKE_TARGET_ORG) missing.push('HUB_SMOKE_TARGET_ORG') + if (!HUB_SMOKE_APP_ID) missing.push('APP_ID') + if (!HUB_SMOKE_PRIVATE_KEY) missing.push('PRIVATE_KEY (or PRIVATE_KEY_PATH)') + if (missing.length > 0) { + throw new Error(`Missing required configuration: ${missing.join(', ')}. ` + + 'The smoke test uses the running app\'s Hub-Sync settings from .env plus HUB_SMOKE_TARGET_ORG.') + } + if (HUB_SMOKE_HUB_ORG.toLowerCase() === HUB_SMOKE_TARGET_ORG.toLowerCase() && + HUB_SMOKE_HUB_REPO.toLowerCase() === HUB_SMOKE_TARGET_REPO.toLowerCase()) { + throw new Error('SAFE_SETTINGS_HUB_REPO and ADMIN_REPO must differ when the Hub org is the target org') + } +} + +// Installation permissions each org needs for Hub-Sync and for this test. +const REQUIRED_PERMISSIONS = { + hub: { contents: 'write', pull_requests: 'write', checks: 'write' }, + target: { administration: 'write', contents: 'write', pull_requests: 'write' } +} +// Webhook events the hub installation must deliver for Hub-Sync to run. +const REQUIRED_HUB_EVENTS = ['pull_request'] + +const PERMISSION_RANK = { read: 1, write: 2, admin: 3 } + +async function listAppInstallations (app) { + const byOrg = new Map() + for await (const { installation } of app.eachInstallation.iterator()) { + const login = installation.account?.login + if (login) byOrg.set(login.toLowerCase(), installation) + } + return byOrg +} + +/** + * Check that the app is installed (and usable) on an org. Logs a pass/fail per + * check and returns the installation, or null when the org cannot be used. + */ +function checkInstallation (installations, org, role) { + const label = role === 'hub' ? `Hub org '${org}'` : `Target org '${org}'` + const installation = installations.get(org.toLowerCase()) + if (!installation) { + const installedOn = [...installations.values()].map(i => i.account.login).sort().join(', ') + logFail(`${label}: the GitHub App (APP_ID ${HUB_SMOKE_APP_ID}) is not installed there, so Hub-Sync has no access to it. ` + + `Install the app on '${org}' or choose another org. Installed on: ${installedOn || 'none'}`) + return null + } + if (installation.suspended_at) { + logFail(`${label}: the app installation is suspended (since ${installation.suspended_at})`) + return null + } + + const granted = installation.permissions || {} + const missing = Object.entries(REQUIRED_PERMISSIONS[role]) + .filter(([name, level]) => (PERMISSION_RANK[granted[name]] || 0) < PERMISSION_RANK[level]) + .map(([name, level]) => `${name}:${level} (has: ${granted[name] || 'none'})`) + if (missing.length > 0) { + logFail(`${label}: the app installation is missing permissions: ${missing.join(', ')}. ` + + 'Update the app permissions and accept them for this org.') + return null + } + + if (role === 'hub') { + const events = installation.events || [] + const missingEvents = REQUIRED_HUB_EVENTS.filter(e => !events.includes(e)) + if (missingEvents.length > 0) { + logFail(`${label}: the app installation is not subscribed to: ${missingEvents.join(', ')}`) + return null + } + } + + logPass(`${label}: app installed (installation ${installation.id}, ${installation.repository_selection} repositories)`) + if (role === 'target' && installation.repository_selection === 'selected') { + logWarn(`${label}: the app only has access to selected repositories; make sure '${HUB_SMOKE_TARGET_REPO}' is accessible once created`) + } + return installation +} + +async function main () { + validateConfig() + + const { App } = await import('octokit') + const app = new App({ appId: HUB_SMOKE_APP_ID, privateKey: HUB_SMOKE_PRIVATE_KEY }) + + log('Checking GitHub App installations...') + const installations = await listAppInstallations(app) + const hubInstallation = checkInstallation(installations, HUB_SMOKE_HUB_ORG, 'hub') + const sameOrg = HUB_SMOKE_HUB_ORG.toLowerCase() === HUB_SMOKE_TARGET_ORG.toLowerCase() + const targetInstallation = sameOrg ? hubInstallation : checkInstallation(installations, HUB_SMOKE_TARGET_ORG, 'target') + if (sameOrg && hubInstallation) { + // Hub and target share one installation, so it also needs the target permissions. + const asTarget = checkInstallation(installations, HUB_SMOKE_TARGET_ORG, 'target') + if (!asTarget) return finish() + } + if (!hubInstallation || !targetInstallation) { + log('Pre-flight check failed — nothing was created.') + return finish() + } + + hubOctokit = await app.getInstallationOctokit(hubInstallation.id) + targetOctokit = sameOrg ? hubOctokit : await app.getInstallationOctokit(targetInstallation.id) + log('Authenticated as GitHub App installation(s)') + + console.log(` +\x1b[36m╔══════════════════════════════════════════════════╗ +║ Safe-Settings Hub-Sync Smoke Test ║ +╚══════════════════════════════════════════════════╝\x1b[0m + App: ${HUB_SMOKE_APP_URL} + Hub: ${HUB_SMOKE_HUB_ORG}/${HUB_SMOKE_HUB_REPO} (${HUB_SMOKE_HUB_ROOT}) + Target: ${HUB_SMOKE_TARGET_ORG}/${HUB_SMOKE_TARGET_REPO} (${HUB_SMOKE_CONFIG_PATH}) +`) + + if (TEARDOWN_ONLY) { + await teardown() + return finish() + } + + if (INTERACTIVE) log('\x1b[33m[interactive] Mode enabled — will pause after each phase.\x1b[0m') + + const allPhases = [ + ['Phase 0: Setup', setup], + ['Phase 1: Import (H1)', phaseImport], + ['Phase 2: Invalid YAML check + closed PR (F2, E2)', phaseInvalidYaml], + ['Phase 3: Organization sync + merge behavior (A, C, F1, F3)', phaseOrgSync], + ['Phase 4: Removed section is still inherited (C2, F3)', phaseRemovedSection], + ['Phase 5: Merge into a non-default branch (E3)', phaseNonDefaultBase], + ['Phase 6: Import again (H2)', phaseReimport] + ] + const phases = ONLY_PHASES === null + ? allPhases + : allPhases.filter(([label]) => { + const m = label.match(/^Phase (\d+)[:\s]/) + return m !== null && (m[1] === '0' || ONLY_PHASES.has(parseInt(m[1], 10))) + }) + if (ONLY_PHASES !== null) log(`\x1b[33m[phase filter] Running setup + phase(s) [${[...ONLY_PHASES].join(', ')}] + teardown.\x1b[0m`) + + let doTeardown = true + try { + for (const [label, fn] of phases) { + const action = await runPhase(label, fn) + if (action === 'abort') { doTeardown = false; break } + if (action === 'quit') break + } + } catch (err) { + if (err instanceof InteractiveExit) { + if (err.action === 'abort') doTeardown = false + } else { + logFail(`Fatal error: ${err.message}`) + if (process.env.SMOKE_VERBOSE) console.error(err.stack) + } + } finally { + if (doTeardown) await teardown() + else log('\x1b[33m[interactive] Aborted — teardown skipped. Clean up later with: npm run smoke-test:hub:teardown\x1b[0m') + } + + finish() +} + +function finish () { + console.log(` +\x1b[36m╔══════════════════════════════════════╗ +║ Results ║ +╚══════════════════════════════════════╝\x1b[0m + \x1b[32mPassed: ${passCount}\x1b[0m + \x1b[31mFailed: ${failCount}\x1b[0m +`) + if (failures.length > 0) { + console.log('\x1b[31mFailures:\x1b[0m') + failures.forEach((f, i) => console.log(` ${i + 1}. ${f}`)) + console.log() + } + process.exit(failCount > 0 ? 1 : 0) +} + +main().catch(err => { + console.error(`\x1b[31m${err.message}\x1b[0m`) + process.exit(1) +}) diff --git a/test/unit/index-config-errors.test.js b/test/unit/index-config-errors.test.js index ac6a42815..8e7fbc55e 100644 --- a/test/unit/index-config-errors.test.js +++ b/test/unit/index-config-errors.test.js @@ -8,6 +8,7 @@ describe('configuration read error reporting', () => { let handlers let github let context + let listedFiles beforeEach(() => { jest.replaceProperty(process, 'env', { ...process.env, CRON: '', GH_ENTERPRISE: '' }) @@ -19,15 +20,16 @@ describe('configuration read error reporting', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(false) handlers = new Map() const installation = { id: 123, account: { login: 'test-org' } } + listedFiles = [{ filename: Settings.FILE_PATH }] github = { - paginate: jest.fn().mockResolvedValue([installation]), + paginate: jest.fn(async route => route === github.rest.pulls.listFiles ? listedFiles : [installation]), rest: { apps: { listInstallations: { endpoint: { merge: jest.fn().mockReturnValue({}) } }, getAuthenticated: jest.fn().mockResolvedValue({ data: { slug: 'safe-settings' } }) }, repos: { getContent: jest.fn() }, - pulls: { listFiles: jest.fn().mockResolvedValue({ data: [{ filename: Settings.FILE_PATH }] }) }, + pulls: { listFiles: jest.fn() }, checks: { update: jest.fn().mockResolvedValue({}) }, issues: { createComment: jest.fn().mockResolvedValue({}) } } @@ -113,4 +115,58 @@ describe('configuration read error reporting', () => { expect(output).not.toContain('TypeError') } }) + describe('pull request file pagination', () => { + const unrelated = count => Array.from({ length: count }, (_, i) => ({ filename: `docs/file-${i}.md` })) + const completedCheck = () => github.rest.checks.update.mock.calls.map(c => c[0]).find(p => p.status === 'completed') + + it('requests every page of changed files', async () => { + listedFiles = unrelated(3) + + await handlers.get('check_run.created')(context) + + expect(github.paginate).toHaveBeenCalledWith(github.rest.pulls.listFiles, { + owner: 'test-org', + repo: 'admin', + pull_number: 1, + per_page: 100 + }) + expect(github.rest.pulls.listFiles).not.toHaveBeenCalled() + }) + + it('validates a settings change that appears after the first page', async () => { + listedFiles = [...unrelated(150), { filename: Settings.FILE_PATH }] + github.rest.repos.getContent.mockRejectedValue(Object.assign(new Error('Forbidden'), { status: 403 })) + const handleError = jest.spyOn(Settings, 'handleError') + + await handlers.get('check_run.created')(context) + await handleError.mock.results[0]?.value + + expect(github.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ path: '.github/settings.yml', ref: 'config-change' })) + expect(completedCheck()).not.toMatchObject({ conclusion: 'success' }) + }) + + it('still reports success when no Safe-settings files changed', async () => { + listedFiles = unrelated(150) + + await handlers.get('check_run.created')(context) + + expect(github.rest.repos.getContent).not.toHaveBeenCalled() + expect(completedCheck()).toMatchObject({ + conclusion: 'success', + output: { title: 'No Safe-settings changes detected' } + }) + }) + + it('does not report success when listing changed files fails', async () => { + const error = new Error('List files failed') + github.paginate.mockImplementation(async route => { + if (route === github.rest.pulls.listFiles) throw error + return [] + }) + + await expect(handlers.get('check_run.created')(context)).rejects.toBe(error) + + expect(completedCheck()).toBeUndefined() + }) + }) }) diff --git a/test/unit/lib/hubSyncHandler-default-branch.test.js b/test/unit/lib/hubSyncHandler-default-branch.test.js new file mode 100644 index 000000000..6e6a0d86b --- /dev/null +++ b/test/unit/lib/hubSyncHandler-default-branch.test.js @@ -0,0 +1,183 @@ +const fs = require('fs') + +jest.mock('../../../lib/installationCache', () => ({ + getInstallations: jest.fn() +})) + +const env = require('../../../lib/env') +const { getInstallations } = require('../../../lib/installationCache') +const { getDefaultBranch, syncHubOrgUpdate, syncHubGlobalsUpdate } = require('../../../lib/hubSyncHandler') + +const notFound = () => Object.assign(new Error('Not Found'), { status: 404 }) + +function makeRobot (destClient) { + const error = jest.fn() + return { + errorSpy: error, // robot.log methods get wrapped by attachFileLogger + log: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error }, + auth: jest.fn().mockResolvedValue(destClient) + } +} + +function makeSpokeClient (defaultBranch) { + return { + rest: { + repos: { + get: jest.fn().mockResolvedValue({ data: { default_branch: defaultBranch } }), + getContent: jest.fn().mockRejectedValue(notFound()), + createOrUpdateFileContents: jest.fn().mockResolvedValue({}) + }, + git: { + getRef: jest.fn().mockResolvedValue({ data: { object: { sha: 'spoke-base-sha' } } }), + createRef: jest.fn().mockResolvedValue({}) + }, + pulls: { + create: jest.fn().mockResolvedValue({ data: { html_url: 'https://example.test/pr/1' } }) + } + } + } +} + +// Hub client: no manifest (sync everything) and a single changed file. +function makeContext (changedFile) { + return { + payload: { pull_request: { number: 7, head: { sha: 'hub-head-sha' } } }, + repo: () => ({ owner: 'hub-org', repo: 'hub-repo' }), + octokit: { + paginate: jest.fn().mockResolvedValue([{ filename: changedFile }]), + rest: { + pulls: { listFiles: jest.fn() }, + repos: { + getContent: jest.fn(async ({ path }) => { + if (path.endsWith('/manifest.yml')) throw notFound() + return { data: { content: Buffer.from('foo: bar\n').toString('base64'), encoding: 'base64' } } + }) + } + } + } + } +} + +describe('Hub-Sync uses the spoke repo default branch', () => { + beforeEach(() => { + jest.replaceProperty(env, 'ADMIN_REPO', 'admin') + jest.replaceProperty(env, 'CONFIG_PATH', '.github') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_ORG', 'hub-org') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_REPO', 'hub-repo') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_PATH', 'safe-settings') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_DIRECT_PUSH', 'false') + jest.spyOn(fs, 'appendFile').mockImplementation((path, content, callback) => callback(null)) + getInstallations.mockResolvedValue([ + { id: 2, account: { login: 'spoke-org', type: 'Organization' } } + ]) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('getDefaultBranch', () => { + it('returns the repository default branch', async () => { + const client = makeSpokeClient('master') + await expect(getDefaultBranch(client, 'spoke-org', 'admin')).resolves.toBe('master') + expect(client.rest.repos.get).toHaveBeenCalledWith({ owner: 'spoke-org', repo: 'admin' }) + }) + + it('propagates errors such as 404', async () => { + const client = makeSpokeClient('master') + client.rest.repos.get.mockRejectedValue(notFound()) + await expect(getDefaultBranch(client, 'spoke-org', 'admin')).rejects.toMatchObject({ status: 404 }) + }) + + it('throws instead of guessing when default_branch is missing', async () => { + const client = makeSpokeClient(undefined) + await expect(getDefaultBranch(client, 'spoke-org', 'admin')).rejects.toThrow('Could not determine the default branch for spoke-org/admin') + }) + }) + + describe('loadManifest', () => { + it('omits ref when none is given, so GitHub reads the default branch', async () => { + jest.resetModules() + const fresh = require('../../../lib/hubSyncHandler') + const freshEnv = require('../../../lib/env') + jest.replaceProperty(freshEnv, 'SAFE_SETTINGS_HUB_ORG', 'hub-org') + jest.replaceProperty(freshEnv, 'SAFE_SETTINGS_HUB_REPO', 'hub-repo') + const octokit = { rest: { repos: { getContent: jest.fn().mockResolvedValue({ data: { content: Buffer.from('rules: []\n').toString('base64'), encoding: 'base64' } }) } } } + + await fresh.loadManifest(octokit, undefined, { debug: jest.fn(), warn: jest.fn(), error: jest.fn() }) + + expect(octokit.rest.repos.getContent).toHaveBeenCalledTimes(1) + expect(octokit.rest.repos.getContent.mock.calls[0][0]).not.toHaveProperty('ref') + }) + }) + + it('globals sync aborts without a pull_request payload', async () => { + const spoke = makeSpokeClient('master') + const robot = makeRobot(spoke) + const context = makeContext('.github/safe-settings/globals/suborgs.yml') + delete context.payload.pull_request + + await syncHubGlobalsUpdate(robot, context, [{ filename: '.github/safe-settings/globals/suborgs.yml' }]) + + expect(context.octokit.rest.repos.getContent).not.toHaveBeenCalled() + expect(spoke.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled() + }) + + it('globals sync skips only the org whose default branch is unknown', async () => { + getInstallations.mockResolvedValue([ + { id: 2, account: { login: 'spoke-org', type: 'Organization' } }, + { id: 3, account: { login: 'other-org', type: 'Organization' } } + ]) + const broken = makeSpokeClient(undefined) + const healthy = makeSpokeClient('master') + const robot = makeRobot(broken) + robot.auth = jest.fn(async id => (id === 2 ? broken : healthy)) + const file = '.github/safe-settings/globals/suborgs.yml' + + await syncHubGlobalsUpdate(robot, makeContext(file), [{ filename: file }]) + + expect(robot.errorSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping org spoke-org: Could not determine the default branch')) + expect(broken.rest.repos.createOrUpdateFileContents).not.toHaveBeenCalled() + expect(healthy.rest.pulls.create).toHaveBeenCalledWith(expect.objectContaining({ base: 'master' })) + }) + + it('organization sync branches from and opens the PR against the default branch', async () => { + const spoke = makeSpokeClient('master') + const robot = makeRobot(spoke) + const context = makeContext('.github/safe-settings/organizations/spoke-org/repos/my-repo.yml') + + await syncHubOrgUpdate(robot, context, 'spoke-org', 'admin', '.github') + + expect(spoke.rest.git.getRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'heads/master' })) + expect(spoke.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ path: '.github/repos/my-repo.yml', ref: 'master' })) + expect(spoke.rest.pulls.create).toHaveBeenCalledWith(expect.objectContaining({ base: 'master' })) + expect(robot.errorSpy).not.toHaveBeenCalled() + }) + + it('organization sync with direct push commits to the default branch', async () => { + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_DIRECT_PUSH', 'true') + const spoke = makeSpokeClient('develop') + const robot = makeRobot(spoke) + const context = makeContext('.github/safe-settings/organizations/spoke-org/repos/my-repo.yml') + + await syncHubOrgUpdate(robot, context, 'spoke-org', 'admin', '.github') + + expect(spoke.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith(expect.objectContaining({ branch: 'develop' })) + expect(spoke.rest.git.createRef).not.toHaveBeenCalled() + expect(spoke.rest.pulls.create).not.toHaveBeenCalled() + }) + + it('globals file sync branches from and opens the PR against the default branch', async () => { + const spoke = makeSpokeClient('master') + const robot = makeRobot(spoke) + const file = '.github/safe-settings/globals/suborgs.yml' + const context = makeContext(file) + + await syncHubGlobalsUpdate(robot, context, [{ filename: file }]) + + expect(spoke.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ ref: 'master' })) + expect(spoke.rest.git.getRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'heads/master' })) + expect(spoke.rest.pulls.create).toHaveBeenCalledWith(expect.objectContaining({ base: 'master' })) + expect(robot.errorSpy).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/lib/hubSyncHandler-import.test.js b/test/unit/lib/hubSyncHandler-import.test.js index 650b864ee..632aff48b 100644 --- a/test/unit/lib/hubSyncHandler-import.test.js +++ b/test/unit/lib/hubSyncHandler-import.test.js @@ -31,6 +31,7 @@ describe('retrieveSettingsFromOrgs', () => { const hubClient = { rest: { repos: { + get: jest.fn().mockResolvedValue({ data: { default_branch: 'trunk' } }), getContent: jest.fn().mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 })) }, git: { @@ -52,6 +53,7 @@ describe('retrieveSettingsFromOrgs', () => { getRef: jest.fn().mockResolvedValue({ data: { object: { sha: 'source-sha' } } }) }, repos: { + get: jest.fn().mockResolvedValue({ data: { default_branch: 'master' } }), getContent: jest.fn() .mockResolvedValueOnce({ data: [{ type: 'file', path: '.github/settings.yml' }] @@ -82,6 +84,10 @@ describe('retrieveSettingsFromOrgs', () => { const results = await retrieveSettingsFromOrgs(robot, ['source-org']) expect(sourceClient.rest.repos.getContent).toHaveBeenCalledTimes(2) + expect(sourceClient.rest.git.getRef).toHaveBeenCalledWith(expect.objectContaining({ repo: 'admin', ref: 'heads/master' })) + expect(sourceClient.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ ref: 'master' })) + expect(hubClient.rest.git.getRef).toHaveBeenCalledWith(expect.objectContaining({ repo: 'hub-repo', ref: 'heads/trunk' })) + expect(hubClient.rest.pulls.create).toHaveBeenCalledWith(expect.objectContaining({ base: 'trunk' })) expect(hubClient.rest.git.createTree).toHaveBeenCalledWith(expect.objectContaining({ tree: [expect.objectContaining({ path: '.github/safe-settings/organizations/source-org/settings.yml', diff --git a/test/unit/lib/hubSyncHandler-inheritance.test.js b/test/unit/lib/hubSyncHandler-inheritance.test.js new file mode 100644 index 000000000..6c892a5ea --- /dev/null +++ b/test/unit/lib/hubSyncHandler-inheritance.test.js @@ -0,0 +1,185 @@ +const yaml = require('js-yaml') + +jest.mock('../../../lib/installationCache', () => ({ + getInstallations: jest.fn() +})) + +const env = require('../../../lib/env') +const { + loadAndMergeSettings, + checkInheritedSections, + validateAndReportHubSync +} = require('../../../lib/hubSyncHandler') + +const ROOT = '.github/safe-settings' +const GLOBALS = `${ROOT}/globals/settings.yml` +const ORG = `${ROOT}/organizations/my-org/settings.yml` + +const globalsYaml = ` +repository: + has_issues: true +labels: + - name: bug + color: d73a4a +branches: + - name: default + protection: + enforce_admins: true +` + +// files: { 'path@ref': yamlString } +function makeOctokit (files) { + return { + rest: { + repos: { + getContent: jest.fn(async ({ path, ref }) => { + const content = files[`${path}@${ref}`] + if (content === undefined) { + throw Object.assign(new Error('Not Found'), { status: 404 }) + } + return { data: { content: Buffer.from(content).toString('base64'), encoding: 'base64' } } + }) + }, + issues: { createComment: jest.fn().mockResolvedValue({}) }, + checks: { update: jest.fn().mockResolvedValue({}) } + } + } +} + +const robot = { log: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() } } + +beforeEach(() => { + jest.replaceProperty(env, 'CONFIG_PATH', '.github') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_PATH', 'safe-settings') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_ORG', 'hub-org') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_REPO', 'hub-repo') +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +function parseGenerated (content) { + return yaml.load(content) +} + +describe('loadAndMergeSettings: opting out of inherited sections', () => { + it('keeps a globals section when the org file simply omits it', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@head`]: 'repository:\n has_wiki: false\n' + }) + const merged = parseGenerated(await loadAndMergeSettings(robot, octokit, 'my-org', 'head')) + expect(merged.branches).toEqual([{ name: 'default', protection: { enforce_admins: true } }]) + }) + + it('drops a globals section that the org file sets to null', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@head`]: 'repository:\n has_wiki: false\nbranches: null\n' + }) + const content = await loadAndMergeSettings(robot, octokit, 'my-org', 'head') + const merged = parseGenerated(content) + + expect(merged).not.toHaveProperty('branches') + expect(merged.labels).toEqual([{ name: 'bug', color: 'd73a4a' }]) + expect(merged.repository).toEqual({ has_issues: true, has_wiki: false }) + expect(content).toContain('# Not inherited from globals (set to null in org file): branches') + }) + + it('keeps nested nulls such as protection: null, which core uses to delete protection', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@head`]: 'branches:\n - name: default\n protection: null\n' + }) + const merged = parseGenerated(await loadAndMergeSettings(robot, octokit, 'my-org', 'head')) + expect(merged.branches).toEqual([{ name: 'default', protection: null }]) + }) + + it('drops null sections when there is no globals file', async () => { + const octokit = makeOctokit({ + [`${ORG}@head`]: 'labels:\n - name: bug\nbranches: null\n' + }) + const merged = parseGenerated(await loadAndMergeSettings(robot, octokit, 'my-org', 'head')) + expect(merged).toEqual({ labels: [{ name: 'bug' }] }) + }) + + it('returns org-only content unchanged (comments preserved) when there are no null sections', async () => { + const orgContent = '# keep me\nlabels:\n - name: bug\n' + const octokit = makeOctokit({ [`${ORG}@head`]: orgContent }) + const content = await loadAndMergeSettings(robot, octokit, 'my-org', 'head') + expect(content.endsWith(orgContent)).toBe(true) + }) +}) + +describe('checkInheritedSections', () => { + it('reports sections deleted from the org file that globals still defines', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@main`]: 'labels: []\nbranches:\n - name: default\nteams: []\n', + [`${ORG}@head`]: 'labels: []\n' + }) + const result = await checkInheritedSections(octokit, 'hub-org', 'hub-repo', 'my-org', 'main', 'head') + // teams was removed too, but globals does not define it + expect(result).toEqual({ stillInherited: ['branches'], optedOut: [] }) + }) + + it('reports null sections as opted out instead of still inherited', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@main`]: 'branches:\n - name: default\n', + [`${ORG}@head`]: 'branches: null\n' + }) + const result = await checkInheritedSections(octokit, 'hub-org', 'hub-repo', 'my-org', 'main', 'head') + expect(result).toEqual({ stillInherited: [], optedOut: ['branches'] }) + }) + + it('reports nothing for a new org file (no base version)', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@head`]: 'labels: []\n' + }) + const result = await checkInheritedSections(octokit, 'hub-org', 'hub-repo', 'my-org', 'main', 'head') + expect(result).toEqual({ stillInherited: [], optedOut: [] }) + }) +}) + +describe('validateAndReportHubSync inheritance reporting', () => { + function makeContext (octokit) { + return { + octokit, + repo: () => ({ owner: 'hub-org', repo: 'hub-repo' }) + } + } + const payload = { repository: { owner: { login: 'hub-org' }, name: 'hub-repo' }, check_run: { id: 7 } } + const pullRequest = { number: 95, head: { sha: 'head' } } + + it('warns (without failing) when a deleted section is still inherited from globals', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@main`]: 'labels: []\nbranches:\n - name: default\n', + [`${ORG}@head`]: 'labels: []\n' + }) + await validateAndReportHubSync(robot, makeContext(octokit), payload, pullRequest, [ORG], 'main') + + const body = octokit.rest.issues.createComment.mock.calls[0][0].body + expect(body).toContain('⚠️ Warnings') + expect(body).toContain('`branches` was removed from `organizations/my-org/settings.yml`') + expect(body).toContain('set `branches: null`') + expect(octokit.rest.checks.update.mock.calls[0][0].conclusion).toBe('success') + }) + + it('lists null sections under Inheritance Overrides', async () => { + const octokit = makeOctokit({ + [`${GLOBALS}@head`]: globalsYaml, + [`${ORG}@main`]: 'branches:\n - name: default\n', + [`${ORG}@head`]: 'branches: null\n' + }) + await validateAndReportHubSync(robot, makeContext(octokit), payload, pullRequest, [ORG], 'main') + + const body = octokit.rest.issues.createComment.mock.calls[0][0].body + expect(body).toContain('Inheritance Overrides (1)') + expect(body).toContain('Organization `my-org` will not inherit from globals: `branches`') + expect(body).not.toContain('was removed from') + }) +}) diff --git a/test/unit/lib/hubSyncHandler-merge.test.js b/test/unit/lib/hubSyncHandler-merge.test.js new file mode 100644 index 000000000..7890eea87 --- /dev/null +++ b/test/unit/lib/hubSyncHandler-merge.test.js @@ -0,0 +1,102 @@ +const { mergeConfigs } = require('../../../lib/hubSyncHandler') + +// Smart merge used by Hub-Sync: globals (first) + org (second, takes precedence) +const merge = (globals, org) => mergeConfigs(globals, org, false) + +describe('Hub-Sync smart merge does not duplicate entries', () => { + it('matches milestones by title', () => { + const entry = ' - title: v1\n description: first\n state: open\n' + const result = merge(`milestones:\n${entry}`, `milestones:\n${entry} - title: v2\n`) + expect(result.milestones).toEqual([ + { title: 'v1', description: 'first', state: 'open' }, + { title: 'v2' } + ]) + }) + + it('lets the org override a matched milestone', () => { + const result = merge('milestones:\n - title: v1\n state: open\n', 'milestones:\n - title: v1\n state: closed\n') + expect(result.milestones).toEqual([{ title: 'v1', state: 'closed' }]) + }) + + it('does not duplicate nested include/exclude lists on matched collaborators', () => { + const collaborator = 'collaborators:\n - username: thor\n permission: push\n include:\n - repo-a\n - repo-b\n' + const result = merge(collaborator, collaborator.replace('repo-b', 'repo-c')) + expect(result.collaborators).toEqual([ + { username: 'thor', permission: 'push', include: ['repo-a', 'repo-b', 'repo-c'] } + ]) + }) + + it('merges ruleset rules, bypass actors and conditions without duplicates', () => { + const ruleset = ` +rulesets: + - name: protect-default + enforcement: active + bypass_actors: + - actor_id: 1 + actor_type: OrganizationAdmin + bypass_mode: always + conditions: + ref_name: + include: ['~DEFAULT_BRANCH'] + exclude: ['refs/heads/old'] + rules: + - type: deletion + - type: pull_request + parameters: + required_approving_review_count: 1 +` + const orgRuleset = ruleset.replace('required_approving_review_count: 1', 'required_approving_review_count: 2') + const [result] = merge(ruleset, orgRuleset).rulesets + + expect(result.bypass_actors).toEqual([{ actor_id: 1, actor_type: 'OrganizationAdmin', bypass_mode: 'always' }]) + expect(result.conditions).toEqual({ ref_name: { include: ['~DEFAULT_BRANCH'], exclude: ['refs/heads/old'] } }) + expect(result.rules).toEqual([ + { type: 'deletion' }, + { type: 'pull_request', parameters: { required_approving_review_count: 2 } } + ]) + }) + + it('keeps bypass actors that share an id but have different types', () => { + const result = merge( + 'bypass_actors:\n - actor_id: 5\n actor_type: Team\n', + 'bypass_actors:\n - actor_id: 5\n actor_type: Integration\n' + ) + expect(result.bypass_actors).toEqual([ + { actor_id: 5, actor_type: 'Team' }, + { actor_id: 5, actor_type: 'Integration' } + ]) + }) + + it('keeps environment reviewers that share a type but have different ids', () => { + const result = merge( + 'reviewers:\n - type: Team\n id: 1\n', + 'reviewers:\n - type: Team\n id: 2\n - type: Team\n id: 1\n' + ) + expect(result.reviewers).toEqual([{ type: 'Team', id: 1 }, { type: 'Team', id: 2 }]) + }) + + it('skips identical entries that have no identifying field', () => { + const result = merge('items:\n - color: red\n', 'items:\n - color: red\n - color: blue\n') + expect(result.items).toEqual([{ color: 'red' }, { color: 'blue' }]) + }) + + it('still merges labels by name and keeps globals-only entries', () => { + const result = merge( + 'labels:\n - name: bug\n color: red\n - name: feature\n color: blue\n', + 'labels:\n - name: bug\n color: green\n - name: docs\n color: yellow\n' + ) + expect(result.labels).toEqual([ + { name: 'bug', color: 'green' }, + { name: 'feature', color: 'blue' }, + { name: 'docs', color: 'yellow' } + ]) + }) + + it('preserves explicit nested nulls such as branch protection: null', () => { + const result = merge( + 'branches:\n - name: default\n protection:\n enforce_admins: true\n', + 'branches:\n - name: default\n protection: null\n' + ) + expect(result.branches).toEqual([{ name: 'default', protection: null }]) + }) +}) diff --git a/test/unit/routes-sync-status.test.js b/test/unit/routes-sync-status.test.js new file mode 100644 index 000000000..6e951c403 --- /dev/null +++ b/test/unit/routes-sync-status.test.js @@ -0,0 +1,423 @@ +jest.mock('../../lib/installationCache', () => ({ + getInstallations: jest.fn(), + getOrgLogins: jest.fn(), + getLastFetchedAt: jest.fn() +})) + +const { setupRoutes } = require('../../lib/routes') +const env = require('../../lib/env') +const { getInstallations, getOrgLogins } = require('../../lib/installationCache') +const { loadAndMergeSettings } = require('../../lib/hubSyncHandler') + +describe('installation sync status authentication', () => { + let robot + let hub + let spoke + let handler + let res + const hubPath = '.github/safe-settings/organizations/spoke-org' + const content = value => ({ data: { content: Buffer.from(value).toString('base64'), encoding: 'base64' } }) + const generatedSettings = source => `# Auto-generated by Safe-Settings Hub-Sync +# Source: organizations/spoke-org/settings.yml only +# DO NOT EDIT THIS FILE DIRECTLY - Edit source files in hub-org/hub + +${source}` + + beforeEach(() => { + jest.replaceProperty(env, 'ADMIN_REPO', 'admin') + jest.replaceProperty(env, 'CONFIG_PATH', '.github') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_PATH', 'safe-settings') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_ORG', 'hub-org') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_REPO', 'hub') + jest.replaceProperty(process, 'env', { ...process.env, SAFE_SETTINGS_HUB_ORG: 'hub-org', SAFE_SETTINGS_HUB_REPO: 'hub' }) + getInstallations.mockResolvedValue([ + { id: 1, account: { type: 'Organization', login: 'HUB-ORG' } }, + { id: 2, account: { type: 'Organization', login: 'spoke-org' } } + ]) + getOrgLogins.mockReturnValue(['spoke-org']) + hub = { + rest: { + repos: { + getContent: jest.fn(async params => { + if (params.owner !== 'hub-org') throw new Error('Hub client cannot read spoke') + if (params.path === hubPath) { + return { data: [{ type: 'file', name: 'settings.yml', path: `${hubPath}/settings.yml` }] } + } + if (params.path === '.github/safe-settings/globals/settings.yml') { + throw Object.assign(new Error('Not found'), { status: 404 }) + } + return content('organization settings') + }) + } + } + } + spoke = { + rest: { + repos: { + get: jest.fn().mockResolvedValue({}), + getContent: jest.fn(async params => { + if (params.owner !== 'spoke-org') throw new Error('Spoke client cannot read private hub') + return params.path === '.github' + ? { data: [{ type: 'file', name: 'settings.yml', path: '.github/settings.yml' }] } + : content(generatedSettings('organization settings')) + }), + listCommits: jest.fn().mockResolvedValue({ data: [] }) + } + } + } + robot = { + auth: jest.fn(async id => { + if (id === 1) return hub + if (id === 2) return spoke + throw new Error(`Unexpected installation ${id}`) + }), + log: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() } + } + const router = setupRoutes(robot, jest.fn()) + handler = router.stack.find(layer => layer.route && layer.route.path === '/api/safe-settings/installation').route.stack[0].handle + res = { status: jest.fn().mockReturnThis(), json: jest.fn() } + }) + + it('uses separate installation clients for matching private hub and spoke content', async () => { + await handler({ query: {} }, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync: true })]) + })) + expect(robot.auth.mock.calls).toEqual([[1], [2]]) + expect(hub.rest.repos.getContent).toHaveBeenCalledWith({ owner: 'hub-org', repo: 'hub', path: hubPath, ref: 'main' }) + expect(hub.rest.repos.getContent).toHaveBeenCalledWith({ + owner: 'hub-org', repo: 'hub', path: '.github/safe-settings/globals/settings.yml', ref: 'main' + }) + expect(hub.rest.repos.getContent).toHaveBeenCalledWith({ + owner: 'hub-org', repo: 'hub', path: `${hubPath}/settings.yml`, ref: 'main' + }) + expect(spoke.rest.repos.getContent.mock.calls).toEqual([ + [{ owner: 'spoke-org', repo: 'admin', path: '.github' }], + [{ owner: 'spoke-org', repo: 'admin', path: '.github/settings.yml' }] + ]) + expect(res.json.mock.calls[0][0].hubSync).toEqual({ status: 'available' }) + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it('reports that sync status is unavailable when Hub-Sync is not configured', async () => { + const { SAFE_SETTINGS_HUB_ORG: _org, SAFE_SETTINGS_HUB_REPO: _repo, ...rest } = process.env + jest.replaceProperty(process, 'env', rest) + + await handler({ query: {} }, res) + + expect(robot.auth.mock.calls).toEqual([[2]]) + expect(hub.rest.repos.getContent).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + hubSync: { status: 'not_configured' }, + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync: false })]) + })) + expect(robot.log.warn).not.toHaveBeenCalled() + }) + + it('still reports differing content as out of sync', async () => { + spoke.rest.repos.getContent.mockImplementation(async params => { + if (params.owner !== 'spoke-org') throw new Error('Spoke client cannot read private hub') + return params.path === '.github' + ? { data: [{ type: 'file', name: 'settings.yml', path: '.github/settings.yml' }] } + : content('different settings') + }) + + await handler({ query: {} }, res) + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync: false })]) + })) + expect(robot.log.error).not.toHaveBeenCalled() + }) + + describe('nested configuration sync status', () => { + const managedFiles = { + 'settings.yml': 'organization settings', + 'repos/settings.yml': 'repository settings', + 'suborgs/division/settings.yml': 'division settings' + } + + function mockContents (client, owner, root, files) { + const entries = new Map([[root, []]]) + for (const [relativePath, value] of Object.entries(files)) { + const parts = relativePath.split('/') + let parent = root + for (const [index, name] of parts.entries()) { + const entryPath = `${parent}/${name}` + const isFile = index === parts.length - 1 + if (!entries.has(entryPath)) { + entries.get(parent).push({ type: isFile ? 'file' : 'dir', name, path: entryPath }) + entries.set(entryPath, isFile ? value : []) + } + parent = entryPath + } + } + client.rest.repos.getContent.mockImplementation(async params => { + if (params.owner !== owner) throw new Error(`Wrong installation client for ${params.owner}`) + if (!entries.has(params.path)) throw Object.assign(new Error(`Not found: ${params.path}`), { status: 404 }) + const value = entries.get(params.path) + return Array.isArray(value) ? { data: value } : content(value) + }) + } + + function deployedFiles (hubFiles) { + return Object.fromEntries(Object.entries(hubFiles).map(([relativePath, value]) => [ + relativePath, + relativePath === 'settings.yml' ? generatedSettings(value) : value + ])) + } + + async function expectSyncStatus (hubFiles, spokeFiles, isInSync) { + mockContents(hub, 'hub-org', hubPath, hubFiles) + mockContents(spoke, 'spoke-org', '.github', spokeFiles) + + await handler({ query: {} }, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync })]) + })) + } + + it('compares matching nested files by relative path, including duplicate basenames', async () => { + await expectSyncStatus(managedFiles, deployedFiles(managedFiles), true) + + for (const relativePath of Object.keys(managedFiles)) { + expect(spoke.rest.repos.getContent).toHaveBeenCalledWith({ + owner: 'spoke-org', repo: 'admin', path: `.github/${relativePath}` + }) + expect(hub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ + owner: 'hub-org', repo: 'hub', path: `${hubPath}/${relativePath}` + })) + } + for (const directory of ['repos', 'suborgs', 'suborgs/division']) { + expect(hub.rest.repos.getContent).toHaveBeenCalledWith({ + owner: 'hub-org', repo: 'hub', path: `${hubPath}/${directory}`, ref: 'main' + }) + } + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it('compares generated settings after merging global and organization sources', async () => { + const orgSource = 'repository:\n has_wiki: false\n' + const globalSource = 'repository:\n has_issues: true\n' + const hubFiles = { 'settings.yml': orgSource } + mockContents(hub, 'hub-org', hubPath, hubFiles) + mockContents(spoke, 'spoke-org', '.github', { 'settings.yml': orgSource }) + const hubGetContent = hub.rest.repos.getContent.getMockImplementation() + hub.rest.repos.getContent.mockImplementation(async params => { + if (params.path === '.github/safe-settings/globals/settings.yml') return content(globalSource) + return hubGetContent(params) + }) + + const expectedSettings = await loadAndMergeSettings(robot, hub, 'spoke-org', 'main') + expect(expectedSettings).not.toBe(orgSource) + expect(expectedSettings).not.toBe(globalSource) + const spokeGetContent = spoke.rest.repos.getContent.getMockImplementation() + spoke.rest.repos.getContent.mockImplementation(async params => { + if (params.path === '.github/settings.yml') return content(expectedSettings) + return spokeGetContent(params) + }) + + await handler({ query: {} }, res) + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync: true })]) + })) + expect(hub.rest.repos.getContent).toHaveBeenCalledWith({ + owner: 'hub-org', repo: 'hub', path: '.github/safe-settings/globals/settings.yml', ref: 'main' + }) + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it.each(['repos/settings.yml', 'suborgs/division/settings.yml'])('reports stale %s as out of sync', async relativePath => { + await expectSyncStatus(managedFiles, { ...deployedFiles(managedFiles), [relativePath]: 'stale settings' }, false) + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it.each(['repos/settings.yml', 'suborgs/division/settings.yml'])('reports missing %s as out of sync', async relativePath => { + const spokeFiles = deployedFiles(managedFiles) + delete spokeFiles[relativePath] + await expectSyncStatus(managedFiles, spokeFiles, false) + }) + + it('reports a missing file inside an existing nested directory as out of sync', async () => { + const spokeFiles = deployedFiles(managedFiles) + delete spokeFiles['repos/settings.yml'] + await expectSyncStatus(managedFiles, { + ...spokeFiles, + 'repos/other.yml': 'unrelated settings' + }, false) + }) + + it('supports configurations with only nested files', async () => { + const files = { 'repos/example.yml': 'repository settings' } + await expectSyncStatus(files, files, true) + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it('does not mistake a directory for a matching empty nested file', async () => { + await expectSyncStatus({ 'repos/empty.yml': '' }, { 'repos/empty.yml/other.yml': '' }, false) + expect(robot.log.error).toHaveBeenCalledWith(expect.stringContaining('Expected readable file content at .github/repos/empty.yml')) + }) + + it('ignores extra spoke files and directories not managed by the hub', async () => { + await expectSyncStatus(managedFiles, { + ...deployedFiles(managedFiles), + 'repos/local.yml': 'local settings', + 'workflows/ci.yml': 'local workflow' + }, true) + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it('does not report success when reading a nested hub directory fails', async () => { + mockContents(hub, 'hub-org', hubPath, managedFiles) + mockContents(spoke, 'spoke-org', '.github', managedFiles) + const getContent = hub.rest.repos.getContent.getMockImplementation() + hub.rest.repos.getContent.mockImplementation(async params => { + if (params.path === `${hubPath}/repos`) throw new Error('Nested directory unavailable') + return getContent(params) + }) + + await handler({ query: {} }, res) + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync: false })]) + })) + expect(robot.log.error).toHaveBeenCalledWith(expect.stringContaining('Nested directory unavailable')) + }) + + it('does not report success when a nested hub listing is not a directory', async () => { + mockContents(hub, 'hub-org', hubPath, managedFiles) + mockContents(spoke, 'spoke-org', '.github', managedFiles) + const getContent = hub.rest.repos.getContent.getMockImplementation() + hub.rest.repos.getContent.mockImplementation(async params => { + if (params.path === `${hubPath}/repos`) return content('not a directory') + return getContent(params) + }) + + await handler({ query: {} }, res) + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: expect.arrayContaining([expect.objectContaining({ account: 'spoke-org', isInSync: false })]) + })) + expect(robot.log.error).toHaveBeenCalledWith(expect.stringContaining('Expected a directory listing for hub path')) + }) + }) + + it('preserves installation and commit information when the hub installation is missing', async () => { + getInstallations.mockResolvedValue([{ id: 2, account: { type: 'Organization', login: 'spoke-org' } }]) + spoke.rest.repos.listCommits.mockResolvedValue({ + data: [{ sha: 'abc123', commit: { message: 'Update settings' } }] + }) + + await handler({ query: {} }, res) + + expect(robot.auth.mock.calls).toEqual([[2]]) + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + hubSync: { status: 'hub_not_installed', org: 'hub-org' }, + installations: [expect.objectContaining({ + account: 'spoke-org', + hasConfigRepo: true, + sha: 'abc123', + message: 'Update settings', + isInSync: false + })] + })) + expect(hub.rest.repos.getContent).not.toHaveBeenCalled() + expect(spoke.rest.repos.getContent).not.toHaveBeenCalled() + expect(robot.log.warn).toHaveBeenCalledWith('Installation for hub org hub-org not found; skipping hub sync comparisons') + expect(robot.log.error).not.toHaveBeenCalled() + }) + + it('reports hub authentication failures explicitly', async () => { + const error = new Error('Hub authentication failed') + robot.auth.mockRejectedValueOnce(error) + + await handler({ query: {} }, res) + + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ error: error.message }) + expect(robot.log.error).toHaveBeenCalledWith(error) + expect(spoke.rest.repos.get).not.toHaveBeenCalled() + }) + + it('does not require hub authentication when activity is disabled', async () => { + getInstallations.mockResolvedValue([{ id: 2, account: { type: 'Organization', login: 'spoke-org' } }]) + + await handler({ query: { disableActivity: 'true' } }, res) + + expect(robot.auth).not.toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + installations: [expect.objectContaining({ account: 'spoke-org' })] + })) + }) +}) + +describe('hub content setup guidance', () => { + let robot + let handler + let res + + beforeEach(() => { + jest.replaceProperty(env, 'CONFIG_PATH', '.github') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_ORG', 'hub-org') + jest.replaceProperty(env, 'SAFE_SETTINGS_HUB_REPO', 'hub') + jest.replaceProperty(process, 'env', { ...process.env, SAFE_SETTINGS_HUB_ORG: 'hub-org', SAFE_SETTINGS_HUB_REPO: 'hub' }) + getInstallations.mockClear() + getInstallations.mockResolvedValue([{ id: 1, account: { type: 'Organization', login: 'hub-org' } }]) + robot = { + auth: jest.fn().mockResolvedValue({ + rest: { + repos: { + getContent: jest.fn().mockResolvedValue({ data: { type: 'file', name: 'settings.yml', path: '.github/settings.yml', sha: 'abc', size: 0, content: '', encoding: 'base64' } }), + listCommits: jest.fn().mockResolvedValue({ data: [] }) + } + } + }), + log: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() } + } + const router = setupRoutes(robot, jest.fn()) + handler = router.stack.find(layer => layer.route && layer.route.path === '/api/safe-settings/hub/content').route.stack[0].handle + res = { status: jest.fn().mockReturnThis(), json: jest.fn() } + }) + + it.each(['SAFE_SETTINGS_HUB_ORG', 'SAFE_SETTINGS_HUB_REPO'])('explains that Hub-Sync is not set up when %s is not configured', async name => { + const { [name]: _omitted, ...rest } = process.env + jest.replaceProperty(process, 'env', rest) + + await handler({ params: {}, query: {} }, res) + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'HUB_NOT_CONFIGURED', + error: 'Safe-Settings Hub is not set up yet' + })) + expect(getInstallations).not.toHaveBeenCalled() + expect(robot.auth).not.toHaveBeenCalled() + }) + + it('explains when the app is not installed on the hub organization', async () => { + getInstallations.mockResolvedValue([{ id: 2, account: { type: 'Organization', login: 'spoke-org' } }]) + + await handler({ params: {}, query: {} }, res) + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'HUB_INSTALLATION_NOT_FOUND', + message: expect.stringContaining("Hub organization 'hub-org'") + })) + expect(robot.auth).not.toHaveBeenCalled() + }) + + it('loads hub content normally when Hub-Sync is configured', async () => { + await handler({ params: {}, query: { recursive: 'false' } }, res) + + expect(robot.auth).toHaveBeenCalledWith(1) + expect(res.json).not.toHaveBeenCalledWith(expect.objectContaining({ code: expect.any(String) })) + }) +}) diff --git a/ui/package-lock.json b/ui/package-lock.json index 23c0972c1..4ea81c0dd 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -96,6 +96,39 @@ "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -329,6 +362,28 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, "node_modules/@img/sharp-darwin-x64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", @@ -351,6 +406,22 @@ "@img/sharp-libvips-darwin-x64": "1.2.0" } }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-darwin-x64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", @@ -367,6 +438,390 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -439,6 +894,19 @@ "langium": "^4.0.0" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@next/env": { "version": "15.4.7", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.7.tgz", @@ -686,39 +1154,251 @@ "tailwindcss": "4.1.12" } }, - "node_modules/@tailwindcss/oxide": { + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", - "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", + "cpu": [ + "arm64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.12", - "@tailwindcss/oxide-darwin-arm64": "4.1.12", - "@tailwindcss/oxide-darwin-x64": "4.1.12", - "@tailwindcss/oxide-freebsd-x64": "4.1.12", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", - "@tailwindcss/oxide-linux-x64-musl": "4.1.12", - "@tailwindcss/oxide-wasm32-wasi": "4.1.12", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", - "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -726,7 +1406,7 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 10" @@ -746,6 +1426,17 @@ "tailwindcss": "4.1.12" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1320,74 +2011,353 @@ "node": ">= 6" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", - "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/typescript-estree": "8.40.0" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=14.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", - "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.40.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "license": "ISC" + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@unrs/resolver-binding-darwin-x64": { + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", "cpu": [ "x64" ], @@ -1395,7 +2365,7 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ] }, "node_modules/@upsetjs/venn.js": { @@ -4821,6 +5791,27 @@ "lightningcss-win32-x64-msvc": "1.30.1" } }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-darwin-x64": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", @@ -4842,6 +5833,186 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/ui/src/app/components/OrganizationsTable.jsx b/ui/src/app/components/OrganizationsTable.jsx index d435855a5..5cc1c3405 100644 --- a/ui/src/app/components/OrganizationsTable.jsx +++ b/ui/src/app/components/OrganizationsTable.jsx @@ -52,6 +52,8 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { const [retrieveMessage, setRetrieveMessage] = useState(null); const [retrieveError, setRetrieveError] = useState(null); const [retrieveResults, setRetrieveResults] = useState(null); + const [hubSync, setHubSync] = useState(null); + const syncUnavailable = hubSync && hubSync.status !== "available"; // Fetch real organizations from backend API on client hydration useEffect(() => { @@ -82,6 +84,7 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { isInSync: typeof i.isInSync === "boolean" ? i.isInSync : false, })); setFetched(mapped); + setHubSync(json.hubSync || null); setError(null); }) .catch((e) => { @@ -383,6 +386,13 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { {/* Table */} + {syncUnavailable && ( +
+ {hubSync.status === "hub_not_installed" + ? `Sync status can't be checked because the Safe-Settings app isn't installed on the Hub organization '${hubSync.org}'.` + : "Sync status will appear here once Safe-Settings Hub is set up. Until then, organizations aren't compared with the Hub configuration."} +
+ )}
cycleSort('key')} className="theme-text-primary user-select-none" style={{ width: '20%', cursor: 'pointer' }}>Key {renderSortIcon('key')} cycleSort('value')} className="theme-text-primary user-select-none" style={{ width: '30%', cursor: 'pointer' }}>Value {renderSortIcon('value')} cycleSort('description')} className="theme-text-primary user-select-none" style={{ cursor: 'pointer' }}>Description {renderSortIcon('description')}
handleSort("name")} >
-
Organization Name
-
{renderSortIcon("name")}
+ Showing {sortedData.length} of {data.length} organizations diff --git a/ui/src/app/components/Safe-settings-hubContent.jsx b/ui/src/app/components/Safe-settings-hubContent.jsx index 0c0610890..b930dfdc9 100644 --- a/ui/src/app/components/Safe-settings-hubContent.jsx +++ b/ui/src/app/components/Safe-settings-hubContent.jsx @@ -129,7 +129,7 @@ export default function SafeSettingsHubContent3b() { if (node.type === 'file') { const selected = selectedPath === node.path; return ( -
setSelectedPath(node.path)}> +
setSelectedPath(node.path)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPath(node.path); } }}> {node.name}
@@ -219,7 +219,7 @@ export default function SafeSettingsHubContent3b() {
{child.type === 'dir' ? : } - {child.name} + {child.lastCommitMessage || '—'}
@@ -523,7 +533,14 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { )} From 72500badda96f78bc33c7fb1f2f3ca0534131ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Wed, 4 Nov 2026 17:18:46 -0500 Subject: [PATCH 8/9] fixed PR comment issue --- ui/src/app/components/EnvVariables.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/app/components/EnvVariables.jsx b/ui/src/app/components/EnvVariables.jsx index 847e687d6..941195f34 100644 --- a/ui/src/app/components/EnvVariables.jsx +++ b/ui/src/app/components/EnvVariables.jsx @@ -125,16 +125,17 @@ export default function EnvVariables() { {sorted.map(r => { const sensitive = SENSITIVE_REGEX.test(r.key); + const value = r.value == null ? '' : String(r.value); return ( ); From 14260d9670cf53aa038e15d7af3edd37ab1f4c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Wed, 4 Nov 2026 18:39:25 -0500 Subject: [PATCH 9/9] fixed PR comment issue --- docs/hubSyncHandler/README.md | 7 ++ lib/hubSyncHandler.js | 46 ++++--- .../lib/hubSyncHandler-default-branch.test.js | 115 +++++++++++++++++- 3 files changed, 153 insertions(+), 15 deletions(-) diff --git a/docs/hubSyncHandler/README.md b/docs/hubSyncHandler/README.md index fe1f81a97..ef68cb11e 100644 --- a/docs/hubSyncHandler/README.md +++ b/docs/hubSyncHandler/README.md @@ -10,6 +10,13 @@ One central **master admin repository** (the hub) serves as the authoritative so **Note:** When something changes in the 'Master' repo (the hub), only those changed files are copied to each affected ORG’s admin repo, so everything stays in sync. +Post-merge synchronization reads the manifest, global settings, organization +settings, and copied files from the PR's merge commit SHA, not its head branch. +This includes changes already on the default branch when the PR was merged. +The PR number is still used to identify changed files. If the webhook omits the +merge SHA, Hub-Sync retrieves it from GitHub; if it cannot be resolved, sync +stops and logs an error rather than using a stale PR-head snapshot. + #### :warning: In order for `Safe-Settings Hub-Sync` to enforce Policy sync in the controlled ORGs/Repos, **Safe-Settings** needs to be given **Bypass authority** for any branch protection Ruleset that prevents 'direct pushes'. ## Sync Lifecycle (High Level) diff --git a/lib/hubSyncHandler.js b/lib/hubSyncHandler.js index d7cc7576b..bca12c7f0 100644 --- a/lib/hubSyncHandler.js +++ b/lib/hubSyncHandler.js @@ -391,8 +391,9 @@ function filterOrgsByManifest(orgNames, manifest, logger) { * @param {string} orgName Destination organization login (also folder name under organizations/) * @param {string} destRepo Destination repo name inside orgName (e.g. admin repo) * @param {string} destinationFolder Base folder in destination repo where content lives (e.g. .github or .github/safe-settings) + * @param {string} [sourceSha] Resolved merge commit SHA for source reads */ -async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationFolder) { +async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationFolder, sourceSha) { attachFileLogger(robot) try { robot.log.info(`Syncing safe settings for organization: ${orgName}`) @@ -402,6 +403,7 @@ async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationF robot.log.warn('No pull_request payload found; aborting sync') return } + sourceSha = sourceSha || await getMergedSourceSha(context) const { owner: srcOwner, repo: srcRepo } = context.repo() const pull_number = pr.number const configRoot = env.CONFIG_PATH || '.github/' @@ -453,7 +455,7 @@ async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationF } // Load manifest and check if this org/files should be synced - const manifest = await loadManifest(context.octokit, pr.head.sha, robot.log) + const manifest = await loadManifest(context.octokit, sourceSha, robot.log) // Check if org is allowed by manifest if (!matchesOrgTargets(orgName, manifest, robot.log)) { @@ -494,7 +496,7 @@ async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationF robot.log.info(`Detected settings.yml change - will merge with globals`) // Load and merge settings (in-memory only) - const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, pr.head.sha) + const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, sourceSha) if (mergedContent) { const destPath = `${destBase}/settings.yml`.replace(/\/+/g, '/') @@ -539,7 +541,7 @@ async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationF } const destPath = `${destBase}/${relative}`.replace(/\/+/g, '/') - const srcContentResp = await context.octokit.rest.repos.getContent({ owner: srcOwner, repo: srcRepo, path: f.filename, ref: pr.head.sha }) + const srcContentResp = await context.octokit.rest.repos.getContent({ owner: srcOwner, repo: srcRepo, path: f.filename, ref: sourceSha }) const data = srcContentResp.data if (Array.isArray(data)) { continue @@ -589,6 +591,20 @@ async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationF * @param {import('probot').Probot} robot * @param {import('probot').Context} context */ +async function getMergedSourceSha (context) { + const pr = context.payload.pull_request + if (!pr || !pr.merged) throw new Error('Hub-Sync requires a merged pull request') + if (pr.merge_commit_sha) return pr.merge_commit_sha + const { data } = await context.octokit.rest.pulls.get({ + ...context.repo(), + pull_number: pr.number + }) + if (!data.merged || !data.merge_commit_sha) { + throw new Error(`Could not determine the merge commit SHA for PR #${pr.number}`) + } + return data.merge_commit_sha +} + async function hubSyncHandler (robot, context) { attachFileLogger(robot) const { payload } = context @@ -610,6 +626,7 @@ async function hubSyncHandler (robot, context) { const pr = pull_request const { owner, repo } = context.repo() const pull_number = pr.number + const sourceSha = await getMergedSourceSha(context) // Paginate through all files changed in the PR const files = await context.octokit.paginate( @@ -625,7 +642,7 @@ async function hubSyncHandler (robot, context) { if (globalsChanged) { robot.log.debug('Detected changes in the globals folder. Routing to syncHubGlobalsUpdate(...).') - await module.exports.syncHubGlobalsUpdate(robot, context, files) + await module.exports.syncHubGlobalsUpdate(robot, context, files, sourceSha) } if (orgsChanged) { @@ -648,7 +665,7 @@ async function hubSyncHandler (robot, context) { for (const orgName of orgNames) { const destRepo = env.ADMIN_REPO const destinationFolder = env.CONFIG_PATH || '.github' - await module.exports.syncHubOrgUpdate(robot, context, orgName, destRepo, destinationFolder) + await module.exports.syncHubOrgUpdate(robot, context, orgName, destRepo, destinationFolder, sourceSha) } } } catch (err) { @@ -661,21 +678,22 @@ async function hubSyncHandler (robot, context) { * @param {import('probot').Probot} robot * @param {import('probot').Context} context * @param {Array} files - Array of changed file objects from PR + * @param {string} [sourceSha] Resolved merge commit SHA for source reads */ -async function syncHubGlobalsUpdate (robot, context, files) { +async function syncHubGlobalsUpdate (robot, context, files, sourceSha) { attachFileLogger(robot) robot.log.info(`Syncing safe settings for 'globals/'.`) - // Get PR head SHA for loading manifest and files + // Read all source content from the immutable post-merge snapshot. const pr = context.payload.pull_request - if (!pr || !pr.head || !pr.head.sha) { + if (!pr) { robot.log.warn('No pull_request payload found; aborting sync') return } - const prHeadSha = pr.head.sha + sourceSha = sourceSha || await getMergedSourceSha(context) // Load manifest with new filtering logic - const manifest = await loadManifest(context.octokit, prHeadSha, robot.log) + const manifest = await loadManifest(context.octokit, sourceSha, robot.log) const changedGlobals = files.filter(f => /\/globals\//.test(f.filename)) if (!changedGlobals.length) { @@ -723,7 +741,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: orgsPath, - ref: prHeadSha + ref: sourceSha }) if (Array.isArray(orgsResp.data)) { orgDirs = orgsResp.data.filter(item => item.type === 'dir').map(item => item.name) @@ -774,7 +792,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { } // Load and merge settings - const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, prHeadSha) + const mergedContent = await loadAndMergeSettings(robot, context.octokit, orgName, sourceSha) if (!mergedContent) { robot.log.info(`No settings content to sync for org ${orgName}`) continue @@ -930,7 +948,7 @@ async function syncHubGlobalsUpdate (robot, context, files) { robot.log.info(`Syncing ${fileName} to ${orgName}`) try { let srcContentResp - const srcRef = prHeadSha + const srcRef = sourceSha srcContentResp = await context.octokit.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, diff --git a/test/unit/lib/hubSyncHandler-default-branch.test.js b/test/unit/lib/hubSyncHandler-default-branch.test.js index 6e6a0d86b..bd07b1ecc 100644 --- a/test/unit/lib/hubSyncHandler-default-branch.test.js +++ b/test/unit/lib/hubSyncHandler-default-branch.test.js @@ -7,6 +7,7 @@ jest.mock('../../../lib/installationCache', () => ({ const env = require('../../../lib/env') const { getInstallations } = require('../../../lib/installationCache') const { getDefaultBranch, syncHubOrgUpdate, syncHubGlobalsUpdate } = require('../../../lib/hubSyncHandler') +const hubSync = require('../../../lib/hubSyncHandler') const notFound = () => Object.assign(new Error('Not Found'), { status: 404 }) @@ -41,7 +42,7 @@ function makeSpokeClient (defaultBranch) { // Hub client: no manifest (sync everything) and a single changed file. function makeContext (changedFile) { return { - payload: { pull_request: { number: 7, head: { sha: 'hub-head-sha' } } }, + payload: { pull_request: { number: 7, merged: true, merge_commit_sha: 'hub-merge-sha', head: { sha: 'hub-head-sha' } } }, repo: () => ({ owner: 'hub-org', repo: 'hub-repo' }), octokit: { paginate: jest.fn().mockResolvedValue([{ filename: changedFile }]), @@ -180,4 +181,116 @@ describe('Hub-Sync uses the spoke repo default branch', () => { expect(spoke.rest.pulls.create).toHaveBeenCalledWith(expect.objectContaining({ base: 'master' })) expect(robot.errorSpy).not.toHaveBeenCalled() }) + + describe('post-merge source snapshot', () => { + it.each(['organization', 'globals'])('reads all %s source files at the merge SHA', async kind => { + const spoke = makeSpokeClient('master') + const robot = makeRobot(spoke) + const file = kind === 'organization' + ? '.github/safe-settings/organizations/spoke-org/repos/my-repo.yml' + : '.github/safe-settings/globals/suborgs.yml' + const context = makeContext(file) + + if (kind === 'organization') { + await syncHubOrgUpdate(robot, context, 'spoke-org', 'admin', '.github') + } else { + await syncHubGlobalsUpdate(robot, context, [{ filename: file }]) + } + + expect(context.octokit.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ path: file, ref: 'hub-merge-sha' })) + for (const [params] of context.octokit.rest.repos.getContent.mock.calls) { + expect(params.ref).toBe('hub-merge-sha') + } + expect(spoke.rest.repos.createOrUpdateFileContents).toHaveBeenCalled() + expect(robot.errorSpy).not.toHaveBeenCalled() + }) + + it.each(['organization', 'globals'])('generates %s settings from merged globals and organization content', async kind => { + const spoke = makeSpokeClient('master') + const robot = makeRobot(spoke) + const root = '.github/safe-settings' + const file = kind === 'organization' + ? `${root}/organizations/spoke-org/settings.yml` + : `${root}/globals/settings.yml` + const context = makeContext(file) + context.octokit.rest.repos.getContent.mockImplementation(async ({ path, ref }) => { + expect(ref).toBe('hub-merge-sha') + if (path.endsWith('/manifest.yml')) throw notFound() + if (path === `${root}/organizations`) { + return { data: [{ type: 'dir', name: 'spoke-org' }] } + } + const content = path === `${root}/globals/settings.yml` + ? 'repository:\n has_issues: false\n' + : 'repository:\n description: org policy\n' + return { data: { content: Buffer.from(content).toString('base64'), encoding: 'base64' } } + }) + + if (kind === 'organization') { + await syncHubOrgUpdate(robot, context, 'spoke-org', 'admin', '.github') + } else { + await syncHubGlobalsUpdate(robot, context, [{ filename: file }]) + } + + expect(spoke.rest.repos.createOrUpdateFileContents).toHaveBeenCalled() + const written = spoke.rest.repos.createOrUpdateFileContents.mock.calls[0][0] + const output = Buffer.from(written.content, 'base64').toString('utf8') + expect(output).toContain('has_issues: false') + expect(output).toContain('description: org policy') + for (const [params] of context.octokit.rest.repos.getContent.mock.calls) { + expect(params.ref).toBe('hub-merge-sha') + } + expect(robot.errorSpy).not.toHaveBeenCalled() + }) + + function routedContext () { + const context = makeContext('.github/safe-settings/organizations/spoke-org/settings.yml') + context.payload.repository = { name: 'hub-repo', owner: { login: 'hub-org' } } + context.octokit.paginate.mockResolvedValue([ + { filename: '.github/safe-settings/globals/settings.yml' }, + { filename: '.github/safe-settings/organizations/spoke-org/settings.yml' } + ]) + return context + } + + it('resolves a missing merge SHA once and passes it to both sync paths', async () => { + const robot = makeRobot(makeSpokeClient('master')) + const context = routedContext() + delete context.payload.pull_request.merge_commit_sha + context.octokit.rest.pulls.get = jest.fn().mockResolvedValue({ + data: { merged: true, merge_commit_sha: 'fetched-merge-sha' } + }) + const globals = jest.spyOn(hubSync, 'syncHubGlobalsUpdate').mockResolvedValue() + const org = jest.spyOn(hubSync, 'syncHubOrgUpdate').mockResolvedValue() + + await hubSync.hubSyncHandler(robot, context) + + expect(context.octokit.rest.pulls.get).toHaveBeenCalledTimes(1) + expect(context.octokit.rest.pulls.get).toHaveBeenCalledWith({ owner: 'hub-org', repo: 'hub-repo', pull_number: 7 }) + expect(globals).toHaveBeenCalledWith(robot, context, expect.any(Array), 'fetched-merge-sha') + expect(org).toHaveBeenCalledWith(robot, context, 'spoke-org', 'admin', '.github', 'fetched-merge-sha') + expect(context.octokit.paginate).toHaveBeenCalledWith(context.octokit.rest.pulls.listFiles, { + owner: 'hub-org', repo: 'hub-repo', pull_number: 7, per_page: 100 + }) + }) + + it.each(['missing', 'API failure'])('aborts explicitly when merge SHA resolution fails: %s', async mode => { + const robot = makeRobot(makeSpokeClient('master')) + const context = routedContext() + delete context.payload.pull_request.merge_commit_sha + context.octokit.rest.pulls.get = mode === 'missing' + ? jest.fn().mockResolvedValue({ data: { merged: true, merge_commit_sha: null } }) + : jest.fn().mockRejectedValue(new Error('GitHub unavailable')) + const globals = jest.spyOn(hubSync, 'syncHubGlobalsUpdate').mockResolvedValue() + const org = jest.spyOn(hubSync, 'syncHubOrgUpdate').mockResolvedValue() + + await hubSync.hubSyncHandler(robot, context) + + expect(globals).not.toHaveBeenCalled() + expect(org).not.toHaveBeenCalled() + expect(context.octokit.paginate).not.toHaveBeenCalled() + expect(robot.errorSpy).toHaveBeenCalledWith(expect.stringContaining( + mode === 'missing' ? 'Could not determine the merge commit SHA' : 'GitHub unavailable' + )) + }) + }) })
- {org.isInSync ? ( + {syncUnavailable ? ( + + — + + ) : org.isInSync ? ( ✓ diff --git a/ui/src/app/components/Safe-settings-hubContent.jsx b/ui/src/app/components/Safe-settings-hubContent.jsx index b930dfdc9..c5c5d8b45 100644 --- a/ui/src/app/components/Safe-settings-hubContent.jsx +++ b/ui/src/app/components/Safe-settings-hubContent.jsx @@ -22,12 +22,21 @@ export default function SafeSettingsHubContent3b() { if (!hydrated) return; setLoading(true); setError(null); fetch(withBasePath('/api/safe-settings/hub/content?fetchContent=true')) - .then(r => { - if (!r.ok) throw new Error(`Unable to retrieve safe-settings hub content (HTTP ${r.status})`); + .then(async r => { + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw Object.assign(new Error(body.error || `HTTP ${r.status}`), { body }); + } return r.json(); }) .then(json => { setRootTree(json); setLastFetchedAt(new Date()); }) - .catch((error) => { setError("Unable to load content. Please try again later."); setRootTree(null); }) + .catch((err) => { + const body = err.body || {}; + setError(body.code + ? { setup: true, title: body.error, message: body.message } + : { message: 'Unable to load content. Please try again later.' }); + setRootTree(null); + }) .finally(() => setLoading(false)); }; @@ -183,8 +192,15 @@ export default function SafeSettingsHubContent3b() { {loading &&
Loading…
} - {error &&
{error}
} - {!loading && !displayTree &&
No entries
} + {error && (error.setup + ? ( +
+ {error.title} +
{error.message}
+
+ ) + :
{error.message}
)} + {!loading && !displayTree && !error?.setup &&
No entries
} {!loading && displayTree && (
From ed56aee54ece16449788c9b0dd1d4c61fa235481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Wed, 4 Nov 2026 15:26:41 -0500 Subject: [PATCH 6/9] Removed the unsupported window.__NEXT_DATA__ lookup --- ui/next.config.js | 3 +++ ui/src/app/utils/basePath.js | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/next.config.js b/ui/next.config.js index c625482b2..0366161c7 100644 --- a/ui/next.config.js +++ b/ui/next.config.js @@ -10,6 +10,9 @@ const basePath = normalizePrefix(process.env.SAFE_SETTINGS_HUB_URL_PREFIX === un const nextConfig = { output: "export", basePath: basePath, + env: { + NEXT_PUBLIC_SAFE_SETTINGS_HUB_URL_PREFIX: basePath, + }, // Disable Next.js ESLint checks during builds eslint: { ignoreDuringBuilds: true, diff --git a/ui/src/app/utils/basePath.js b/ui/src/app/utils/basePath.js index 59ffb55e1..4c1f80c69 100644 --- a/ui/src/app/utils/basePath.js +++ b/ui/src/app/utils/basePath.js @@ -1,6 +1,6 @@ /** * URL prefix utility for handling deployment behind proxies - * Reads from Next.js basePath configuration + * Reads the public build-time prefix shared with next.config.js */ // Normalize URL prefix: add leading '/' if missing, treat '/' as empty for root @@ -9,11 +9,10 @@ const normalizePrefix = (prefix) => { return prefix.startsWith('/') ? prefix : `/${prefix}`; }; -// Next.js automatically handles basePath for routing and asset loading -// We expose this for manual link construction (URL prefix in browser) -export const BASE_PATH = typeof window !== 'undefined' - ? normalizePrefix(window.__NEXT_DATA__?.basePath ?? '/safe-settings') - : normalizePrefix(process.env.NEXT_PUBLIC_SAFE_SETTINGS_HUB_URL_PREFIX ?? process.env.SAFE_SETTINGS_HUB_URL_PREFIX ?? '/safe-settings'); +// Next.js handles routing and assets; this is for manual URL construction. +export const BASE_PATH = normalizePrefix( + process.env.NEXT_PUBLIC_SAFE_SETTINGS_HUB_URL_PREFIX ?? '/safe-settings' +); /** * Prepend base path to a URL From be76e9e69b06e51fcc227cf2a706dc7201a3b1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Efeish?= Date: Wed, 4 Nov 2026 15:49:41 -0500 Subject: [PATCH 7/9] Enhance deployment instructions and UI components Updated deployment instructions and UI rebuild notes. Changed header to button for better accessibility. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/hubSyncHandler/BASE_PATH.md | 2 +- docs/hubSyncHandler/README.md | 13 ++++++++++--- ui/src/app/components/OrganizationsTable.jsx | 7 ++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/hubSyncHandler/BASE_PATH.md b/docs/hubSyncHandler/BASE_PATH.md index 548fbae76..4da9f3a59 100644 --- a/docs/hubSyncHandler/BASE_PATH.md +++ b/docs/hubSyncHandler/BASE_PATH.md @@ -42,7 +42,7 @@ SAFE_SETTINGS_HUB_URL_PREFIX= ### 2. Rebuild the UI -After changing the SAFE_SETTINGS_HUB_URL_PREFIX, you must rebuild the Next.js UI: +After changing SAFE_SETTINGS_HUB_URL_PREFIX, you must rebuild the Next.js UI. Next.js reads `.env` files from `ui/`, not the repository root, so export `SAFE_SETTINGS_HUB_URL_PREFIX` into the build shell (or set it in `ui/.env.local`) with the same value used by the backend before running these commands: ```bash cd ui diff --git a/docs/hubSyncHandler/README.md b/docs/hubSyncHandler/README.md index 6658d82d5..fe1f81a97 100644 --- a/docs/hubSyncHandler/README.md +++ b/docs/hubSyncHandler/README.md @@ -47,10 +47,17 @@ Deploy Safe Settings on a VM (AWS EC2, Azure VM, Google Compute Engine, etc.): #### Option 2: Container Deployment Deploy using Docker: -1. Use the provided `Dockerfile` or `docker-compose.yml` -2. Build and run the container: +1. Build the UI using the steps in **Build the UI** below, with the same URL prefix as the backend. +2. Create `docker-compose.override.yml` in the repository root to mount the export at the path expected by the backend: + ```yaml + services: + probot: + volumes: + - ./ui/out:/opt/safe-settings/ui/out:ro + ``` +3. Build and run the container with both Compose files: ```bash - docker-compose up -d + docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d --build ``` #### Option 3: Serverless Deployment diff --git a/ui/src/app/components/OrganizationsTable.jsx b/ui/src/app/components/OrganizationsTable.jsx index 5cc1c3405..9923f84a0 100644 --- a/ui/src/app/components/OrganizationsTable.jsx +++ b/ui/src/app/components/OrganizationsTable.jsx @@ -454,10 +454,11 @@ const OrganizationsTable = ({ organizations: propOrganizations = [] }) => {
handleSort("lastSyncDate")} > - Last Sync - {renderSortIcon("lastSyncDate")} +
{r.key} - {maskedValue(r.key, r.value)} + {maskedValue(r.key, value)} {r.description || 'NA'} {sensitive && } - +