From 49b1e029b0832e0a3200571bd136cdf05e56d7ad Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 18:53:25 +0000 Subject: [PATCH] fix(dev): add actionable error for missing cert file --- packages/nuxt-cli/src/dev/cert.ts | 26 +++++++++-- packages/nuxt-cli/test/unit/dev-cert.spec.ts | 48 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 packages/nuxt-cli/test/unit/dev-cert.spec.ts diff --git a/packages/nuxt-cli/src/dev/cert.ts b/packages/nuxt-cli/src/dev/cert.ts index 7a8b3ee35..e63becf14 100644 --- a/packages/nuxt-cli/src/dev/cert.ts +++ b/packages/nuxt-cli/src/dev/cert.ts @@ -6,6 +6,7 @@ import { existsSync, readFileSync } from 'node:fs' import { readFile, writeFile } from 'node:fs/promises' import { isIP } from 'node:net' import process from 'node:process' +import { styleText } from 'node:util' import { join } from 'pathe' @@ -41,18 +42,35 @@ export interface ResolvedCertificate { export async function resolveCertificate(options: HTTPSOptions): Promise { if (options.pfx) { - return { pfx: await readFile(options.pfx), pfxPath: options.pfx, passphrase: options.passphrase } + return { pfx: await readCertificateFile(options.pfx, '--https.pfx'), pfxPath: options.pfx, passphrase: options.passphrase } } if (options.cert && options.key) { const [cert, key] = await Promise.all([ - readFile(options.cert, 'utf8'), - readFile(options.key, 'utf8'), + readCertificateFile(options.cert, '--https.cert'), + readCertificateFile(options.key, '--https.key'), ]) - return { cert, key, passphrase: options.passphrase } + return { cert: cert.toString('utf8'), key: key.toString('utf8'), passphrase: options.passphrase } } return generateCertificate(options) } +/** Read a file the user pointed `--https.*` at, naming the flag when it cannot be read. */ +async function readCertificateFile(path: string, flag: string): Promise { + try { + return await readFile(path) + } + catch (error) { + const reason = (error as NodeJS.ErrnoException).code === 'ENOENT' + ? `There is no file at ${styleText('cyan', path)}` + : `Cannot read ${styleText('cyan', path)} (${(error as NodeJS.ErrnoException).code})` + throw new ActionableError([ + `${styleText('red', reason)}${styleText('red', ', given as')} ${styleText('cyan', flag)}${styleText('red', '.')}`, + '', + `Point ${styleText('cyan', flag)} at a readable file, or drop it and let Nuxt generate a certificate.`, + ].join('\n')) + } +} + /** Regenerate rather than serve a certificate that expires within the day. */ const CERT_MIN_REMAINING_MS = 24 * 60 * 60 * 1000 diff --git a/packages/nuxt-cli/test/unit/dev-cert.spec.ts b/packages/nuxt-cli/test/unit/dev-cert.spec.ts new file mode 100644 index 000000000..e3220ba0d --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev-cert.spec.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { resolveCertificate } from '../../src/dev/cert' + +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true, maxRetries: 3 }))) +}) + +async function scratch(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'nuxi-cert-')) + dirs.push(dir) + return dir +} + +describe('resolving a certificate the user supplied', () => { + it('should read the pair it is pointed at', async () => { + const dir = await scratch() + await writeFile(join(dir, 'cert.pem'), 'certificate') + await writeFile(join(dir, 'key.pem'), 'private key') + + await expect(resolveCertificate({ cert: join(dir, 'cert.pem'), key: join(dir, 'key.pem') })) + .resolves + .toMatchObject({ cert: 'certificate', key: 'private key' }) + }) + + it('should name the missing file and the flag that pointed at it', async () => { + const dir = await scratch() + await writeFile(join(dir, 'key.pem'), 'private key') + + await expect(resolveCertificate({ cert: join(dir, 'absent.pem'), key: join(dir, 'key.pem') })) + .rejects + .toThrow(/There is no file at .*absent\.pem, given as --https\.cert/) + }) + + it('should say the same for a keystore', async () => { + const dir = await scratch() + + await expect(resolveCertificate({ pfx: join(dir, 'absent.p12') })) + .rejects + .toThrow(/absent\.p12, given as --https\.pfx/) + }) +})