From e175762c95980bf76ddb28cbe18ef853a88fc58c Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Fri, 14 Aug 2026 18:53:06 +0100 Subject: [PATCH] Fix: stop register-super failing against a running app (fixes #123) --- lib/utils/isAppRunning.js | 22 ++++++++++++ lib/utils/registerSuperUser.js | 6 ++-- tests/isAppRunning.spec.js | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 lib/utils/isAppRunning.js create mode 100644 tests/isAppRunning.spec.js diff --git a/lib/utils/isAppRunning.js b/lib/utils/isAppRunning.js new file mode 100644 index 0000000..06fe3bf --- /dev/null +++ b/lib/utils/isAppRunning.js @@ -0,0 +1,22 @@ +import internalApiRequest from './internalApiRequest.js' + +/** + * Checks whether an app instance is already listening on the configured host/port. + * + * Any HTTP response means something is serving, including an error status: the probe requests + * the API endpoint map, which requires authentication, so an unauthenticated probe is answered + * with a 401 rather than a 200. Only a refused connection means nothing is listening. + * + * @param {Object} options Options to pass to the request + * @return {Promise} Whether an app instance is already running + */ +export default async function isAppRunning (options = {}) { + try { + await internalApiRequest('', undefined, { ...options, method: 'GET' }) + } catch (e) { + if (e.statusCode !== undefined) return true + if ((e.code ?? e.cause?.code) === 'ECONNREFUSED') return false + throw e + } + return true +} diff --git a/lib/utils/registerSuperUser.js b/lib/utils/registerSuperUser.js index f2b6ec0..85c89e0 100644 --- a/lib/utils/registerSuperUser.js +++ b/lib/utils/registerSuperUser.js @@ -1,13 +1,11 @@ import { createInterface } from 'readline' import prompts from 'prompts' import internalApiRequest from './internalApiRequest.js' +import isAppRunning from './isAppRunning.js' import startApp from './startApp.js' export default async function registerSuperUser (options = {}) { - try { - await internalApiRequest('', undefined, { ...options, method: 'GET' }) - } catch (e) { - if ((e.code ?? e.cause?.code) !== 'ECONNREFUSED') throw e + if (!await isAppRunning(options)) { await startApp(options) } let email = options.superEmail diff --git a/tests/isAppRunning.spec.js b/tests/isAppRunning.spec.js new file mode 100644 index 0000000..762f169 --- /dev/null +++ b/tests/isAppRunning.spec.js @@ -0,0 +1,62 @@ +import { describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' + +let requestError + +// mock internalApiRequest before importing isAppRunning +mock.module('../lib/utils/internalApiRequest.js', { + defaultExport: async () => { + if (requestError) throw requestError + } +}) + +const { default: isAppRunning } = await import('../lib/utils/isAppRunning.js') + +/** Builds the error internalApiRequest throws for a non-2xx response */ +function httpError (code, statusCode) { + const e = new Error(code) + e.code = code + e.statusCode = statusCode + return e +} + +/** Builds the error fetch throws when it can't connect */ +function transportError (code) { + const e = new TypeError('fetch failed') + e.cause = Object.assign(new Error(code), { code }) + return e +} + +describe('isAppRunning', () => { + it('should return true when the request succeeds', async () => { + requestError = undefined + assert.equal(await isAppRunning(), true) + }) + + it('should return true when the app responds with an auth error', async () => { + requestError = httpError('MISSING_AUTH_HEADER', 401) + assert.equal(await isAppRunning(), true) + }) + + it('should return true for any other error status', async () => { + for (const [code, status] of [['UNAUTHORISED', 403], ['NOT_FOUND', 404], ['SERVER_ERROR', 500]]) { + requestError = httpError(code, status) + assert.equal(await isAppRunning(), true) + } + }) + + it('should return false when the connection is refused', async () => { + requestError = transportError('ECONNREFUSED') + assert.equal(await isAppRunning(), false) + }) + + it('should rethrow errors which are neither a response nor a refused connection', async () => { + requestError = new Error('Cannot find module conf/production.config.js') + await assert.rejects(() => isAppRunning(), /Cannot find module/) + }) + + it('should rethrow other transport errors', async () => { + requestError = transportError('EHOSTUNREACH') + await assert.rejects(() => isAppRunning(), e => e.cause.code === 'EHOSTUNREACH') + }) +})