| | |
| | | #!/usr/bin/env node |
| | | |
| | | import { spawn } from "node:child_process"; |
| | | import { mkdtemp, readFile, rm, access } from "node:fs/promises"; |
| | | import { constants } from "node:fs"; |
| | | import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; |
| | | import { tmpdir } from "node:os"; |
| | | import { join } from "node:path"; |
| | | |
| | |
| | | configFile: string; |
| | | keepOnSuccess: boolean; |
| | | keepOnFailure: boolean; |
| | | runContentSmokeTest: boolean; |
| | | verbose: boolean; |
| | | } |
| | | |
| | |
| | | configFile: "hugo.toml", |
| | | keepOnSuccess: false, |
| | | keepOnFailure: true, |
| | | runContentSmokeTest: true, |
| | | verbose: true, |
| | | }; |
| | | |
| | |
| | | * Print CLI help. |
| | | */ |
| | | function printHelp(): void { |
| | | console.log(` |
| | | console.log( |
| | | ` |
| | | Usage: |
| | | node scripts/test-hugo-quickstart.ts [options] |
| | | |
| | |
| | | --config-file=<file> Hugo config file to update |
| | | --keep-on-success Do not delete the temp directory when the test passes |
| | | --no-keep-on-failure Delete the temp directory when the test fails |
| | | --no-content-smoke-test Skip "hugo new foo.md" |
| | | --quiet Reduce step logging |
| | | --help Show this help |
| | | |
| | | Examples: |
| | | node scripts/test-hugo-quickstart.ts |
| | | node scripts/test-hugo-quickstart.ts --keep-on-success |
| | | node scripts/test-hugo-quickstart.ts --project-name=quickstart-test |
| | | `.trim()); |
| | | `.trim(), |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | continue; |
| | | } |
| | | |
| | | if (arg === "--no-content-smoke-test") { |
| | | options.runContentSmokeTest = false; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === "--quiet") { |
| | | options.verbose = false; |
| | | continue; |
| | |
| | | |
| | | child.on("close", (code, signal) => { |
| | | const durationMs = Date.now() - started; |
| | | const combined = [stdout, stderr].filter(Boolean).join(stdout && stderr ? "\n" : ""); |
| | | const combined = [stdout, stderr] |
| | | .filter(Boolean) |
| | | .join(stdout && stderr ? "\n" : ""); |
| | | |
| | | resolve({ |
| | | code, |
| | |
| | | } |
| | | |
| | | /** |
| | | * Ensure a file exists. |
| | | * Ensure a file or directory exists. |
| | | * |
| | | * @param filePath Absolute file path. |
| | | * @param filePath Absolute path to check. |
| | | */ |
| | | async function assertFileExists(filePath: string): Promise<void> { |
| | | await access(filePath, constants.F_OK); |
| | | } |
| | | |
| | | /** |
| | | * Execute one quick-start step and validate success. |
| | | * Ensure a file or directory does not exist. |
| | | * |
| | | * @param filePath Absolute path to check. |
| | | */ |
| | | async function assertFileDoesNotExist(filePath: string): Promise<void> { |
| | | try { |
| | | await access(filePath, constants.F_OK); |
| | | throw new Error(`Unexpected path exists: ${filePath}`); |
| | | } catch (error: unknown) { |
| | | if ( |
| | | error instanceof Error && |
| | | error.message.startsWith("Unexpected path exists:") |
| | | ) { |
| | | throw error; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Read a UTF-8 text file. |
| | | * |
| | | * @param filePath Absolute file path. |
| | | * @returns File contents. |
| | | */ |
| | | async function readTextFile(filePath: string): Promise<string> { |
| | | return readFile(filePath, "utf8"); |
| | | } |
| | | |
| | | /** |
| | | * Write a UTF-8 text file. |
| | | * |
| | | * @param filePath Absolute file path. |
| | | * @param content File contents. |
| | | */ |
| | | async function writeTextFile(filePath: string, content: string): Promise<void> { |
| | | await writeFile(filePath, content, "utf8"); |
| | | } |
| | | |
| | | /** |
| | | * Remove the generated public directory inside the temporary project. |
| | | * |
| | | * @param projectRoot Absolute path to the temporary quickstart project. |
| | | */ |
| | | async function removePublicDir(projectRoot: string): Promise<void> { |
| | | const publicPath = join(projectRoot, "public"); |
| | | await rm(publicPath, { recursive: true, force: true }); |
| | | } |
| | | |
| | | /** |
| | | * Execute one step and validate success. |
| | | * |
| | | * @param step Step definition. |
| | | * @returns Step report. |
| | |
| | | await assertFileExists(absolutePath); |
| | | } catch (error: unknown) { |
| | | const message = |
| | | error instanceof Error ? error.message : "Unknown file assertion error"; |
| | | error instanceof Error |
| | | ? error.message |
| | | : "Unknown file assertion error"; |
| | | |
| | | throw new Error( |
| | | [ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Determine whether a Hugo command generates output in `public/`. |
| | | * |
| | | * @param step Step definition. |
| | | * @returns True when the command is a build command. |
| | | */ |
| | | function isHugoBuildCommand(step: StepDefinition): boolean { |
| | | if (step.command !== "hugo") { |
| | | return false; |
| | | } |
| | | |
| | | if (step.args.length === 0) { |
| | | return true; |
| | | } |
| | | |
| | | if (step.args.includes("--buildDrafts")) { |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Execute a Hugo build command after clearing the generated public directory. |
| | | * |
| | | * @param step Step definition. |
| | | * @param projectRoot Absolute path to the temporary quickstart project. |
| | | * @returns Step report. |
| | | */ |
| | | async function executeHugoBuildStep( |
| | | step: StepDefinition, |
| | | projectRoot: string, |
| | | ): Promise<StepReport> { |
| | | await removePublicDir(projectRoot); |
| | | return executeStep(step); |
| | | } |
| | | |
| | | /** |
| | | * Escape a string for safe use in a regular expression. |
| | | * |
| | | * @param value Raw string. |
| | |
| | | } |
| | | |
| | | /** |
| | | * Read a text file as UTF-8. |
| | | * Assert that Hugo config contains the expected theme assignment somewhere in |
| | | * the file, without requiring the whole config to match a fixed template. |
| | | * |
| | | * @param filePath Absolute file path. |
| | | * @returns File contents. |
| | | */ |
| | | async function readTextFile(filePath: string): Promise<string> { |
| | | return readFile(filePath, "utf8"); |
| | | } |
| | | |
| | | /** |
| | | * Assert that Hugo config contains the expected theme assignment. |
| | | * Accepts either single or double quotes, for example: |
| | | * - theme = 'ananke' |
| | | * - theme = "ananke" |
| | | * |
| | | * @param configPath Absolute config path. |
| | | * @param themeName Expected theme name. |
| | | * @throws Error when the config does not contain the expected theme. |
| | | * @throws Error when the config does not contain the expected theme line. |
| | | */ |
| | | async function assertThemeConfigured( |
| | | configPath: string, |
| | |
| | | [ |
| | | "Strict assertion failed: theme configuration missing or incorrect.", |
| | | `Config file: ${configPath}`, |
| | | `Expected: theme = '${themeName}'`, |
| | | `Expected to find a line like: theme = '${themeName}'`, |
| | | "Actual file contents:", |
| | | config, |
| | | ].join("\n\n"), |
| | |
| | | } |
| | | |
| | | /** |
| | | * Return the strict homepage assertions. |
| | | * |
| | | * These are intentionally a mix of generic HTML checks and theme-oriented checks. |
| | | * The goal is not to pin every byte of output, but to prove that the theme actually |
| | | * rendered a plausible Ananke homepage. |
| | | * Return homepage assertions for the initial static build. |
| | | * |
| | | * @returns List of homepage assertions. |
| | | */ |
| | |
| | | }, |
| | | { |
| | | description: "homepage contains at least one navigation-related landmark", |
| | | test: (html: string): boolean => |
| | | /<(nav|header)\b/i.test(html), |
| | | test: (html: string): boolean => /<(nav|header)\b/i.test(html), |
| | | }, |
| | | { |
| | | description: "homepage contains theme-generated CSS class markers", |
| | |
| | | }, |
| | | { |
| | | description: "homepage contains a main content area", |
| | | test: (html: string): boolean => |
| | | /<(main|article|section)\b/i.test(html), |
| | | test: (html: string): boolean => /<(main|article|section)\b/i.test(html), |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Assert that the generated homepage looks like a real themed render and not just |
| | | * an empty or broken output file. |
| | | * Assert that the generated homepage looks like a real themed render. |
| | | * |
| | | * @param homepagePath Absolute path to `public/index.html`. |
| | | * @throws Error when one or more strict assertions fail. |
| | | * @throws Error when one or more assertions fail. |
| | | */ |
| | | async function assertHomepageLooksValid(homepagePath: string): Promise<void> { |
| | | const html = await readTextFile(homepagePath); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Assert that generated sample content contains frontmatter and basic metadata. |
| | | * Extract the auto-generated date line from a Hugo content file with TOML frontmatter. |
| | | * |
| | | * @param contentPath Absolute path to the created content file. |
| | | * @throws Error when the content file does not look like a valid Hugo content file. |
| | | * @param content Raw file contents. |
| | | * @returns Original date line. |
| | | * @throws Error when the date line is missing. |
| | | */ |
| | | async function assertGeneratedContentLooksValid(contentPath: string): Promise<void> { |
| | | const content = await readTextFile(contentPath); |
| | | function extractGeneratedDateLine(content: string): string { |
| | | const match = content.match(/^\s*date\s*=\s*.+$/m); |
| | | |
| | | if (!match) { |
| | | throw new Error( |
| | | [ |
| | | "Strict assertion failed: could not find auto-generated date line in content file.", |
| | | "Actual file contents:", |
| | | content, |
| | | ].join("\n\n"), |
| | | ); |
| | | } |
| | | |
| | | return match[0]; |
| | | } |
| | | |
| | | /** |
| | | * Replace the generated content with the requested sample draft while preserving |
| | | * the original date line created by `hugo new`. |
| | | * |
| | | * @param contentPath Absolute path to the content file. |
| | | */ |
| | | async function replaceGeneratedContent(contentPath: string): Promise<void> { |
| | | const original = await readTextFile(contentPath); |
| | | const dateLine = extractGeneratedDateLine(original); |
| | | |
| | | const updated = [ |
| | | "+++", |
| | | "title = 'My First Post'", |
| | | dateLine, |
| | | "draft = true", |
| | | "+++", |
| | | "## Introduction", |
| | | "", |
| | | "This is **bold** text, and this is *emphasized* text.", |
| | | "", |
| | | "Visit the [Hugo](https://gohugo.io) website!", |
| | | "", |
| | | ].join("\n"); |
| | | |
| | | await writeTextFile(contentPath, updated); |
| | | } |
| | | |
| | | /** |
| | | * Replace the root Hugo config with the requested quickstart config. |
| | | * |
| | | * @param configPath Absolute path to `hugo.toml`. |
| | | * @param themeName Theme name to set. |
| | | */ |
| | | async function replaceHugoConfig( |
| | | configPath: string, |
| | | themeName: string, |
| | | ): Promise<void> { |
| | | const content = [ |
| | | "baseURL = 'https://example.com/'", |
| | | "locale = 'en-gb'", |
| | | "title = 'Ananke Test Quickstart'", |
| | | `theme = '${themeName}'`, |
| | | "", |
| | | ].join("\n"); |
| | | |
| | | await writeTextFile(configPath, content); |
| | | } |
| | | |
| | | /** |
| | | * Assert that the generated page contains the expected rendered draft content. |
| | | * |
| | | * @param pageHtml HTML from `public/foo/index.html`. |
| | | */ |
| | | function assertDraftPageRendered(pageHtml: string): void { |
| | | const failures: string[] = []; |
| | | |
| | | if (!/^---\s*$/m.test(content) && !/^\+\+\+\s*$/m.test(content)) { |
| | | failures.push("- generated content does not appear to contain frontmatter delimiters"); |
| | | if (!/<h2[^>]*>\s*Introduction\s*<\/h2>/i.test(pageHtml)) { |
| | | failures.push("- heading 'Introduction' was not rendered as an h2 element"); |
| | | } |
| | | |
| | | if (!/^\s*title\s*:/m.test(content) && !/^\s*title\s*=/m.test(content)) { |
| | | failures.push("- generated content does not contain a title field"); |
| | | if (!/<strong>\s*bold\s*<\/strong>/i.test(pageHtml)) { |
| | | failures.push("- bold Markdown was not rendered as a <strong> element"); |
| | | } |
| | | |
| | | if (!/^\s*date\s*:/m.test(content) && !/^\s*date\s*=/m.test(content)) { |
| | | failures.push("- generated content does not contain a date field"); |
| | | if (!/<em>\s*emphasized\s*<\/em>/i.test(pageHtml)) { |
| | | failures.push("- emphasized Markdown was not rendered as an <em> element"); |
| | | } |
| | | |
| | | if ( |
| | | !/<a[^>]+href=["']https:\/\/gohugo\.io["'][^>]*>\s*Hugo\s*<\/a>/i.test( |
| | | pageHtml, |
| | | ) |
| | | ) { |
| | | failures.push("- Markdown link was not rendered as an anchor element"); |
| | | } |
| | | |
| | | if (!/My First Post/i.test(pageHtml)) { |
| | | failures.push("- post title was not visible on the rendered page"); |
| | | } |
| | | |
| | | if (failures.length > 0) { |
| | | throw new Error( |
| | | [ |
| | | "Strict assertion failed: generated content file did not match expected Hugo structure.", |
| | | `Content file: ${contentPath}`, |
| | | "Strict assertion failed: draft page content was not rendered as expected.", |
| | | "Failed assertions:", |
| | | ...failures, |
| | | "", |
| | | "Actual file contents:", |
| | | content, |
| | | ].join("\n"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Run the Hugo quick-start verification routine. |
| | | * Assert that the generated homepage reflects updated title and locale configuration. |
| | | * |
| | | * Locale is checked strictly on the `<html>` tag. |
| | | * |
| | | * @param homepageHtml HTML from `public/index.html`. |
| | | */ |
| | | function assertUpdatedConfigInOutput(homepageHtml: string): void { |
| | | const failures: string[] = []; |
| | | |
| | | if (!/Ananke Test Quickstart/i.test(homepageHtml)) { |
| | | failures.push( |
| | | "- updated site title was not visible in the generated output", |
| | | ); |
| | | } |
| | | |
| | | if (!/<html[^>]+lang=["']en-gb["'][^>]*>/i.test(homepageHtml)) { |
| | | failures.push( |
| | | "- updated locale 'en-gb' was not present in the <html lang=\"en-gb\"> tag", |
| | | ); |
| | | } |
| | | |
| | | if (failures.length > 0) { |
| | | throw new Error( |
| | | [ |
| | | "Strict assertion failed: updated config was not reflected in the generated output.", |
| | | "Failed assertions:", |
| | | ...failures, |
| | | ].join("\n"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Assert that the draft page is not part of the production build. |
| | | * |
| | | * @param projectRoot Project root. |
| | | * @param homepagePath Absolute path to `public/index.html`. |
| | | */ |
| | | async function assertDraftHiddenInProduction( |
| | | projectRoot: string, |
| | | homepagePath: string, |
| | | ): Promise<void> { |
| | | const draftOutputPath = join(projectRoot, "public", "foo", "index.html"); |
| | | await assertFileDoesNotExist(draftOutputPath); |
| | | |
| | | const homepageHtml = await readTextFile(homepagePath); |
| | | |
| | | if (/My First Post/i.test(homepageHtml)) { |
| | | throw new Error( |
| | | [ |
| | | "Strict assertion failed: draft post title was visible in the production homepage output.", |
| | | `Homepage file: ${homepagePath}`, |
| | | ].join("\n\n"), |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Run the full Hugo quickstart verification routine. |
| | | * |
| | | * @param options Runtime options. |
| | | * @returns Process exit code. |
| | |
| | | { |
| | | name: "Configure theme in Hugo config", |
| | | command: "bash", |
| | | args: ["-lc", `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`], |
| | | args: [ |
| | | "-lc", |
| | | `printf "\\ntheme = '${options.themeName}'\\n" >> ${JSON.stringify(options.configFile)}`, |
| | | ], |
| | | cwd: projectRoot, |
| | | expectedFiles: [options.configFile], |
| | | }, |
| | |
| | | }, |
| | | ]; |
| | | |
| | | if (options.runContentSmokeTest) { |
| | | steps.push({ |
| | | name: "Create sample content", |
| | | command: "hugo", |
| | | args: ["new", "foo.md"], |
| | | cwd: projectRoot, |
| | | expectedFiles: ["content/foo.md"], |
| | | }); |
| | | } |
| | | |
| | | try { |
| | | console.log(`Test root: ${sandboxRoot}`); |
| | | console.log(`Project root: ${projectRoot}`); |
| | |
| | | console.log(` ${formatCommand(step.command, step.args)}`); |
| | | } |
| | | |
| | | const report = await executeStep(step); |
| | | const report = isHugoBuildCommand(step) |
| | | ? await executeHugoBuildStep(step, projectRoot) |
| | | : await executeStep(step); |
| | | |
| | | reports.push(report); |
| | | |
| | | if (options.verbose) { |
| | |
| | | |
| | | const configPath = join(projectRoot, options.configFile); |
| | | const homepagePath = join(projectRoot, "public/index.html"); |
| | | const contentPath = join(projectRoot, "content/foo.md"); |
| | | const draftOutputPath = join(projectRoot, "public", "foo", "index.html"); |
| | | |
| | | console.log("\n[RUN] Strict config assertion"); |
| | | await assertThemeConfigured(configPath, options.themeName); |
| | |
| | | await assertHomepageLooksValid(homepagePath); |
| | | console.log("[OK ] Strict homepage assertion"); |
| | | |
| | | if (options.runContentSmokeTest) { |
| | | const contentPath = join(projectRoot, "content/foo.md"); |
| | | console.log("\n[RUN] Create sample content"); |
| | | const createContentStep: StepDefinition = { |
| | | name: "Create sample content", |
| | | command: "hugo", |
| | | args: ["new", "foo.md"], |
| | | cwd: projectRoot, |
| | | expectedFiles: ["content/foo.md"], |
| | | }; |
| | | const createContentReport = await executeStep(createContentStep); |
| | | reports.push(createContentReport); |
| | | |
| | | console.log("\n[RUN] Strict generated content assertion"); |
| | | await assertGeneratedContentLooksValid(contentPath); |
| | | console.log("[OK ] Strict generated content assertion"); |
| | | if (options.verbose) { |
| | | console.log( |
| | | ` ${formatCommand(createContentStep.command, createContentStep.args)}`, |
| | | ); |
| | | console.log( |
| | | `[OK ] ${createContentStep.name} (${createContentReport.result.durationMs} ms, exit ${String(createContentReport.result.code)})`, |
| | | ); |
| | | |
| | | const trimmedOutput = createContentReport.result.combined.trim(); |
| | | if (trimmedOutput) { |
| | | console.log(trimmedOutput); |
| | | } |
| | | } |
| | | |
| | | console.log("\n[RUN] Replace generated content with quickstart sample"); |
| | | await replaceGeneratedContent(contentPath); |
| | | console.log("[OK ] Replace generated content with quickstart sample"); |
| | | |
| | | console.log("\n[RUN] Build drafts and verify rendered draft content"); |
| | | const draftBuildStep: StepDefinition = { |
| | | name: "Build site with drafts", |
| | | command: "hugo", |
| | | args: ["--buildDrafts"], |
| | | cwd: projectRoot, |
| | | expectedFiles: ["public/index.html", "public/foo/index.html"], |
| | | }; |
| | | const draftBuildReport = await executeHugoBuildStep( |
| | | draftBuildStep, |
| | | projectRoot, |
| | | ); |
| | | reports.push(draftBuildReport); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | ` ${formatCommand(draftBuildStep.command, draftBuildStep.args)}`, |
| | | ); |
| | | console.log( |
| | | `[OK ] ${draftBuildStep.name} (${draftBuildReport.result.durationMs} ms, exit ${String(draftBuildReport.result.code)})`, |
| | | ); |
| | | |
| | | const trimmedOutput = draftBuildReport.result.combined.trim(); |
| | | if (trimmedOutput) { |
| | | console.log(trimmedOutput); |
| | | } |
| | | } |
| | | |
| | | const draftPageHtml = await readTextFile(draftOutputPath); |
| | | assertDraftPageRendered(draftPageHtml); |
| | | console.log("[OK ] Build drafts and verify rendered draft content"); |
| | | |
| | | console.log("\n[RUN] Replace root hugo.toml with quickstart config"); |
| | | await replaceHugoConfig(configPath, options.themeName); |
| | | console.log("[OK ] Replace root hugo.toml with quickstart config"); |
| | | |
| | | console.log("\n[RUN] Build drafts and verify updated title and locale"); |
| | | const configBuildStep: StepDefinition = { |
| | | name: "Build site with updated config and drafts", |
| | | command: "hugo", |
| | | args: ["--buildDrafts"], |
| | | cwd: projectRoot, |
| | | expectedFiles: ["public/index.html", "public/foo/index.html"], |
| | | }; |
| | | const configBuildReport = await executeHugoBuildStep( |
| | | configBuildStep, |
| | | projectRoot, |
| | | ); |
| | | reports.push(configBuildReport); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | ` ${formatCommand(configBuildStep.command, configBuildStep.args)}`, |
| | | ); |
| | | console.log( |
| | | `[OK ] ${configBuildStep.name} (${configBuildReport.result.durationMs} ms, exit ${String(configBuildReport.result.code)})`, |
| | | ); |
| | | |
| | | const trimmedOutput = configBuildReport.result.combined.trim(); |
| | | if (trimmedOutput) { |
| | | console.log(trimmedOutput); |
| | | } |
| | | } |
| | | |
| | | const updatedHomepageHtml = await readTextFile(homepagePath); |
| | | assertUpdatedConfigInOutput(updatedHomepageHtml); |
| | | console.log("[OK ] Build drafts and verify updated title and locale"); |
| | | |
| | | console.log("\n[RUN] Production build should exclude draft content"); |
| | | const productionBuildStep: StepDefinition = { |
| | | name: "Build production site without drafts", |
| | | command: "hugo", |
| | | args: [], |
| | | cwd: projectRoot, |
| | | expectedFiles: ["public/index.html"], |
| | | }; |
| | | const productionBuildReport = await executeHugoBuildStep( |
| | | productionBuildStep, |
| | | projectRoot, |
| | | ); |
| | | reports.push(productionBuildReport); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | ` ${formatCommand(productionBuildStep.command, productionBuildStep.args)}`, |
| | | ); |
| | | console.log( |
| | | `[OK ] ${productionBuildStep.name} (${productionBuildReport.result.durationMs} ms, exit ${String(productionBuildReport.result.code)})`, |
| | | ); |
| | | |
| | | const trimmedOutput = productionBuildReport.result.combined.trim(); |
| | | if (trimmedOutput) { |
| | | console.log(trimmedOutput); |
| | | } |
| | | } |
| | | |
| | | await assertDraftHiddenInProduction(projectRoot, homepagePath); |
| | | console.log("[OK ] Production build should exclude draft content"); |
| | | |
| | | console.log("\nResult: PASS"); |
| | | |
| | |
| | | } |
| | | |
| | | if (options.keepOnFailure) { |
| | | console.error(`\nKept failing test directory for inspection: ${projectRoot}`); |
| | | console.error( |
| | | `\nKept failing test directory for inspection: ${projectRoot}`, |
| | | ); |
| | | } else { |
| | | await rm(sandboxRoot, { recursive: true, force: true }); |
| | | console.error(`\nDeleted failing test directory: ${sandboxRoot}`); |
| | |
| | | const exitCode = await runRoutine(options); |
| | | process.exit(exitCode); |
| | | } catch (error: unknown) { |
| | | const message = error instanceof Error ? error.message : "Unknown fatal error"; |
| | | const message = |
| | | error instanceof Error ? error.message : "Unknown fatal error"; |
| | | console.error(`Fatal error: ${message}`); |
| | | process.exit(1); |
| | | } |