diff --git a/.changeset/hotload-boundary-manifest.md b/.changeset/hotload-boundary-manifest.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/hotload-boundary-manifest.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/package.json b/package.json index 7ef6382fb9c..0af07572927 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "bundlewatch": "turbo run bundlewatch", "changeset": "changeset", "changeset:empty": "pnpm changeset --empty", + "check:hotload-boundary": "node ./scripts/hotload-boundary/check-hotload-boundary.mjs", "clean": "turbo run clean", "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension", "dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared", diff --git a/scripts/hotload-boundary/abi-manifest.json b/scripts/hotload-boundary/abi-manifest.json new file mode 100644 index 00000000000..8d4e9e1ea20 --- /dev/null +++ b/scripts/hotload-boundary/abi-manifest.json @@ -0,0 +1,427 @@ +{ + "version": 1, + "description": "Runtime contract manifest for Clerk / IsomorphicClerk __internal_* members that can cross the Clerk.js hotload boundary.", + "surfaces": { + "Clerk": { + "package": "@clerk/clerk-js", + "file": "packages/clerk-js/src/core/clerk.ts", + "className": "Clerk" + }, + "IsomorphicClerk": { + "package": "@clerk/react", + "file": "packages/react/src/isomorphicClerk.ts", + "className": "IsomorphicClerk" + } + }, + "entries": [ + { + "member": "__internal_addNavigationListener", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/ui/src/router/VirtualRouter.tsx"], + "notes": "Consumed by UI routing code to observe external navigation; removal would break UI packages paired with hotloaded Clerk.js." + }, + { + "member": "__internal_attemptToEnableEnvironmentSetting", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts", + "packages/vue/src/composables/useOrganization.ts" + ], + "notes": "Consumed by shared and framework hooks to enable environment-backed features on demand." + }, + { + "member": "__internal_closeBlankCaptchaModal", + "surfaces": ["Clerk"], + "boundary": "local-only", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts"], + "notes": "Used inside clerk-js captcha handling; no current cross-package consumer." + }, + { + "member": "__internal_closeCheckout", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"], + "notes": "Part of the checkout UI control surface mirrored through IsomorphicClerk." + }, + { + "member": "__internal_closeEnableOrganizationsPrompt", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx"], + "notes": "Consumed by UI prompts that can be bundled separately from the hotloaded Clerk runtime." + }, + { + "member": "__internal_closePlanDetails", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"], + "notes": "Part of the plan details UI control surface mirrored through IsomorphicClerk." + }, + { + "member": "__internal_closeReverification", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/shared/src/types/clerk.ts"], + "notes": "Part of the reverification UI control surface exposed through shared Clerk types." + }, + { + "member": "__internal_closeSubscriptionDetails", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"], + "notes": "Part of the subscription details UI control surface mirrored through IsomorphicClerk." + }, + { + "member": "__internal_country", + "surfaces": ["Clerk"], + "boundary": "local-only", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/clerk-js/src/core/clerk.ts"], + "notes": "Set and read by clerk-js resources for country-specific behavior." + }, + { + "member": "__internal_createPublicCredentials", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/expo/src/provider/singleton/createClerkInstance.ts"], + "notes": "Assigned by Expo to provide native passkey credential creation to Clerk resources." + }, + { + "member": "__internal_environment", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "high", + "consumerFiles": [ + "packages/shared/src/react/hooks/useBillingIsEnabled.ts", + "packages/shared/src/react/billing/payment-element.tsx", + "packages/expo/src/provider/singleton/createClerkInstance.ts" + ], + "notes": "Broadly consumed for environment settings and feature flags; shape changes can silently alter feature availability." + }, + { + "member": "__internal_getCachedResources", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/shared/src/types/clerk.ts" + ], + "notes": "Installed SDKs can provide cached resources that the hotloaded runtime consumes during bootstrap." + }, + { + "member": "__internal_getOption", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "high", + "consumerFiles": [ + "packages/ui/src/contexts/components/SessionTasks.ts", + "packages/shared/src/react/billing/payment-element.tsx", + "packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts" + ], + "notes": "Shared, UI, and runtime code read Clerk options through this method; signature changes affect multiple package generations." + }, + { + "member": "__internal_getPublicCredentials", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/clerk-js/src/core/resources/SignIn.ts" + ], + "notes": "Assigned by Expo and consumed by Clerk resources for native passkey credential retrieval." + }, + { + "member": "__internal_handleUnauthenticatedDevBrowser", + "surfaces": ["Clerk"], + "boundary": "local-only", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/clerk-js/src/core/resources/Base.ts"], + "notes": "Used by clerk-js resources for development browser handling." + }, + { + "member": "__internal_isWebAuthnAutofillSupported", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/clerk-js/src/core/resources/SignIn.ts" + ], + "notes": "Assigned by Expo and consumed by Clerk resources for native WebAuthn autofill support." + }, + { + "member": "__internal_isWebAuthnPlatformAuthenticatorSupported", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/clerk-js/src/core/resources/Passkey.ts" + ], + "notes": "Assigned by Expo and consumed by Clerk resources for native platform authenticator support." + }, + { + "member": "__internal_isWebAuthnSupported", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/clerk-js/src/core/resources/SignIn.ts" + ], + "notes": "Assigned by Expo and consumed by Clerk resources for native WebAuthn support." + }, + { + "member": "__internal_lastEmittedResources", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "high", + "consumerFiles": [ + "packages/react/src/hooks/useAuthBase.tsx", + "packages/shared/src/react/hooks/base/useClientBase.ts", + "packages/ui/src/Components.tsx" + ], + "notes": "Used by hooks to read the latest emitted resource snapshot; stale or missing values can produce silent empty state." + }, + { + "member": "__internal_last_error", + "surfaces": ["Clerk"], + "boundary": "local-only", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/clerk-js/src/core/clerk.ts"], + "notes": "Internal backing storage for navigation-with-error handling." + }, + { + "member": "__internal_loadStripeJs", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/shared/src/react/billing/useStripeClerkLibs.tsx"], + "notes": "Shared billing code loads Stripe through Clerk; keep the function shape coordinated with billing packages." + }, + { + "member": "__internal_mountOAuthConsent", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/react/src/components/uiComponents.tsx"], + "notes": "React UI components mount OAuth consent through this bridge." + }, + { + "member": "__internal_navigateWithError", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/ui/src/components/SignIn/shared.ts"], + "notes": "UI sign-in flows use this method to route while preserving Clerk API errors." + }, + { + "member": "__internal_onAfterResponse", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/chrome-extension/src/internal/clerk.ts" + ], + "notes": "Installed SDKs attach response interceptors to the Clerk runtime." + }, + { + "member": "__internal_onBeforeRequest", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/expo/src/provider/singleton/createClerkInstance.ts", + "packages/chrome-extension/src/internal/clerk.ts" + ], + "notes": "Installed SDKs attach request interceptors to the Clerk runtime." + }, + { + "member": "__internal_openBlankCaptchaModal", + "surfaces": ["Clerk"], + "boundary": "local-only", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts"], + "notes": "Used inside clerk-js captcha handling; no current cross-package consumer." + }, + { + "member": "__internal_openCheckout", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/react/src/components/CheckoutButton.tsx", + "packages/ui/src/contexts/components/Plans.tsx", + "packages/vue/src/components/CheckoutButton.vue" + ], + "notes": "Framework and UI packages open checkout through this runtime method." + }, + { + "member": "__internal_openEnableOrganizationsPrompt", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"], + "notes": "Prompt control surface used when installed packages request organization enablement." + }, + { + "member": "__internal_openPlanDetails", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/react/src/components/PlanDetailsButton.tsx", + "packages/ui/src/components/PricingTable/PricingTableDefault.tsx", + "packages/vue/src/components/PlanDetailsButton.vue" + ], + "notes": "Framework and UI packages open plan details through this runtime method." + }, + { + "member": "__internal_openReverification", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/shared/src/react/hooks/useReverification.ts"], + "notes": "Shared reverification hooks open the runtime reverification UI through this method." + }, + { + "member": "__internal_openSubscriptionDetails", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/react/src/components/SubscriptionDetailsButton.tsx", + "packages/ui/src/contexts/components/Plans.tsx", + "packages/vue/src/components/SubscriptionDetailsButton.vue" + ], + "notes": "Framework and UI packages open subscription details through this runtime method." + }, + { + "member": "__internal_queryClient", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "shim", + "risk": "high", + "notes": "Backward-compatibility shim for older installed @clerk/shared versions that read this getter and wait for queryClientStatus." + }, + { + "member": "__internal_reloadInitialResources", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/expo/src/provider/ClerkProvider.tsx", "packages/expo/src/native/AuthView.tsx"], + "notes": "Expo uses this method to recover initial resources after native auth flows." + }, + { + "member": "__internal_setActiveInProgress", + "surfaces": ["Clerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": [ + "packages/ui/src/common/SSOCallback.tsx", + "packages/ui/src/components/SignIn/SignInFactorOne.tsx" + ], + "notes": "UI flows use this flag to avoid racing active-session transitions." + }, + { + "member": "__internal_setCountry", + "surfaces": ["Clerk"], + "boundary": "local-only", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/clerk-js/src/core/resources/Base.ts"], + "notes": "Used by clerk-js resources to persist response-derived country state." + }, + { + "member": "__internal_setEnvironment", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "released-together", + "compatibility": "keep", + "risk": "low", + "consumerFiles": ["packages/react/src/isomorphicClerk.ts"], + "notes": "Compatibility bridge for older Clerk implementations; currently used by IsomorphicClerk when available." + }, + { + "member": "__internal_state", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "high", + "consumerFiles": [ + "packages/react/src/hooks/useClerkSignal.ts", + "packages/shared/src/react/hooks/useCheckout.ts", + "packages/react/src/stateProxy.ts" + ], + "notes": "State signal object consumed by installed React/shared code; shape and signal semantics are runtime contract." + }, + { + "member": "__internal_unmountOAuthConsent", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "medium", + "consumerFiles": ["packages/react/src/components/uiComponents.tsx"], + "notes": "React UI components unmount OAuth consent through this bridge." + }, + { + "member": "__internal_updateProps", + "surfaces": ["Clerk", "IsomorphicClerk"], + "boundary": "hotload-boundary", + "compatibility": "keep", + "risk": "high", + "consumerFiles": [ + "packages/react/src/components/uiComponents.tsx", + "packages/vue/src/utils/updateClerkOptions.ts", + "packages/astro/src/internal/create-clerk-instance.ts" + ], + "notes": "Framework adapters and component wrappers update runtime props through this method." + } + ] +} diff --git a/scripts/hotload-boundary/check-hotload-boundary.mjs b/scripts/hotload-boundary/check-hotload-boundary.mjs new file mode 100644 index 00000000000..60f572f62e1 --- /dev/null +++ b/scripts/hotload-boundary/check-hotload-boundary.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '../..'); +const manifestPath = path.join(scriptDir, 'abi-manifest.json'); + +const allowedBoundaries = new Set(['local-only', 'released-together', 'hotload-boundary']); +const allowedCompatibility = new Set(['keep', 'shim', 'deprecate', 'major-only removal']); +const allowedRisks = new Set(['low', 'medium', 'high']); + +const args = new Set(process.argv.slice(2)); + +function toPosixPath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function toAbsolutePath(filePath) { + return path.join(repoRoot, filePath); +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf8')); +} + +function getMemberName(nameNode) { + if (!nameNode) { + return undefined; + } + + if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { + return nameNode.text; + } + + return undefined; +} + +function getMemberKind(member) { + if (ts.isGetAccessor(member)) { + return 'getter'; + } + if (ts.isSetAccessor(member)) { + return 'setter'; + } + if (ts.isMethodDeclaration(member)) { + return 'method'; + } + if (ts.isPropertyDeclaration(member)) { + return 'property'; + } + return 'member'; +} + +async function extractInternalMembers(surface) { + const sourceText = await readFile(toAbsolutePath(surface.file), 'utf8'); + const sourceFile = ts.createSourceFile(surface.file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const membersByName = new Map(); + + function visit(node) { + if (ts.isClassDeclaration(node) && node.name?.text === surface.className) { + for (const member of node.members) { + const memberName = getMemberName(member.name); + + if (!memberName?.startsWith('__internal_')) { + continue; + } + + const line = sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile)).line + 1; + const existing = membersByName.get(memberName); + + if (existing) { + existing.kinds.add(getMemberKind(member)); + existing.line = Math.min(existing.line, line); + } else { + membersByName.set(memberName, { + member: memberName, + surface: surface.name, + file: surface.file, + line, + kinds: new Set([getMemberKind(member)]), + }); + } + } + return; + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + return [...membersByName.values()] + .map(member => ({ + ...member, + kinds: [...member.kinds].sort(), + })) + .sort((left, right) => left.member.localeCompare(right.member)); +} + +function getSurfaceDefinitions(manifest) { + return Object.entries(manifest.surfaces || {}).map(([name, surface]) => ({ + name, + ...surface, + })); +} + +function formatMemberKey(surface, member) { + return `${surface}.${member}`; +} + +async function validateConsumerFiles(entry, failures) { + for (const consumerFile of entry.consumerFiles || []) { + const absolutePath = toAbsolutePath(consumerFile); + let content; + + try { + content = await readFile(absolutePath, 'utf8'); + } catch { + failures.push(`${entry.member}: consumer file does not exist: ${consumerFile}`); + continue; + } + + if (!content.includes(entry.member)) { + failures.push(`${entry.member}: consumer file does not reference member: ${consumerFile}`); + } + } +} + +function renderMarkdownTable(entries) { + const rows = [ + '| Member | Surfaces | Boundary | Compatibility | Risk | Consumer files |', + '| --- | --- | --- | --- | --- | --- |', + ]; + + for (const entry of entries) { + rows.push( + [ + `\`${entry.member}\``, + entry.surfaces.map(surface => `\`${surface}\``).join(', '), + entry.boundary, + entry.compatibility, + entry.risk, + (entry.consumerFiles || []).map(file => `\`${file}\``).join('
') || 'None listed', + ] + .join(' | ') + .replace(/^/, '| ') + ' |', + ); + } + + return rows.join('\n'); +} + +async function main() { + const manifest = await readJson(manifestPath); + const failures = []; + const surfaceDefinitions = getSurfaceDefinitions(manifest); + const knownSurfaces = new Set(surfaceDefinitions.map(surface => surface.name)); + const discoveredMembers = (await Promise.all(surfaceDefinitions.map(extractInternalMembers))).flat(); + const discoveredByKey = new Map( + discoveredMembers.map(member => [formatMemberKey(member.surface, member.member), member]), + ); + const manifestByKey = new Map(); + + if (!Array.isArray(manifest.entries)) { + failures.push('Manifest must contain an entries array.'); + } + + for (const surface of surfaceDefinitions) { + if (!surface.file || !surface.className) { + failures.push(`${surface.name}: surface must define file and className.`); + } + } + + for (const entry of manifest.entries || []) { + if (!entry.member?.startsWith('__internal_')) { + failures.push(`Invalid member name: ${entry.member || ''}`); + continue; + } + + if (!Array.isArray(entry.surfaces) || entry.surfaces.length === 0) { + failures.push(`${entry.member}: must list at least one surface.`); + } + + if (!allowedBoundaries.has(entry.boundary)) { + failures.push(`${entry.member}: invalid boundary "${entry.boundary}".`); + } + + if (!allowedCompatibility.has(entry.compatibility)) { + failures.push(`${entry.member}: invalid compatibility "${entry.compatibility}".`); + } + + if (!allowedRisks.has(entry.risk)) { + failures.push(`${entry.member}: invalid risk "${entry.risk}".`); + } + + if (!entry.notes) { + failures.push(`${entry.member}: notes are required.`); + } + + if (entry.consumerFiles && !Array.isArray(entry.consumerFiles)) { + failures.push(`${entry.member}: consumerFiles must be an array when present.`); + } + + for (const surface of entry.surfaces || []) { + if (!knownSurfaces.has(surface)) { + failures.push(`${entry.member}: unknown surface "${surface}".`); + continue; + } + + const key = formatMemberKey(surface, entry.member); + if (manifestByKey.has(key)) { + failures.push(`${key}: duplicate manifest entry.`); + } + manifestByKey.set(key, entry); + + if (!discoveredByKey.has(key)) { + failures.push(`${key}: listed in manifest but not found in source.`); + } + } + + await validateConsumerFiles(entry, failures); + } + + for (const [key, member] of discoveredByKey) { + if (!manifestByKey.has(key)) { + failures.push(`${key}: found in ${member.file}:${member.line} but missing from manifest.`); + } + } + + if (failures.length > 0) { + console.error('Hotload boundary manifest check failed:\n'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + const coveredPairs = manifestByKey.size; + const sourceFiles = surfaceDefinitions.map(surface => toPosixPath(surface.file)).join(', '); + console.log(`Hotload boundary manifest covers ${coveredPairs} internal member surface(s) from ${sourceFiles}.`); + + if (args.has('--markdown')) { + console.log(''); + console.log(renderMarkdownTable(manifest.entries)); + } +} + +await main();