diff --git a/.gitignore b/.gitignore index 4c3d36e..f861fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ CLAUDE.md # Generated operator configs (environment-specific, must not be committed) nginx-auth.conf.generated +domains.config.json +vercel.redirects.json # Vercel .vercel/ diff --git a/docs/devdocify/how-to/deploy.md b/docs/devdocify/how-to/deploy.md index 8e9aa32..88008ac 100644 --- a/docs/devdocify/how-to/deploy.md +++ b/docs/devdocify/how-to/deploy.md @@ -105,10 +105,110 @@ For link-specific validation, run: npm run lint-content ``` -## Custom domain +## Custom domains -To attach a custom domain: +DevDocify supports single-domain, multi-domain, and legacy domain redirect configurations. Domain setup is managed through `domains.config.json` and automated via the Vercel API. + +### Single domain + +To attach one custom domain: 1. Go to **Settings > Domains** in your Vercel project. 2. Enter your domain and follow the DNS configuration instructions. 3. Vercel provisions a TLS certificate automatically. + +Or use the CLI: + +```bash +VERCEL_TOKEN= VERCEL_PROJECT_ID= npm run manage-domains add docs.example.com +``` + +### Multi-domain setup + +For multiple domains (e.g. apex redirect, per-docset subdomains), use the domain configuration file. + +1. Copy the example config: + +```bash +cp domains.config.example.json domains.config.json +``` + +2. Edit `domains.config.json` with your domains: + +```json +{ + "primaryDomain": "docs.example.com", + "aliases": [ + { "domain": "example.com", "redirectToPrimary": true } + ], + "docsetDomains": [ + { "domain": "api.example.com", "docsetId": "petstore", "basePath": "/" } + ] +} +``` + +3. Validate the configuration: + +```bash +npm run validate-domains -- --config domains.config.json +``` + +4. Sync all domains to Vercel: + +```bash +VERCEL_TOKEN= VERCEL_PROJECT_ID= npm run manage-domains sync --config domains.config.json +``` + +5. Check verification status: + +```bash +VERCEL_TOKEN= VERCEL_PROJECT_ID= npm run manage-domains list +``` + +### Legacy domain redirects + +When migrating from an old domain, configure redirects so existing bookmarks and external links continue working. + +Add entries to the `legacyRedirects` array in `domains.config.json`: + +```json +{ + "legacyRedirects": [ + { + "fromDomain": "old-docs.example.com", + "toDomain": "docs.example.com", + "statusCode": 308, + "preservePath": true + } + ] +} +``` + +Generate the Vercel redirect rules: + +```bash +npm run generate-domain-redirects -- --config domains.config.json --output vercel.redirects.json +``` + +Merge the generated `redirects` array into your `vercel.json`. + +### DNS configuration + +Configure DNS records at your DNS provider. Common patterns: + +| Domain type | Record | Name | Value | +|---|---|---|---| +| Subdomain (www, docs, api) | CNAME | `www` | `cname.vercel-dns.com` | +| Apex domain | A | `@` | `76.76.21.21` | + +After adding DNS records, verify the domain: + +```bash +VERCEL_TOKEN= VERCEL_PROJECT_ID= npm run manage-domains verify docs.example.com +``` + +### Troubleshooting + +- **Domain not verifying.** DNS propagation can take up to 48 hours. Run `npm run manage-domains verify ` to check status and see required DNS records. +- **SSL certificate pending.** Vercel provisions TLS certificates automatically after DNS verification. Allow a few minutes after verification completes. +- **Redirect loops.** Run `npm run validate-domains` to detect redirect chains or circular references in your domain configuration. diff --git a/domains.config.example.json b/domains.config.example.json new file mode 100644 index 0000000..693e6d4 --- /dev/null +++ b/domains.config.example.json @@ -0,0 +1,34 @@ +{ + "_comment": "Copy this file to domains.config.json and configure your domains. Never commit domains.config.json.", + "primaryDomain": "www.devdocify.com", + "aliases": [ + { + "domain": "devdocify.com", + "redirectToPrimary": true + } + ], + "docsetDomains": [ + { + "_comment": "Optional: serve a docset on its own subdomain. Readers visiting docs.example.com see the docset at /.", + "domain": "docs.example.com", + "docsetId": "devdocify", + "basePath": "/" + } + ], + "legacyRedirects": [ + { + "_comment": "Redirect traffic from a retired domain to the current site, preserving the URL path.", + "fromDomain": "old-docs.example.com", + "toDomain": "www.devdocify.com", + "statusCode": 308, + "preservePath": true + } + ], + "dns": { + "_comment": "Reference only. Actual DNS records must be configured at your DNS provider.", + "expectedRecords": [ + {"type": "CNAME", "name": "www", "value": "cname.vercel-dns.com"}, + {"type": "A", "name": "@", "value": "76.76.21.21"} + ] + } +} diff --git a/package.json b/package.json index 59ddba3..10db0a7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "validate-assistant-quality": "npx tsx scripts/validate-assistant-quality.ts", "generate-nginx-auth-config": "npx tsx scripts/generate-nginx-auth-config.ts", "check-rbac-permission": "npx tsx scripts/check-rbac-permission.ts", + "validate-domains": "npx tsx scripts/validate-domains-config.ts", + "manage-domains": "npx tsx scripts/manage-vercel-domains.ts", + "generate-domain-redirects": "npx tsx scripts/generate-domain-redirects.ts", "test": "npx tsx --test scripts/__tests__/*.test.ts" }, "dependencies": { diff --git a/scripts/generate-domain-redirects.ts b/scripts/generate-domain-redirects.ts new file mode 100644 index 0000000..192af8f --- /dev/null +++ b/scripts/generate-domain-redirects.ts @@ -0,0 +1,124 @@ +/** + * Legacy domain redirect generator: Epic 13, Story 13.3 + * + * Reads domains.config.json and generates a Vercel redirects configuration + * fragment from legacyRedirects and alias entries. Output can be merged into + * vercel.json or used as input for other hosting providers. + * + * Usage: + * npx tsx scripts/generate-domain-redirects.ts [--config ] [--output ] + * npx tsx scripts/generate-domain-redirects.ts --config domains.config.json --output vercel.redirects.json + * + * If --output is omitted, the result is printed to stdout. + */ + +import fs from 'fs'; +import path from 'path'; +import type { DomainsConfig } from './validate-domains-config'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type VercelRedirect = { + source: string; + destination: string; + statusCode: number; + has?: Array<{ type: string; key?: string; value: string }>; +}; + +// --------------------------------------------------------------------------- +// Generator +// --------------------------------------------------------------------------- + +export function generateRedirects(config: DomainsConfig): VercelRedirect[] { + const redirects: VercelRedirect[] = []; + + // Alias redirects (e.g. apex -> www) + if (config.aliases) { + for (const alias of config.aliases) { + if (!alias.redirectToPrimary) continue; + redirects.push({ + source: '/:path(.*)', + destination: `https://${config.primaryDomain}/:path`, + statusCode: 308, + has: [{ type: 'host', value: alias.domain }], + }); + } + } + + // Legacy domain redirects + if (config.legacyRedirects) { + for (const lr of config.legacyRedirects) { + const statusCode = lr.statusCode ?? 308; + if (lr.preservePath !== false) { + redirects.push({ + source: '/:path(.*)', + destination: `https://${lr.toDomain}/:path`, + statusCode, + has: [{ type: 'host', value: lr.fromDomain }], + }); + } else { + redirects.push({ + source: '/:path(.*)', + destination: `https://${lr.toDomain}/`, + statusCode, + has: [{ type: 'host', value: lr.fromDomain }], + }); + } + } + } + + return redirects; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(): { configPath: string; outputPath: string | null } { + const args = process.argv.slice(2); + const configIdx = args.indexOf('--config'); + const configPath = configIdx !== -1 && args[configIdx + 1] + ? path.resolve(process.cwd(), args[configIdx + 1]) + : path.resolve(process.cwd(), 'domains.config.json'); + + const outputIdx = args.indexOf('--output'); + const outputPath = outputIdx !== -1 && args[outputIdx + 1] + ? path.resolve(process.cwd(), args[outputIdx + 1]) + : null; + + return { configPath, outputPath }; +} + +const { configPath, outputPath } = parseArgs(); + +if (!fs.existsSync(configPath)) { + console.error(`[domain-redirects] ERROR: config file not found: ${configPath}`); + console.error(' Copy domains.config.example.json to domains.config.json and configure your domains.'); + process.exit(1); +} + +let config: DomainsConfig; +try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +} catch (err) { + console.error(`[domain-redirects] ERROR: failed to parse JSON: ${(err as Error).message}`); + process.exit(1); +} + +const redirects = generateRedirects(config); + +if (redirects.length === 0) { + console.log('[domain-redirects] No redirects to generate.'); + process.exit(0); +} + +const output = JSON.stringify({ redirects }, null, 2); + +if (outputPath) { + fs.writeFileSync(outputPath, output + '\n', 'utf8'); + console.log(`[domain-redirects] ${redirects.length} redirect(s) written to ${outputPath}`); +} else { + console.log(output); +} diff --git a/scripts/manage-vercel-domains.ts b/scripts/manage-vercel-domains.ts new file mode 100644 index 0000000..972f30e --- /dev/null +++ b/scripts/manage-vercel-domains.ts @@ -0,0 +1,240 @@ +/** + * Vercel domain provisioning script: Epic 13, Story 13.2 + * + * Manages custom domains on a Vercel project via the Vercel REST API. + * Supports adding, listing, removing, and checking verification status. + * + * Required environment variables: + * VERCEL_TOKEN - Vercel API token (Settings > Tokens) + * VERCEL_PROJECT_ID - Vercel project ID (from .vercel/project.json or project settings) + * VERCEL_TEAM_ID - (optional) Vercel team/org ID for team-scoped projects + * + * Usage: + * npx tsx scripts/manage-vercel-domains.ts list + * npx tsx scripts/manage-vercel-domains.ts add docs.example.com + * npx tsx scripts/manage-vercel-domains.ts add docs.example.com --dry-run + * npx tsx scripts/manage-vercel-domains.ts remove docs.example.com + * npx tsx scripts/manage-vercel-domains.ts verify docs.example.com + * npx tsx scripts/manage-vercel-domains.ts sync --config domains.config.json + */ + +import fs from 'fs'; +import path from 'path'; +import type { DomainsConfig } from './validate-domains-config'; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +function requireEnv(name: string): string { + const val = process.env[name]; + if (!val) { + console.error(`[vercel-domains] ERROR: environment variable ${name} is required`); + process.exit(1); + } + return val; +} + +const VERCEL_TOKEN = requireEnv('VERCEL_TOKEN'); +const VERCEL_PROJECT_ID = requireEnv('VERCEL_PROJECT_ID'); +const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID; + +const BASE_URL = 'https://api.vercel.com'; + +function teamQuery(): string { + return VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ''; +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +type VercelDomain = { + name: string; + verified: boolean; + verification?: Array<{ type: string; domain: string; value: string }>; + redirect?: string | null; + redirectStatusCode?: number | null; +}; + +async function apiRequest( + method: string, + urlPath: string, + body?: Record, +): Promise<{ ok: boolean; status: number; data: Record }> { + const url = `${BASE_URL}${urlPath}${urlPath.includes('?') ? '&' : '?'}${VERCEL_TEAM_ID ? `teamId=${VERCEL_TEAM_ID}` : ''}`; + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${VERCEL_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json() as Record; + return { ok: res.ok, status: res.status, data }; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +async function listDomains(): Promise { + const res = await apiRequest('GET', `/v9/projects/${VERCEL_PROJECT_ID}/domains`); + if (!res.ok) { + console.error(`[vercel-domains] ERROR: failed to list domains (${res.status}):`, JSON.stringify(res.data, null, 2)); + process.exit(1); + } + const domains = (res.data.domains ?? []) as VercelDomain[]; + if (domains.length === 0) { + console.log('[vercel-domains] No domains configured.'); + return; + } + console.log(`[vercel-domains] ${domains.length} domain(s):\n`); + for (const d of domains) { + const status = d.verified ? 'verified' : 'UNVERIFIED'; + const redirect = d.redirect ? ` -> ${d.redirect} (${d.redirectStatusCode ?? 308})` : ''; + console.log(` ${d.name} [${status}]${redirect}`); + if (!d.verified && d.verification) { + for (const v of d.verification) { + console.log(` DNS: ${v.type} record for ${v.domain} = ${v.value}`); + } + } + } +} + +async function addDomain(domain: string, dryRun: boolean): Promise { + if (dryRun) { + console.log(`[vercel-domains] DRY RUN: would add domain "${domain}" to project ${VERCEL_PROJECT_ID}`); + return; + } + const res = await apiRequest('POST', `/v10/projects/${VERCEL_PROJECT_ID}/domains`, { name: domain }); + if (!res.ok) { + if (res.status === 409) { + console.log(`[vercel-domains] Domain "${domain}" is already added to this project.`); + return; + } + console.error(`[vercel-domains] ERROR: failed to add domain (${res.status}):`, JSON.stringify(res.data, null, 2)); + process.exit(1); + } + console.log(`[vercel-domains] Domain "${domain}" added successfully.`); + const d = res.data as unknown as VercelDomain; + if (!d.verified && d.verification) { + console.log('[vercel-domains] Domain is not yet verified. Add these DNS records:'); + for (const v of d.verification) { + console.log(` ${v.type} record: ${v.domain} = ${v.value}`); + } + } +} + +async function removeDomain(domain: string, dryRun: boolean): Promise { + if (dryRun) { + console.log(`[vercel-domains] DRY RUN: would remove domain "${domain}" from project ${VERCEL_PROJECT_ID}`); + return; + } + const res = await apiRequest('DELETE', `/v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}`); + if (!res.ok) { + console.error(`[vercel-domains] ERROR: failed to remove domain (${res.status}):`, JSON.stringify(res.data, null, 2)); + process.exit(1); + } + console.log(`[vercel-domains] Domain "${domain}" removed.`); +} + +async function verifyDomain(domain: string): Promise { + const res = await apiRequest('POST', `/v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}/verify`); + if (!res.ok) { + console.error(`[vercel-domains] ERROR: verification failed (${res.status}):`, JSON.stringify(res.data, null, 2)); + process.exit(1); + } + const d = res.data as unknown as VercelDomain; + if (d.verified) { + console.log(`[vercel-domains] Domain "${domain}" is verified.`); + } else { + console.log(`[vercel-domains] Domain "${domain}" is NOT yet verified.`); + if (d.verification) { + console.log('[vercel-domains] Required DNS records:'); + for (const v of d.verification) { + console.log(` ${v.type} record: ${v.domain} = ${v.value}`); + } + } + } +} + +async function syncFromConfig(configPath: string, dryRun: boolean): Promise { + if (!fs.existsSync(configPath)) { + console.error(`[vercel-domains] ERROR: config not found: ${configPath}`); + process.exit(1); + } + const config: DomainsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const desired = new Set(); + + desired.add(config.primaryDomain); + for (const alias of config.aliases ?? []) { + desired.add(alias.domain); + } + for (const dd of config.docsetDomains ?? []) { + desired.add(dd.domain); + } + + console.log(`[vercel-domains] Syncing ${desired.size} domain(s) from config...`); + for (const domain of desired) { + await addDomain(domain, dryRun); + } + console.log(`[vercel-domains] Sync complete.`); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0]; + const dryRun = args.includes('--dry-run'); + + switch (command) { + case 'list': + await listDomains(); + break; + case 'add': { + const domain = args[1]; + if (!domain || domain.startsWith('--')) { + console.error('[vercel-domains] Usage: manage-vercel-domains.ts add [--dry-run]'); + process.exit(1); + } + await addDomain(domain, dryRun); + break; + } + case 'remove': { + const domain = args[1]; + if (!domain || domain.startsWith('--')) { + console.error('[vercel-domains] Usage: manage-vercel-domains.ts remove '); + process.exit(1); + } + await removeDomain(domain, dryRun); + break; + } + case 'verify': { + const domain = args[1]; + if (!domain || domain.startsWith('--')) { + console.error('[vercel-domains] Usage: manage-vercel-domains.ts verify '); + process.exit(1); + } + await verifyDomain(domain); + break; + } + case 'sync': { + const configIdx = args.indexOf('--config'); + const configPath = configIdx !== -1 && args[configIdx + 1] + ? path.resolve(process.cwd(), args[configIdx + 1]) + : path.resolve(process.cwd(), 'domains.config.json'); + await syncFromConfig(configPath, dryRun); + break; + } + default: + console.error(`[vercel-domains] Usage: manage-vercel-domains.ts [args]`); + process.exit(1); + } +} + +main(); diff --git a/scripts/validate-domains-config.ts b/scripts/validate-domains-config.ts new file mode 100644 index 0000000..7921433 --- /dev/null +++ b/scripts/validate-domains-config.ts @@ -0,0 +1,225 @@ +/** + * Custom domain configuration schema and validator: Epic 13, Story 13.1 + * + * Validates the domain configuration file for correctness before deployment. + * Checks for duplicate domains, redirect loops, required fields, and DNS + * record plausibility. + * + * Config file: domains.config.json at the project root (gitignored in + * production; use domains.config.example.json as the committed template). + * + * Usage: + * npx tsx scripts/validate-domains-config.ts [--config ] + * npx tsx scripts/validate-domains-config.ts --config domains.config.json + */ + +import fs from 'fs'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DomainAlias = { + /** Alias domain (e.g. apex without www). */ + domain: string; + /** If true, requests to this alias 308-redirect to the primary domain. */ + redirectToPrimary: boolean; +}; + +export type DocsetDomain = { + /** Custom domain that serves a specific docset. */ + domain: string; + /** Docset ID that this domain serves (must match a registered docset). */ + docsetId: string; + /** Base path on the custom domain (default "/"). */ + basePath?: string; +}; + +export type LegacyRedirect = { + /** Domain being retired. */ + fromDomain: string; + /** Domain to redirect to. */ + toDomain: string; + /** HTTP status code for the redirect (default 308). */ + statusCode?: 301 | 302 | 307 | 308; + /** If true, the URL path is preserved in the redirect. */ + preservePath?: boolean; +}; + +export type DnsRecord = { + type: 'A' | 'AAAA' | 'CNAME'; + name: string; + value: string; +}; + +export type DomainsConfig = { + /** The primary production domain. */ + primaryDomain: string; + /** Additional domains that redirect to or mirror the primary. */ + aliases?: DomainAlias[]; + /** Per-docset custom domain mappings. */ + docsetDomains?: DocsetDomain[]; + /** Legacy domain redirect rules. */ + legacyRedirects?: LegacyRedirect[]; + /** DNS record expectations (reference only, not enforced). */ + dns?: { + expectedRecords?: DnsRecord[]; + }; +}; + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +type ConfigError = { level: 'error' | 'warn'; field: string; message: string }; + +export function validateDomainsConfig(config: DomainsConfig): ConfigError[] { + const errors: ConfigError[] = []; + const allDomains = new Set(); + + // Primary domain + if (!config.primaryDomain) { + errors.push({ level: 'error', field: 'primaryDomain', message: 'required' }); + } else { + if (config.primaryDomain.includes('://')) { + errors.push({ level: 'error', field: 'primaryDomain', message: 'must be a bare domain, not a URL (remove protocol)' }); + } + allDomains.add(config.primaryDomain); + } + + // Aliases + if (config.aliases) { + for (const alias of config.aliases) { + if (!alias.domain) { + errors.push({ level: 'error', field: 'aliases[].domain', message: 'required' }); + continue; + } + if (alias.domain === config.primaryDomain) { + errors.push({ level: 'error', field: `aliases[${alias.domain}]`, message: 'alias cannot be the same as primaryDomain' }); + } + if (allDomains.has(alias.domain)) { + errors.push({ level: 'error', field: `aliases[${alias.domain}]`, message: 'duplicate domain' }); + } + allDomains.add(alias.domain); + } + } + + // Docset domains + if (config.docsetDomains) { + const seenDocsets = new Set(); + for (const dd of config.docsetDomains) { + if (!dd.domain) { + errors.push({ level: 'error', field: 'docsetDomains[].domain', message: 'required' }); + } + if (!dd.docsetId) { + errors.push({ level: 'error', field: 'docsetDomains[].docsetId', message: 'required' }); + } + if (dd.domain && allDomains.has(dd.domain)) { + errors.push({ level: 'error', field: `docsetDomains[${dd.domain}]`, message: 'duplicate domain (already used as primary or alias)' }); + } + if (dd.docsetId && seenDocsets.has(dd.docsetId)) { + errors.push({ level: 'warn', field: `docsetDomains[${dd.docsetId}]`, message: 'docset mapped to multiple domains' }); + } + if (dd.domain) allDomains.add(dd.domain); + if (dd.docsetId) seenDocsets.add(dd.docsetId); + } + } + + // Legacy redirects + if (config.legacyRedirects) { + const redirectSources = new Set(); + for (const lr of config.legacyRedirects) { + if (!lr.fromDomain) { + errors.push({ level: 'error', field: 'legacyRedirects[].fromDomain', message: 'required' }); + continue; + } + if (!lr.toDomain) { + errors.push({ level: 'error', field: 'legacyRedirects[].toDomain', message: 'required' }); + continue; + } + if (lr.fromDomain === lr.toDomain) { + errors.push({ level: 'error', field: `legacyRedirects[${lr.fromDomain}]`, message: 'redirect loop: fromDomain equals toDomain' }); + } + if (redirectSources.has(lr.fromDomain)) { + errors.push({ level: 'error', field: `legacyRedirects[${lr.fromDomain}]`, message: 'duplicate fromDomain' }); + } + redirectSources.add(lr.fromDomain); + + const validCodes = [301, 302, 307, 308]; + if (lr.statusCode !== undefined && !validCodes.includes(lr.statusCode)) { + errors.push({ level: 'error', field: `legacyRedirects[${lr.fromDomain}].statusCode`, message: `must be one of: ${validCodes.join(', ')}` }); + } + } + + // Detect redirect chains: A -> B -> C + for (const lr of config.legacyRedirects) { + if (redirectSources.has(lr.toDomain)) { + errors.push({ level: 'warn', field: `legacyRedirects[${lr.fromDomain}]`, message: `redirect chain detected: ${lr.fromDomain} -> ${lr.toDomain} -> ... (consider redirecting directly to final destination)` }); + } + } + } + + // DNS records + if (config.dns?.expectedRecords) { + for (const rec of config.dns.expectedRecords) { + const validTypes = ['A', 'AAAA', 'CNAME']; + if (!validTypes.includes(rec.type)) { + errors.push({ level: 'warn', field: `dns.expectedRecords[${rec.name}]`, message: `unexpected record type: ${rec.type}` }); + } + if (!rec.name || !rec.value) { + errors.push({ level: 'warn', field: 'dns.expectedRecords[]', message: 'name and value are both required' }); + } + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(): { configPath: string } { + const args = process.argv.slice(2); + const idx = args.indexOf('--config'); + const configPath = idx !== -1 && args[idx + 1] + ? path.resolve(process.cwd(), args[idx + 1]) + : path.resolve(process.cwd(), 'domains.config.json'); + return { configPath }; +} + +const { configPath } = parseArgs(); + +if (!fs.existsSync(configPath)) { + console.error(`[domains-config] ERROR: config file not found: ${configPath}`); + console.error(' Copy domains.config.example.json to domains.config.json and configure your domains.'); + process.exit(1); +} + +let config: DomainsConfig; +try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +} catch (err) { + console.error(`[domains-config] ERROR: failed to parse JSON: ${(err as Error).message}`); + process.exit(1); +} + +const errors = validateDomainsConfig(config); +let hasErrors = false; + +for (const e of errors) { + const prefix = e.level === 'error' ? 'ERROR' : 'WARN'; + console[e.level === 'error' ? 'error' : 'warn'](`[domains-config] ${prefix}: ${e.field}: ${e.message}`); + if (e.level === 'error') hasErrors = true; +} + +if (hasErrors) { + process.exit(1); +} else { + const warnCount = errors.filter(e => e.level === 'warn').length; + const domainCount = 1 + + (config.aliases?.length ?? 0) + + (config.docsetDomains?.length ?? 0); + console.log(`[domains-config] config valid: ${domainCount} domain(s) configured${warnCount > 0 ? ` (${warnCount} warning(s))` : ''}`); +}