From 8ba86dc69ed60944ef7b3766829106f58cb17f44 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Thu, 5 Nov 2026 23:01:15 +1100 Subject: [PATCH] readme update --- README.md | 96 ++-- scripts/docs/update_provider_listings.js | 490 +++++++++--------- scripts/docs/update_provider_listings.test.js | 328 +++++++----- 3 files changed, 487 insertions(+), 427 deletions(-) diff --git a/README.md b/README.md index b3924b1e..4d974d00 100644 --- a/README.md +++ b/README.md @@ -31,55 +31,53 @@ A repository of `provider` interface documents supporting [stackql](https://stac The following StackQL providers are available in the public StackQL Provider Registry. Use `REGISTRY LIST` to discover providers and `REGISTRY PULL ` to install one. -| Icon | Provider | Full Name | Latest Version | Date First Published | Date Last Published | -| --- | --- | --- | --- | --- | --- | -| Anthropic | `anthropic` | Anthropic | `v26.09.00461` | 2024-10-25 | 2026-09-15 | -| Anthropic Admin | `anthropic_admin` | Anthropic Admin | `v26.07.00416` | 2026-07-09 | 2026-07-09 | -| Amazon Web Services | `aws` | Amazon Web Services | `v26.08.00444` | 2022-08-19 | 2026-08-20 | -| AWS Cloud Control | `awscc` | AWS Cloud Control | `v26.07.00426` | 2024-03-07 | 2026-07-28 | -| Microsoft Azure | `azure` | Microsoft Azure | `v26.07.00420` | 2022-08-20 | 2026-07-14 | -| Azure Extras | `azure_extras` | Azure Extras | `v26.07.00420` | 2022-08-20 | 2026-07-14 | -| Azure ISV | `azure_isv` | Azure ISV | `v26.07.00420` | 2024-01-25 | 2026-07-14 | -| Azure Stack | `azure_stack` | Azure Stack | `v26.07.00420` | 2024-01-25 | 2026-07-14 | -| ClickHouse | `clickhouse` | ClickHouse | `v26.08.00442` | 2026-08-17 | 2026-08-17 | -| Cloudflare | `cloudflare` | Cloudflare | `v26.07.00424` | 2023-03-24 | 2026-07-21 | -| Confluent | `confluent` | Confluent | `v26.05.00403` | 2024-11-01 | 2026-05-31 | -| Databricks Account | `databricks_account` | Databricks Account | `v26.07.00430` | 2024-12-20 | 2026-07-30 | -| Databricks Workspace | `databricks_workspace` | Databricks Workspace | `v26.07.00430` | 2024-12-20 | 2026-07-30 | -| DataDog | `datadog` | DataDog | `v26.08.00455` | 2023-12-28 | 2026-08-26 | -| Deno Deploy | `deno` | Deno Deploy | `v26.09.00459` | 2025-09-09 | 2026-09-15 | -| Digital Ocean | `digitalocean` | Digital Ocean | `v26.08.00434` | 2023-03-24 | 2026-08-03 | -| Microsoft Entra ID | `entra_id` | Microsoft Entra ID | `v26.07.00412` | 2026-06-02 | 2026-07-06 | -| Firebase | `firebase` | Firebase | `v26.08.00446` | 2022-07-04 | 2026-08-20 | -| Fivetran | `fivetran` | Fivetran | `v26.09.00467` | 2023-03-17 | 2026-09-28 | -| GitHub | `github` | GitHub | `v26.08.00448` | 2022-05-02 | 2026-08-20 | -| GitLab | `gitlab` | GitLab | `v26.09.00465` | 2026-09-26 | 2026-09-26 | -| GoDaddy | `godaddy` | GoDaddy | `v26.09.00463` | 2023-12-16 | 2026-09-16 | -| Google Admin | `googleadmin` | Google Admin | `v26.07.00432` | 2023-07-07 | 2026-07-31 | -| Google Cloud Platform | `googleapis.com` | Google Cloud Platform | `v26.08.00446` | 2022-03-15 | 2026-08-20 | -| Google Workspace | `googleworkspace` | Google Workspace | `v26.07.00432` | 2023-01-08 | 2026-07-31 | -| Homebrew | `homebrew` | Homebrew | `v25.01.00292` | 2023-12-28 | 2025-01-27 | -| Kubernetes | `k8s` | Kubernetes | `v26.08.00453` | 2022-06-19 | 2026-08-24 | -| Kafka | `kafka` | Kafka | `v26.05.00403` | 2026-05-31 | 2026-05-31 | -| Lambda Cloud | `lambda` | Lambda Cloud | `v26.10.00481` | 2026-10-21 | 2026-10-21 | -| Linode | `linode` | Linode | `v26.08.00440` | 2023-05-12 | 2026-08-04 | -| Netlify | `netlify` | Netlify | `v26.08.00457` | 2022-05-11 | 2026-08-28 | -| NVIDIA NGC | `nvidia` | NVIDIA NGC | `v26.10.00479` | 2026-10-19 | 2026-10-19 | -| Oracle Cloud Infrastructure | `oci` | Oracle Cloud Infrastructure | `v26.10.00475` | 2026-10-05 | 2026-10-05 | -| Okta | `okta` | Okta | `v26.08.00436` | 2022-03-15 | 2026-08-04 | -| OpenAI | `openai` | OpenAI | `v26.07.00422` | 2024-10-25 | 2026-07-15 | -| OpenAI Admin | `openai_admin` | OpenAI Admin | `v26.07.00422` | 2026-07-15 | 2026-07-15 | -| PagerDuty | `pagerduty` | PagerDuty | `v26.09.00459` | 2023-12-20 | 2026-09-15 | -| Railway | `railway` | Railway | `v26.09.00469` | 2026-09-28 | 2026-09-28 | -| Snowflake | `snowflake` | Snowflake | `v26.08.00438` | 2025-05-20 | 2026-08-04 | -| SumoLogic | `sumologic` | SumoLogic | `v26.09.00459` | 2022-12-10 | 2026-09-15 | -| Supabase | `supabase` | Supabase | `v26.10.00477` | 2026-10-12 | 2026-10-12 | -| TypeSafe AI | `typesafe` | TypeSafe AI | `v26.10.00473` | 2026-10-04 | 2026-10-04 | -| Vercel | `vercel` | Vercel | `v26.09.00459` | 2023-12-14 | 2026-09-15 | +| Icon | Provider | Latest Version | Date First Published | Date Last Published | +| --- | --- | --- | --- | --- | +| anthropic | `anthropic` | `v26.09.00461` | 2024-10-25 | 2026-09-15 | +| anthropic_admin | `anthropic_admin` | `v26.07.00416` | 2026-07-09 | 2026-07-09 | +| aws | `aws` | `v26.08.00444` | 2022-08-19 | 2026-08-20 | +| awscc | `awscc` | `v26.07.00426` | 2024-03-07 | 2026-07-28 | +| azure | `azure` | `v26.07.00420` | 2022-08-20 | 2026-07-14 | +| azure_extras | `azure_extras` | `v26.07.00420` | 2022-08-20 | 2026-07-14 | +| azure_isv | `azure_isv` | `v26.07.00420` | 2024-01-25 | 2026-07-14 | +| azure_stack | `azure_stack` | `v26.07.00420` | 2024-01-25 | 2026-07-14 | +| clickhouse | `clickhouse` | `v26.08.00442` | 2026-08-17 | 2026-08-17 | +| cloudflare | `cloudflare` | `v26.07.00424` | 2023-03-24 | 2026-07-21 | +| confluent | `confluent` | `v26.05.00403` | 2024-11-01 | 2026-05-31 | +| databricks_account | `databricks_account` | `v26.11.00490` | 2024-12-20 | 2026-11-05 | +| databricks_workspace | `databricks_workspace` | `v26.11.00490` | 2024-12-20 | 2026-11-05 | +| datadog | `datadog` | `v26.08.00455` | 2023-12-28 | 2026-08-26 | +| deno | `deno` | `v26.09.00459` | 2025-09-09 | 2026-09-15 | +| digitalocean | `digitalocean` | `v26.08.00434` | 2023-03-24 | 2026-08-03 | +| entra_id | `entra_id` | `v26.07.00412` | 2026-06-02 | 2026-07-06 | +| firebase | `firebase` | `v26.08.00446` | 2022-07-04 | 2026-08-20 | +| fivetran | `fivetran` | `v26.09.00467` | 2023-03-17 | 2026-09-28 | +| github | `github` | `v26.08.00448` | 2022-05-02 | 2026-08-20 | +| gitlab | `gitlab` | `v26.09.00465` | 2026-09-26 | 2026-09-26 | +| godaddy | `godaddy` | `v26.09.00463` | 2023-12-16 | 2026-09-16 | +| google | `google` | `v26.08.00446` | 2022-03-15 | 2026-08-20 | +| googleadmin | `googleadmin` | `v26.07.00432` | 2023-07-07 | 2026-07-31 | +| googleworkspace | `googleworkspace` | `v26.07.00432` | 2023-01-08 | 2026-07-31 | +| homebrew | `homebrew` | `v25.01.00292` | 2023-12-28 | 2025-01-27 | +| k8s | `k8s` | `v26.08.00453` | 2022-06-19 | 2026-08-24 | +| kafka | `kafka` | `v26.05.00403` | 2026-05-31 | 2026-05-31 | +| lambda | `lambda` | `v26.10.00481` | 2026-10-21 | 2026-10-21 | +| linode | `linode` | `v26.08.00440` | 2023-05-12 | 2026-08-04 | +| netlify | `netlify` | `v26.08.00457` | 2022-05-11 | 2026-08-28 | +| nvidia | `nvidia` | `v26.10.00479` | 2026-10-19 | 2026-10-19 | +| oci | `oci` | `v26.10.00475` | 2026-10-05 | 2026-10-05 | +| okta | `okta` | `v26.08.00436` | 2022-03-15 | 2026-08-04 | +| openai | `openai` | `v26.07.00422` | 2024-10-25 | 2026-07-15 | +| openai_admin | `openai_admin` | `v26.07.00422` | 2026-07-15 | 2026-07-15 | +| pagerduty | `pagerduty` | `v26.09.00459` | 2023-12-20 | 2026-09-15 | +| railway | `railway` | `v26.09.00469` | 2026-09-28 | 2026-09-28 | +| snowflake | `snowflake` | `v26.08.00438` | 2025-05-20 | 2026-08-04 | +| sumologic | `sumologic` | `v26.09.00459` | 2022-12-10 | 2026-09-15 | +| supabase | `supabase` | `v26.10.00477` | 2026-10-12 | 2026-10-12 | +| typesafe | `typesafe` | `v26.10.00473` | 2026-10-04 | 2026-10-04 | +| vercel | `vercel` | `v26.09.00459` | 2023-12-14 | 2026-09-15 | -Latest versions come from the [production registry manifest](https://registry.stackql.app/providers/dist/providers.yaml). Dates are UTC, inferred from production Git history: **Date First Published** is the earliest commit on the production branch that introduced the provider's source or packaged artifacts; **Date Last Published** is the release commit corresponding to its latest published version. These are Git-based publication estimates, not deployment completion timestamps. `googleapis.com` is the source directory for the registry provider named `google`. - ### Updating the Catalog With Node.js 18 or later, internet access, and a full Git checkout, run: @@ -89,10 +87,6 @@ git fetch origin main node scripts/docs/update_provider_listings.js ``` -The script updates the catalog and table of contents in this README. It reads published versions from the production registry and dates from `origin/main`; use `--git-ref ` to select another local production-history ref, or `--check` to check for stale content without writing. It fails explicitly if metadata, local icons, or release history are missing. When adding a provider, add its display name and icon mapping to the script and its favicon to `images/`. - -Favicons and display names were initially copied from the [StackQL website provider tiles](https://stackql.io/providers); Lambda Cloud's favicon comes from [Lambda](https://lambda.ai/favicon.ico), as it did not yet have a website tile. Brand spelling and capitalization are preserved. - ## Context diff --git a/scripts/docs/update_provider_listings.js b/scripts/docs/update_provider_listings.js index 3cd12762..06042180 100644 --- a/scripts/docs/update_provider_listings.js +++ b/scripts/docs/update_provider_listings.js @@ -1,243 +1,247 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); - -const ROOT = path.resolve(__dirname, '..', '..'); -const MANIFEST_URL = 'https://registry.stackql.app/providers/dist/providers.yaml'; -const TOC_MARKER = 'TABLE OF CONTENTS'; -const CATALOG_MARKER = 'PROVIDER CATALOG'; - -// Display names and icons from stackql.io's provider tiles, keyed by source directory. -const PROVIDERS = { - anthropic: ['Anthropic', 'anthropic.png'], - anthropic_admin: ['Anthropic Admin', 'anthropic.png'], - aws: ['Amazon Web Services', 'aws.ico'], - awscc: ['AWS Cloud Control', 'aws.ico'], - azure: ['Microsoft Azure', 'azure.ico'], - azure_extras: ['Azure Extras', 'azure.ico'], - azure_isv: ['Azure ISV', 'azure.ico'], - azure_stack: ['Azure Stack', 'azure.ico'], - clickhouse: ['ClickHouse', 'clickhouse.ico'], - cloudflare: ['Cloudflare', 'cloudflare.ico'], - confluent: ['Confluent', 'confluent.ico'], - databricks_account: ['Databricks Account', 'databricks.ico'], - databricks_workspace: ['Databricks Workspace', 'databricks.ico'], - datadog: ['DataDog', 'datadog.ico'], - deno: ['Deno Deploy', 'deno.ico'], - digitalocean: ['Digital Ocean', 'digitalocean.png'], - entra_id: ['Microsoft Entra ID', 'entra_id.svg'], - firebase: ['Firebase', 'firebase.png'], - fivetran: ['Fivetran', 'fivetran.png'], - github: ['GitHub', 'github.ico'], - gitlab: ['GitLab', 'gitlab.ico'], - godaddy: ['GoDaddy', 'godaddy.png'], - googleadmin: ['Google Admin', 'googleadmin.ico'], - 'googleapis.com': ['Google Cloud Platform', 'google.ico'], - googleworkspace: ['Google Workspace', 'googleworkspace.ico'], - homebrew: ['Homebrew', 'homebrew.ico'], - k8s: ['Kubernetes', 'k8s.png'], - kafka: ['Kafka', 'kafka.png'], - lambda: ['Lambda Cloud', 'lambda.svg'], - linode: ['Linode', 'linode.ico'], - netlify: ['Netlify', 'netlify.ico'], - nvidia: ['NVIDIA NGC', 'nvidia.ico'], - oci: ['Oracle Cloud Infrastructure', 'oci.ico'], - okta: ['Okta', 'okta.png'], - openai: ['OpenAI', 'openai.ico'], - openai_admin: ['OpenAI Admin', 'openai.ico'], - pagerduty: ['PagerDuty', 'pagerduty.svg'], - railway: ['Railway', 'railway.ico'], - snowflake: ['Snowflake', 'snowflake.ico'], - sumologic: ['SumoLogic', 'sumologic.png'], - supabase: ['Supabase', 'supabase.ico'], - typesafe: ['TypeSafe AI', 'typesafe.png'], - vercel: ['Vercel', 'vercel.ico'], -}; - -function git(args) { - return execFileSync('git', ['--no-pager', ...args], { - cwd: ROOT, - encoding: 'utf8', - maxBuffer: 16 * 1024 * 1024, - }).trim(); -} - -// Accept only the registry's provider -> versions-list YAML schema, not arbitrary YAML. -function parseManifest(text) { - const providers = new Map(); - let provider; - let expectingVersions = false; - let rootSeen = false; - for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) { - if (!line.trim()) continue; - if (line === 'providers:' && !rootSeen) { - rootSeen = true; - continue; - } - let match = /^ ([a-z0-9_.-]+):$/.exec(line); - if (rootSeen && match && !expectingVersions) { - provider = match[1]; - if (providers.has(provider)) throw new Error(`Duplicate provider: ${provider}`); - providers.set(provider, []); - expectingVersions = true; - continue; - } - if (provider && expectingVersions && line === ' versions:') { - expectingVersions = false; - continue; - } - match = /^ (?: )?- (v\d+\.\d+\.\d+)$/.exec(line); - if (provider && !expectingVersions && match) { - providers.get(provider).push(match[1]); - continue; - } - throw new Error(`Unsupported registry manifest line: ${JSON.stringify(line)}`); - } - if (!providers.size || expectingVersions) throw new Error('Incomplete registry manifest'); - for (const [name, versions] of providers) { - if (!versions.length) throw new Error(`No published versions for ${name}`); - } - return providers; -} - -function compareVersions(left, right) { - const a = left.slice(1).split('.').map(Number); - const b = right.slice(1).split('.').map(Number); - return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; -} - -function utcDate(timestamp) { - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) throw new Error(`Invalid Git date: ${timestamp}`); - return date.toISOString().slice(0, 10); -} - -function releaseDates(log) { - const dates = new Map(); - for (const line of log.split(/\r?\n/)) { - if (!line) continue; - const [timestamp, subject] = line.split('\t'); - const match = /^Merge pull request #(\d+) from /.exec(subject) - || /\(#(\d+)\)\s*$/.exec(subject); - if (match) { - const date = utcDate(timestamp); - const version = `v${date.slice(2, 4)}.${date.slice(5, 7)}.${match[1].padStart(5, '0')}`; - if (dates.has(version)) throw new Error(`Ambiguous release history for ${version}`); - dates.set(version, date); - } - } - return dates; -} - -function replaceBlock(readme, marker, content) { - const begin = ``; - const end = ``; - const start = readme.indexOf(begin); - const finish = readme.indexOf(end); - if (start < 0 || finish < start || readme.indexOf(begin, start + 1) !== -1 - || readme.indexOf(end, finish + 1) !== -1) { - throw new Error(`Expected exactly one ${marker} marker pair in README.md`); - } - return `${readme.slice(0, start + begin.length)}\n${content}\n${readme.slice(finish)}`; -} - -function tableOfContents(readme) { - let fence; - const anchors = new Map(); - const entries = []; - for (const line of readme.split(/\r?\n/)) { - const delimiter = /^\s*(`{3,}|~{3,})/.exec(line); - if (delimiter) { - if (!fence) fence = delimiter[1]; - else if (delimiter[1][0] === fence[0] && delimiter[1].length >= fence.length) fence = undefined; - continue; - } - if (fence) continue; - const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); - if (!heading) continue; - const title = heading[2]; - const slug = title.toLowerCase().replace(/[`]/g, '').replace(/[^\w -]/g, '').replace(/ /g, '-'); - const count = anchors.get(slug) || 0; - anchors.set(slug, count + 1); - if (heading[1].length === 1 || title === 'Table of Contents') continue; - const anchor = count ? `${slug}-${count}` : slug; - entries.push(`${' '.repeat(heading[1].length - 2)}- [${title}](#${anchor})`); - } - return entries.join('\n'); -} - -function buildCatalog(manifest, dates, firstPublished, root = ROOT) { - const rows = [ - '| Icon | Provider | Full Name | Latest Version | Date First Published | Date Last Published |', - '| --- | --- | --- | --- | --- | --- |', - ]; - const names = [...manifest.keys()].map((name) => name === 'google' ? 'googleapis.com' : name).sort(); - for (const name of names) { - if (!Object.hasOwn(PROVIDERS, name)) throw new Error(`Add display name and icon metadata for ${name}`); - const [fullName, icon] = PROVIDERS[name]; - if (!fs.statSync(path.join(root, 'images', icon)).isFile()) { - throw new Error(`Missing favicon for ${name}: images/${icon}`); - } - const registryName = name === 'googleapis.com' ? 'google' : name; - const latest = [...manifest.get(registryName)].sort(compareVersions).at(-1); - const last = dates.get(latest); - if (!last) throw new Error(`No production release commit for ${name} ${latest}; fetch production Git history`); - const first = firstPublished(name, registryName); - if (!/^\d{4}-\d{2}-\d{2}$/.test(first) || first > last) { - throw new Error(`Invalid first publication date for ${name}: ${first}`); - } - rows.push(`| ${fullName} | \`${name}\` | ${fullName} | \`${latest}\` | ${first} | ${last} |`); - } - return rows.join('\n'); -} - -async function main(args = process.argv.slice(2)) { - let ref = 'origin/main'; - let check = false; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--check') check = true; - else if (args[i] === '--git-ref' && args[i + 1] && !args[i + 1].startsWith('-')) ref = args[++i]; - else throw new Error(`Unknown or incomplete argument: ${args[i]}`); - } - if (git(['rev-parse', '--is-shallow-repository']) !== 'false') { - throw new Error('Full Git history is required; run git fetch --unshallow'); - } - const commit = git(['rev-parse', '--verify', `${ref}^{commit}`]); - const dates = releaseDates(git(['log', '--first-parent', '--format=%cI%x09%s', commit])); - const response = await fetch(MANIFEST_URL, { signal: AbortSignal.timeout(30000) }); - if (!response.ok) throw new Error(`Registry request failed: HTTP ${response.status}`); - const manifest = parseManifest(await response.text()); - const catalog = buildCatalog(manifest, dates, (name, registryName) => { - const history = git([ - 'log', '--first-parent', '--reverse', '--format=%cI', commit, '--', - `providers/src/${name}`, `providers/dist/${name}`, `providers/dist/${registryName}`, - ]); - if (!history) throw new Error(`No production history for ${name}`); - return utcDate(history.split(/\r?\n/)[0]); - }); - const readmePath = path.join(ROOT, 'README.md'); - const original = fs.readFileSync(readmePath, 'utf8'); - const newline = original.includes('\r\n') ? '\r\n' : '\n'; - let updated = replaceBlock(original.replace(/\r\n/g, '\n'), CATALOG_MARKER, catalog); - updated = replaceBlock(updated, TOC_MARKER, tableOfContents(updated)).replace(/\n/g, newline); - if (updated === original) { - console.log(`Provider catalog is current (${manifest.size} providers).`); - } else if (check) { - throw new Error('README.md provider catalog or table of contents is stale; run this script without --check'); - } else { - fs.writeFileSync(readmePath, updated); - console.log(`Updated README.md (${manifest.size} providers).`); - } -} - -if (require.main === module) { - main().catch((error) => { - console.error(`Provider catalog update failed: ${error.message}`); - process.exitCode = 1; - }); -} - -module.exports = { parseManifest, compareVersions, utcDate, releaseDates, replaceBlock, tableOfContents, buildCatalog }; +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const IMAGES_DIR = path.join(ROOT, 'images'); +const SOURCE_DIR = path.join(ROOT, 'providers', 'src'); +const MANIFEST_URL = 'https://registry.stackql.app/providers/dist/providers.yaml'; +const TOC_MARKER = 'TABLE OF CONTENTS'; +const CATALOG_MARKER = 'PROVIDER CATALOG'; + +function git(args) { + return execFileSync('git', ['--no-pager', ...args], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }).trim(); +} + +// Accept only the registry's provider -> versions-list YAML schema, not arbitrary YAML. +function parseManifest(text) { + const providers = new Map(); + let provider; + let expectingVersions = false; + let rootSeen = false; + for (const line of text.replace(/^/, '').split(/\r?\n/)) { + if (!line.trim()) continue; + if (line === 'providers:' && !rootSeen) { + rootSeen = true; + continue; + } + let match = /^ ([a-z0-9_.-]+):$/.exec(line); + if (rootSeen && match && !expectingVersions) { + provider = match[1]; + if (providers.has(provider)) throw new Error(`Duplicate provider: ${provider}`); + providers.set(provider, []); + expectingVersions = true; + continue; + } + if (provider && expectingVersions && line === ' versions:') { + expectingVersions = false; + continue; + } + match = /^ (?: )?- (v\d+\.\d+\.\d+)$/.exec(line); + if (provider && !expectingVersions && match) { + providers.get(provider).push(match[1]); + continue; + } + throw new Error(`Unsupported registry manifest line: ${JSON.stringify(line)}`); + } + if (!providers.size || expectingVersions) throw new Error('Incomplete registry manifest'); + for (const [name, versions] of providers) { + if (!versions.length) throw new Error(`No published versions for ${name}`); + } + return providers; +} + +function compareVersions(left, right) { + const a = left.slice(1).split('.').map(Number); + const b = right.slice(1).split('.').map(Number); + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +function utcDate(timestamp) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) throw new Error(`Invalid Git date: ${timestamp}`); + return date.toISOString().slice(0, 10); +} + +function releaseDates(log) { + const dates = new Map(); + for (const line of log.split(/\r?\n/)) { + if (!line) continue; + const [timestamp, subject] = line.split('\t'); + const match = /^Merge pull request #(\d+) from /.exec(subject) + || /\(#(\d+)\)\s*$/.exec(subject); + if (match) { + const date = utcDate(timestamp); + const version = `v${date.slice(2, 4)}.${date.slice(5, 7)}.${match[1].padStart(5, '0')}`; + if (dates.has(version)) throw new Error(`Ambiguous release history for ${version}`); + dates.set(version, date); + } + } + return dates; +} + +// Index icon files by stem (file name without extension); the first file in sorted order wins per stem. +function iconIndex(dir = IMAGES_DIR) { + const icons = new Map(); + for (const file of fs.readdirSync(dir).sort()) { + if (!fs.statSync(path.join(dir, file)).isFile()) continue; + const stem = path.parse(file).name; + if (!icons.has(stem)) icons.set(stem, file); + } + return icons; +} + +// Exact stem match first, otherwise the longest stem the provider name starts with, +// so databricks.ico serves databricks_account and databricks_workspace. Undefined when nothing matches. +function resolveIcon(name, icons) { + let best; + for (const stem of icons.keys()) { + if (name.startsWith(stem) && (!best || stem.length > best.length)) best = stem; + } + return best === undefined ? undefined : icons.get(best); +} + +// Top-level id from a provider document; nested ids are indented and never match. +function providerId(document) { + const match = /^id:[ \t]*["']?([A-Za-z0-9_.-]+)["']?[ \t]*\r?$/m.exec(document); + return match ? match[1] : undefined; +} + +// Map registry provider ids to providers/src directories (googleapis.com publishes as google). +function sourceDirectories(dir = SOURCE_DIR) { + const sources = new Map(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + let id; + for (const version of fs.readdirSync(path.join(dir, entry.name)).sort()) { + const file = path.join(dir, entry.name, version, 'provider.yaml'); + if (!fs.existsSync(file)) continue; + id = providerId(fs.readFileSync(file, 'utf8')); + if (id) break; + } + id = id || entry.name; + if (sources.has(id)) { + throw new Error(`Provider ${id} has multiple source directories: ${sources.get(id)}, ${entry.name}`); + } + sources.set(id, entry.name); + } + return sources; +} + +function replaceBlock(readme, marker, content) { + const begin = ``; + const end = ``; + const start = readme.indexOf(begin); + const finish = readme.indexOf(end); + if (start < 0 || finish < start || readme.indexOf(begin, start + 1) !== -1 + || readme.indexOf(end, finish + 1) !== -1) { + throw new Error(`Expected exactly one ${marker} marker pair in README.md`); + } + return `${readme.slice(0, start + begin.length)}\n${content}\n${readme.slice(finish)}`; +} + +function tableOfContents(readme) { + let fence; + const anchors = new Map(); + const entries = []; + for (const line of readme.split(/\r?\n/)) { + const delimiter = /^\s*(`{3,}|~{3,})/.exec(line); + if (delimiter) { + if (!fence) fence = delimiter[1]; + else if (delimiter[1][0] === fence[0] && delimiter[1].length >= fence.length) fence = undefined; + continue; + } + if (fence) continue; + const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (!heading) continue; + const title = heading[2]; + const slug = title.toLowerCase().replace(/[`]/g, '').replace(/[^\w -]/g, '').replace(/ /g, '-'); + const count = anchors.get(slug) || 0; + anchors.set(slug, count + 1); + if (heading[1].length === 1 || title === 'Table of Contents') continue; + const anchor = count ? `${slug}-${count}` : slug; + entries.push(`${' '.repeat(heading[1].length - 2)}- [${title}](#${anchor})`); + } + return entries.join('\n'); +} + +function buildCatalog(manifest, dates, firstPublished, icons) { + const rows = [ + '| Icon | Provider | Latest Version | Date First Published | Date Last Published |', + '| --- | --- | --- | --- | --- |', + ]; + for (const name of [...manifest.keys()].sort()) { + const icon = resolveIcon(name, icons); + const image = icon ? `${name}` : ''; + const latest = [...manifest.get(name)].sort(compareVersions).at(-1); + const last = dates.get(latest); + if (!last) throw new Error(`No production release commit for ${name} ${latest}; fetch production Git history`); + const first = firstPublished(name); + if (!/^\d{4}-\d{2}-\d{2}$/.test(first) || first > last) { + throw new Error(`Invalid first publication date for ${name}: ${first}`); + } + rows.push(`| ${image} | \`${name}\` | \`${latest}\` | ${first} | ${last} |`); + } + return rows.join('\n'); +} + +async function main(args = process.argv.slice(2)) { + let ref = 'origin/main'; + let check = false; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--check') check = true; + else if (args[i] === '--git-ref' && args[i + 1] && !args[i + 1].startsWith('-')) ref = args[++i]; + else throw new Error(`Unknown or incomplete argument: ${args[i]}`); + } + if (git(['rev-parse', '--is-shallow-repository']) !== 'false') { + throw new Error('Full Git history is required; run git fetch --unshallow'); + } + const commit = git(['rev-parse', '--verify', `${ref}^{commit}`]); + const dates = releaseDates(git(['log', '--first-parent', '--format=%cI%x09%s', commit])); + const icons = iconIndex(); + const sources = sourceDirectories(); + const response = await fetch(MANIFEST_URL, { signal: AbortSignal.timeout(30000) }); + if (!response.ok) throw new Error(`Registry request failed: HTTP ${response.status}`); + const manifest = parseManifest(await response.text()); + const catalog = buildCatalog(manifest, dates, (name) => { + const source = sources.get(name) || name; + const history = git([ + 'log', '--first-parent', '--reverse', '--format=%cI', commit, '--', + `providers/src/${source}`, `providers/dist/${source}`, `providers/dist/${name}`, + ]); + if (!history) throw new Error(`No production history for ${name}`); + return utcDate(history.split(/\r?\n/)[0]); + }, icons); + const readmePath = path.join(ROOT, 'README.md'); + const original = fs.readFileSync(readmePath, 'utf8'); + const newline = original.includes('\r\n') ? '\r\n' : '\n'; + let updated = replaceBlock(original.replace(/\r\n/g, '\n'), CATALOG_MARKER, catalog); + updated = replaceBlock(updated, TOC_MARKER, tableOfContents(updated)).replace(/\n/g, newline); + if (updated === original) { + console.log(`Provider catalog is current (${manifest.size} providers).`); + } else if (check) { + throw new Error('README.md provider catalog or table of contents is stale; run this script without --check'); + } else { + fs.writeFileSync(readmePath, updated); + console.log(`Updated README.md (${manifest.size} providers).`); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(`Provider catalog update failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { + parseManifest, compareVersions, utcDate, releaseDates, iconIndex, resolveIcon, providerId, + sourceDirectories, replaceBlock, tableOfContents, buildCatalog, +}; diff --git a/scripts/docs/update_provider_listings.test.js b/scripts/docs/update_provider_listings.test.js index 6d5176cb..e650dfcd 100644 --- a/scripts/docs/update_provider_listings.test.js +++ b/scripts/docs/update_provider_listings.test.js @@ -1,133 +1,195 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { test } = require('node:test'); -const { - parseManifest, compareVersions, utcDate, releaseDates, replaceBlock, tableOfContents, buildCatalog, -} = require('./update_provider_listings'); - -const ROOT = path.resolve(__dirname, '..', '..'); - -test('parses the registry schema with LF, CRLF and both list indentations', () => { - const yaml = 'providers:\n aws:\n versions:\n - v26.08.00444\n google:\n versions:\n - v26.08.00446\n'; - const expected = [['aws', ['v26.08.00444']], ['google', ['v26.08.00446']]]; - assert.deepEqual([...parseManifest(yaml)], expected); - assert.deepEqual([...parseManifest(`\uFEFF${yaml.replace(/\n/g, '\r\n')}`)], expected); -}); - -test('rejects incomplete, duplicate and unsupported manifest data', () => { - for (const yaml of [ - '', 'providers:', 'providers:\n aws:', - 'providers:\n aws:\n versions:', - 'providers:\n aws:\n versions:\n - invalid', - 'providers:\n aws:\n versions:\n - v26.08.00444\n aws:', - 'providers:\n aws:\n versions:\n - v26.08.00444\nunexpected: true', - ]) { - assert.throws(() => parseManifest(yaml)); - } -}); - -test('selects versions numerically rather than lexicographically or by list order', () => { - assert.deepEqual( - ['v9.12.99999', 'v26.9.00461', 'v26.10.00481', 'v26.09.00459'].sort(compareVersions), - ['v9.12.99999', 'v26.09.00459', 'v26.9.00461', 'v26.10.00481'], - ); -}); - -test('derives UTC release dates from merge and squash commits, ignoring source commits', () => { - const dates = releaseDates([ - '2026-10-21T16:15:29+11:00\tMerge pull request #481 from stackql/dev', - '2026-07-01T00:15:00+10:00\tProvider update (#405)', - '2026-10-19T17:22:55+11:00\tlambda provider', - ].join('\n')); - assert.deepEqual([...dates], [['v26.10.00481', '2026-10-21'], ['v26.06.00405', '2026-06-30']]); - assert.equal(utcDate('2022-08-20T00:15:24+10:00'), '2022-08-19'); - assert.throws(() => utcDate('not a date'), /Invalid Git date/); - assert.throws(() => releaseDates([ - '2026-10-21T05:15:29Z\tMerge pull request #481 from stackql/dev', - '2026-10-22T05:15:29Z\tAnother release (#481)', - ].join('\n')), /Ambiguous release history/); -}); - -test('replaces only generated blocks and rejects missing, reversed or duplicate markers', () => { - const readme = 'before\n\nold\n\nafter\n'; - assert.equal( - replaceBlock(readme, 'TEST', 'new'), - 'before\n\nnew\n\nafter\n', - ); - for (const invalid of [ - '', '', - `${readme}`, `${readme}`, - ]) { - assert.throws(() => replaceBlock(invalid, 'TEST', 'new'), /marker pair/); - } -}); - -test('generates nested TOC links, ignoring fenced code and handling duplicate headings', () => { - assert.equal(tableOfContents([ - '# Project', '## Table of Contents', '## Provider Catalog', '### Updating the Catalog', - '```bash', '## Not a heading', '```', '~~~', '## Also code', '~~~', - '## Testing using the `dev` Registry', '## Provider Catalog', - ].join('\n')), [ - '- [Provider Catalog](#provider-catalog)', - ' - [Updating the Catalog](#updating-the-catalog)', - '- [Testing using the `dev` Registry](#testing-using-the-dev-registry)', - '- [Provider Catalog](#provider-catalog-1)', - ].join('\n')); -}); - -test('builds six-column catalog sorted by source directory, with the Google alias and latest release', () => { - const manifest = new Map([ - ['google', ['v26.08.00446']], - ['aws', ['v26.08.00444', 'v26.07.00414']], - ['anthropic_admin', ['v26.07.00416']], - ]); - const dates = new Map([ - ['v26.08.00446', '2026-08-20'], - ['v26.08.00444', '2026-08-20'], - ['v26.07.00416', '2026-07-09'], - ]); - const names = []; - const catalog = buildCatalog(manifest, dates, (name, registryName) => { - names.push([name, registryName]); - return '2024-01-01'; - }); - assert.deepEqual(names, [ - ['anthropic_admin', 'anthropic_admin'], ['aws', 'aws'], ['googleapis.com', 'google'], - ]); - assert.match(catalog, /`googleapis\.com` \| Google Cloud Platform \| `v26\.08\.00446`/); - assert.match(catalog, /`aws` \| Amazon Web Services \| `v26\.08\.00444`/); - assert.doesNotMatch(catalog, /v26\.07\.00414/); - for (const row of catalog.split('\n')) assert.equal(row.split('|').length, 8); -}); - -test('fails explicitly on missing metadata, release history, icons or invalid publication dates', () => { - const manifest = new Map([['aws', ['v26.08.00444']]]); - const dates = new Map([['v26.08.00444', '2026-08-20']]); - assert.throws(() => buildCatalog(new Map([['unknown', ['v26.08.00444']]]), dates, () => '2022-01-01'), /metadata/); - assert.throws(() => buildCatalog(manifest, new Map(), () => '2022-01-01'), /release commit/); - assert.throws(() => buildCatalog(manifest, dates, () => '2022-01-01', __dirname), /ENOENT/); - assert.throws(() => buildCatalog(manifest, dates, () => ''), /first publication date/); - assert.throws(() => buildCatalog(manifest, dates, () => '2027-01-01'), /first publication date/); -}); - -test('README lists every current provider directory exactly once, sorted, with six columns and local icons', () => { - const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8'); - const catalog = readme.split('')[1].split('')[0]; - const rows = catalog.split(/\r?\n/).filter((line) => line.startsWith('| /\| `([^`]+)` \|/.exec(line)[1]); - const expected = fs.readdirSync(path.join(ROOT, 'providers', 'src'), { withFileTypes: true }) - .filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); - assert.deepEqual(actual, expected); - for (const row of rows) { - assert.equal(row.split('|').length, 8); - const icon = /src="([^"]+)"/.exec(row)[1]; - assert.ok(fs.statSync(path.join(ROOT, ...icon.split('/'))).isFile()); - assert.match(row, /\| `v\d+\.\d+\.\d+` \| \d{4}-\d{2}-\d{2} \| \d{4}-\d{2}-\d{2} \|$/); - } - const toc = readme.split('')[1].split('')[0].trim(); - assert.equal(toc, tableOfContents(readme)); - assert.equal(replaceBlock(readme, 'PROVIDER CATALOG', catalog.trim()), readme); -}); +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { + parseManifest, compareVersions, utcDate, releaseDates, iconIndex, resolveIcon, providerId, + sourceDirectories, replaceBlock, tableOfContents, buildCatalog, +} = require('./update_provider_listings'); + +const ROOT = path.resolve(__dirname, '..', '..'); + +function withTempDir(name, callback) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + try { + return callback(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('parses the registry schema with LF, CRLF and both list indentations', () => { + const yaml = 'providers:\n aws:\n versions:\n - v26.08.00444\n google:\n versions:\n - v26.08.00446\n'; + const expected = [['aws', ['v26.08.00444']], ['google', ['v26.08.00446']]]; + assert.deepEqual([...parseManifest(yaml)], expected); + assert.deepEqual([...parseManifest(`${yaml.replace(/\n/g, '\r\n')}`)], expected); +}); + +test('rejects incomplete, duplicate and unsupported manifest data', () => { + for (const yaml of [ + '', 'providers:', 'providers:\n aws:', + 'providers:\n aws:\n versions:', + 'providers:\n aws:\n versions:\n - invalid', + 'providers:\n aws:\n versions:\n - v26.08.00444\n aws:', + 'providers:\n aws:\n versions:\n - v26.08.00444\nunexpected: true', + ]) { + assert.throws(() => parseManifest(yaml)); + } +}); + +test('selects versions numerically rather than lexicographically or by list order', () => { + assert.deepEqual( + ['v9.12.99999', 'v26.9.00461', 'v26.10.00481', 'v26.09.00459'].sort(compareVersions), + ['v9.12.99999', 'v26.09.00459', 'v26.9.00461', 'v26.10.00481'], + ); +}); + +test('derives UTC release dates from merge and squash commits, ignoring source commits', () => { + const dates = releaseDates([ + '2026-10-21T16:15:29+11:00\tMerge pull request #481 from stackql/dev', + '2026-07-01T00:15:00+10:00\tProvider update (#405)', + '2026-10-19T17:22:55+11:00\tlambda provider', + ].join('\n')); + assert.deepEqual([...dates], [['v26.10.00481', '2026-10-21'], ['v26.06.00405', '2026-06-30']]); + assert.equal(utcDate('2022-08-20T00:15:24+10:00'), '2022-08-19'); + assert.throws(() => utcDate('not a date'), /Invalid Git date/); + assert.throws(() => releaseDates([ + '2026-10-21T05:15:29Z\tMerge pull request #481 from stackql/dev', + '2026-10-22T05:15:29Z\tAnother release (#481)', + ].join('\n')), /Ambiguous release history/); +}); + +test('resolves icons by exact stem, then longest prefix, and leaves unmatched providers blank', () => { + withTempDir('icons', (dir) => { + for (const file of ['aws.png', 'aws.ico', 'databricks.ico', 'google.ico', 'googleadmin.ico', 'entra_id.svg']) { + fs.writeFileSync(path.join(dir, file), ''); + } + fs.mkdirSync(path.join(dir, 'ignored.ico')); + const icons = iconIndex(dir); + assert.deepEqual([...icons], [ + ['aws', 'aws.ico'], ['databricks', 'databricks.ico'], ['entra_id', 'entra_id.svg'], + ['google', 'google.ico'], ['googleadmin', 'googleadmin.ico'], + ]); + assert.equal(resolveIcon('aws', icons), 'aws.ico'); + assert.equal(resolveIcon('awscc', icons), 'aws.ico'); + assert.equal(resolveIcon('databricks_account', icons), 'databricks.ico'); + assert.equal(resolveIcon('databricks_workspace', icons), 'databricks.ico'); + assert.equal(resolveIcon('googleadmin', icons), 'googleadmin.ico'); + assert.equal(resolveIcon('googleworkspace', icons), 'google.ico'); + assert.equal(resolveIcon('entra_id', icons), 'entra_id.svg'); + assert.equal(resolveIcon('k8s', icons), undefined); + assert.equal(resolveIcon('ignored', icons), undefined); + }); +}); + +test('maps registry ids to source directories from the top-level provider document id', () => { + assert.equal(providerId('config:\n auth:\n id: nested\nid: google\nname: google\n'), 'google'); + assert.equal(providerId('id: "quoted"\r\nname: quoted\r\n'), 'quoted'); + assert.equal(providerId(' id: nested\nname: only\n'), undefined); + withTempDir('sources', (dir) => { + const write = (provider, version, body) => { + fs.mkdirSync(path.join(dir, provider, version), { recursive: true }); + fs.writeFileSync(path.join(dir, provider, version, 'provider.yaml'), body); + }; + write('googleapis.com', 'v00.00.00000', 'config:\n auth:\n id: nested\nid: google\nname: google\n'); + write('aws', 'v00.00.00000', 'id: aws\nname: aws\n'); + fs.mkdirSync(path.join(dir, 'nodoc', 'v00.00.00000'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), ''); + assert.deepEqual([...sourceDirectories(dir)].sort(), [ + ['aws', 'aws'], ['google', 'googleapis.com'], ['nodoc', 'nodoc'], + ]); + write('google', 'v00.00.00000', 'id: google\n'); + assert.throws(() => sourceDirectories(dir), /multiple source directories/); + }); +}); + +test('replaces only generated blocks and rejects missing, reversed or duplicate markers', () => { + const readme = 'before\n\nold\n\nafter\n'; + assert.equal( + replaceBlock(readme, 'TEST', 'new'), + 'before\n\nnew\n\nafter\n', + ); + for (const invalid of [ + '', '', + `${readme}`, `${readme}`, + ]) { + assert.throws(() => replaceBlock(invalid, 'TEST', 'new'), /marker pair/); + } +}); + +test('generates nested TOC links, ignoring fenced code and handling duplicate headings', () => { + assert.equal(tableOfContents([ + '# Project', '## Table of Contents', '## Provider Catalog', '### Updating the Catalog', + '```bash', '## Not a heading', '```', '~~~', '## Also code', '~~~', + '## Testing using the `dev` Registry', '## Provider Catalog', + ].join('\n')), [ + '- [Provider Catalog](#provider-catalog)', + ' - [Updating the Catalog](#updating-the-catalog)', + '- [Testing using the `dev` Registry](#testing-using-the-dev-registry)', + '- [Provider Catalog](#provider-catalog-1)', + ].join('\n')); +}); + +test('builds five-column catalog sorted by registry name with resolved or blank icons and latest release', () => { + const manifest = new Map([ + ['google', ['v26.08.00446']], + ['aws', ['v26.08.00444', 'v26.07.00414']], + ['anthropic_admin', ['v26.07.00416']], + ['newprovider', ['v26.07.00416']], + ]); + const dates = new Map([ + ['v26.08.00446', '2026-08-20'], + ['v26.08.00444', '2026-08-20'], + ['v26.07.00416', '2026-07-09'], + ]); + const icons = new Map([['anthropic', 'anthropic.png'], ['aws', 'aws.ico'], ['google', 'google.ico']]); + const names = []; + const catalog = buildCatalog(manifest, dates, (name) => { + names.push(name); + return '2024-01-01'; + }, icons); + assert.deepEqual(names, ['anthropic_admin', 'aws', 'google', 'newprovider']); + const rows = catalog.split('\n'); + assert.equal(rows[0], '| Icon | Provider | Latest Version | Date First Published | Date Last Published |'); + assert.equal(rows[2], '| anthropic_admin | `anthropic_admin` | `v26.07.00416` | 2024-01-01 | 2026-07-09 |'); + assert.match(catalog, /images\/google\.ico" alt="google" [^|]*\| `google` \| `v26\.08\.00446`/); + assert.match(catalog, /images\/aws\.ico" alt="aws" [^|]*\| `aws` \| `v26\.08\.00444`/); + assert.match(catalog, /\n\| \| `newprovider` \| `v26\.07\.00416` \| 2024-01-01 \| 2026-07-09 \|/); + assert.doesNotMatch(catalog, /v26\.07\.00414/); + for (const row of rows) assert.equal(row.split('|').length, 7); +}); + +test('fails explicitly on missing release history or invalid publication dates', () => { + const manifest = new Map([['aws', ['v26.08.00444']]]); + const dates = new Map([['v26.08.00444', '2026-08-20']]); + assert.throws(() => buildCatalog(manifest, new Map(), () => '2022-01-01', new Map()), /release commit/); + assert.throws(() => buildCatalog(manifest, dates, () => '', new Map()), /first publication date/); + assert.throws(() => buildCatalog(manifest, dates, () => '2027-01-01', new Map()), /first publication date/); +}); + +test('README lists every current provider id exactly once, sorted, with five columns and repository icons', () => { + const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8'); + const catalog = readme.split('')[1].split('')[0]; + const rows = catalog.split(/\r?\n/).filter((line) => line.startsWith('| ')).slice(2); + const actual = rows.map((line) => /\| `([^`]+)` \|/.exec(line)[1]); + assert.deepEqual(actual, [...sourceDirectories().keys()].sort()); + const icons = iconIndex(); + for (const [index, row] of rows.entries()) { + assert.equal(row.split('|').length, 7); + const expected = resolveIcon(actual[index], icons); + const cell = row.split('|')[1].trim(); + if (expected) { + assert.equal(cell, `${actual[index]}`); + assert.ok(fs.statSync(path.join(ROOT, 'images', expected)).isFile()); + } else { + assert.equal(cell, ''); + } + assert.match(row, /\| `v\d+\.\d+\.\d+` \| \d{4}-\d{2}-\d{2} \| \d{4}-\d{2}-\d{2} \|$/); + } + const toc = readme.split('')[1].split('')[0].trim(); + assert.equal(toc, tableOfContents(readme)); + assert.equal(replaceBlock(readme, 'PROVIDER CATALOG', catalog.trim()), readme); +});