From 974d9515e2537299eca77d2d5eeb827235f62599 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 10:35:22 +0100 Subject: [PATCH 1/8] Update device-agent setup for interactive registration --- index.js | 763 ++++++++++++++++++++++++++++++--------- lib/AgentManager.js | 31 +- lib/cli/flowsImporter.js | 29 +- package-lock.json | 352 +++++++++++++----- package.json | 1 + 5 files changed, 896 insertions(+), 280 deletions(-) diff --git a/index.js b/index.js index c305bc55..4649774f 100755 --- a/index.js +++ b/index.js @@ -1,44 +1,49 @@ #!/usr/bin/env node +/* eslint-disable no-console */ const semver = require('semver') if (semver.lt(process.version, '14.0.0')) { - // eslint-disable-next-line no-console console.log('FlowFuse Device Agent requires at least NodeJS v14.x') process.exit(1) } const TESTING = process.env.NODE_ENV === 'test' -const commandLineArgs = require('command-line-args') -const { info, warn } = require('./lib/log') -const { hasProperty } = require('./lib/utils') + const path = require('path') const fs = require('fs') +const os = require('os') +const childProcess = require('child_process') +const net = require('net') + +const commandLineArgs = require('command-line-args') +const figures = require('@inquirer/figures').default +const confirm = require('@inquirer/confirm').default +const input = require('@inquirer/input').default +const select = require('@inquirer/select').default +const chalk = require('yoctocolors-cjs') // switch to the lighter yoctocolors-cjs to match @inquirer + +const { info } = require('./lib/log') +const { hasProperty } = require('./lib/utils') const { AgentManager } = require('./lib/AgentManager') const { WebServer } = require('./frontend/server') const ConfigLoader = require('./lib/config') -const webServer = new WebServer() -const figures = require('@inquirer/figures').default -const confirm = require('@inquirer/confirm').default -const print = (message, /** @type {figures} */ figure = chalk.gray(figures.lineBold)) => console.info(figure ?? chalk.gray(figures.lineBold), message) const flowImport = require('./lib/cli/flowsImporter').flowImport const { OLD_PROJECT_FILE, PROJECT_FILE } = require('./lib/agent') -const chalk = require('yoctocolors-cjs') // switch to the lighter yoctocolors-cjs to match @inquirer -function main (testOptions) { - const pkg = require('./package.json') - if (pkg.name === '@flowforge/flowforge-device-agent') { - // eslint-disable-next-line no-console - console.log(` -************************************************************************** -* The FlowFuse Device Agent is moving to '@flowfuse/device-agent' on npm * -* and 'flowfuse/device-agent' on DockerHub. Please upgrade to the new * -* packages to ensure you continue to receive updates. * -* See https://flowfuse.com/docs/device-agent/install/ for details * -************************************************************************** -`) +const webServer = new WebServer() + +const defaultInquirerTheme = { + prefix: '', + style: { + // Give some spacing to the messages + message: (text, status) => { return '\n' + chalk.bold(text) + '\n' } } +} - let options +async function main (testOptions) { + const pkg = require('./package.json') + // Parse command line args + let options try { options = commandLineArgs(require('./lib/cli/args'), { camelCase: true }) options = options._all @@ -62,6 +67,7 @@ function main (testOptions) { installerMode = true } + // Locate the config directory if (options.dir === '') { // No dir has been explicitly set, so we need to set the default. // 1. Use `/opt/flowforge-device` if it exists @@ -85,12 +91,21 @@ function main (testOptions) { throw new Error('Failed to create dir') } } catch (err) { - const quitMsg = `Cannot create dir '${options.dir}'. -Please ensure the parent directory is writable, or set a different path with -d` + const quitMsg = `Cannot create directory '${options.dir}'.\nPlease ensure the parent directory is writable, or set a different path with -d` quit(quitMsg, 20) // Exit Code 20 - Invalid dir // REF: https://slg.ddnss.de/list-of-common-exit-codes-for-gnu-linux/ return } + } else { + // Check we can write to the dir + const testFile = path.join(options.dir, `.write-test-${process.pid}-${Date.now()}`) + try { + fs.writeFileSync(testFile, '') + fs.unlinkSync(testFile) + // DIR is writeable, continue + } catch (err) { + quit(`Cannot write to directory '${options.dir}'.\nPlease ensure the directory is writable, or set a different path with -d`, 21) // Exit Code 21 - Dir not writable + } } // Locate the config file. Either the path exactly as specified, @@ -113,92 +128,341 @@ Please ensure the parent directory is writable, or set a different path with -d` options.deviceFile = deviceFile2 // deviceFile2 is the default value } + // Validate the config file if it exists, and parse it into an object + const parsedConfig = configFound && (ConfigLoader.parseDeviceConfigFile(options.deviceFile) || { valid: false }) + const isValidDeviceConfig = !!parsedConfig.valid + + // Now we can predict what port it will try to run NR on. Check if the port is available + // and quit with message if not available. + const desiredPort = options.port || parsedConfig?.deviceConfig?.port || 1880 + try { + const portAvailable = await isPortAvailable(desiredPort, '127.0.0.1') + if (!portAvailable) { + let portMessage + if (options.port) { + // command-line specified port + portMessage = `Port ${desiredPort} is not available. Use the --port option to select a different port to use.` + } else if (parsedConfig?.deviceConfig?.port) { + // device.yml specified port + portMessage = `Port ${desiredPort} is not available. Update device.yml or use the --port option to select a different port to use.` + } else { + // default 1880 + portMessage = `Port ${desiredPort} is not available. Use the --port option to select a different port to use.` + } + quit(portMessage, 2) + } + } catch (err) { + // Error checking port availability; err on the side of caution and continue + } + delete options.config AgentManager.init(options) + // CLI Option indicates this is a OTC setup run if (hasProperty(options, 'otc') || hasProperty(options, 'ffUrl')) { - // Quick Connect mode if (!options.otc || options.otc.length < 8) { // 8 is the minimum length of an OTC // e.g. ab-cd-ef - warn('Device setup requires parameter --otc to be 8 or more characters') + console.log('Device setup requires parameter --otc to be 8 or more characters') quit(null, 2) } - console.info() - print('Setting up your device...', chalk.gray(figures.bullet)) if (!options.ffUrl) { - warn('Device setup requires parameter --ff-url to be set') + console.log('Device setup requires parameter --ff-url to be set') quit(null, 2) } - let deviceSettings = null - AgentManager.quickConnectDevice().then((provisioningData) => { - deviceSettings = provisioningData - if (!deviceSettings) { - print('Device setup was unsuccessful', chalk.redBright(figures.cross)) - quit(null, 2) + logSetupStart() + console.log() + console.log(`Connecting to ${chalk.cyan(options.ffUrl)} with code ${chalk.cyan(options.otc)}`) + handleOTCSetup(options) + } else if (!isValidDeviceConfig) { + // No valid config found - run the interactive setup flow + handleInteractiveRegistration(options) + } else { + // Valid config found - start the agent normally + start(options, configFound) + if (TESTING) { + return { + AgentManager, + webServer, + options: { + ...ConfigLoader.defaults, + ...options + } } - const runCommandInfo = ['flowfuse-device-agent'] - if (options.dir !== '/opt/flowfuse-device') { - runCommandInfo.push(`-d ${options.dir}`) + } + } + return null + + // ---- Only utility functions below this point ---- + + /** + * Start the DeviceAgent with the given options and configFound flag. + * @param {*} options + * @param {*} configFound + */ + async function start (options, configFound) { + info('FlowFuse Device Agent') + info('----------------------') + + if (options.ui) { + info('Starting Web UI') + if (!options.uiUser || !options.uiPass) { + quit('Web UI cannot run without a username and password. These are set via with --ui-user and --ui-pass', 2) + } + const uiRuntime = Number(options.uiRuntime) + if (isNaN(uiRuntime) || uiRuntime === Infinity || uiRuntime < 0) { + quit('Web UI runtime must be 0 or greater', 2) } - if (installerMode) { - print('Success!', chalk.green(figures.tick)) + const opts = { + port: options.uiPort || 1879, + host: options.uiHost || '0.0.0.0', + credentials: { + username: options.uiUser, + password: options.uiPass + }, + runtime: uiRuntime, + dir: options.dir, + config: options.config, + deviceFile: options.deviceFile + } + webServer.initialize(AgentManager, opts) + webServer.start().then().catch((err) => { + info(`Web UI failed to start: ${err.message}`) + }) + } + + process.on('SIGINT', closeAgentAndQuit) + process.on('SIGTERM', closeAgentAndQuit) + process.on('SIGQUIT', closeAgentAndQuit) + + // Revalidate the config file if it exists as an earlier step may have regenerated it + const parsedConfig = configFound && (ConfigLoader.parseDeviceConfigFile(options.deviceFile) || { valid: false }) + const isValidDeviceConfig = !!parsedConfig.valid + + if (isValidDeviceConfig) { + const desiredPort = options.port || parsedConfig.deviceConfig.port || 1880 + try { + const portAvailable = await isPortAvailable(desiredPort, '127.0.0.1') + if (!portAvailable) { + quit(`Port ${desiredPort} is not available. Update device.yml or use the --port option to select a different port to use.`, 2) + } + } catch (err) { + // Error checking port availability; err on the side of caution and continue + } + + AgentManager.startAgent() + } else if (configFound && options.ui === true) { + info(`Invalid config file '${options.deviceFile}'.`) + } else if (!configFound && options.ui === true) { + info(`No config file found at '${deviceFile1}' or '${deviceFile2}'`) + } else { + if (configFound) { + quit(`Invalid config file '${options.deviceFile}': ${parsedConfig?.message || 'Unknown error'}'.`, 9) // Exit Code 9 - Invalid config file } else { - print('Success!', chalk.green(figures.tick)) - print('This Device can be launched at any time using the following command:') - print(runCommandInfo.join(' '), ' ') + quit(`No config file found at '${deviceFile1}' or '${deviceFile2}'`, 2) // No such file or directory } - if (!options.otcNoImport) { - // Support for importing flows during initial state check-in was added after 2.16.0. - // Use semver.coerce to validate the ffVersion. This will, by default, strip off suffixes to ensure - // a clean x.y.z comparison. - const ffVersion = semver.coerce(deviceSettings.meta?.ffVersion || '0.0.0') // Strip suffixes like -beta.1 - const ffSupportsImport = (ffVersion && semver.gt(ffVersion, '2.16.0')) - - if (ffSupportsImport) { - const home = process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || '/' - const parentOfHome = path.dirname(home) - const root = path.parse(parentOfHome).root - const homeNodeRed = path.join(home, '.node-red') - const rootNodeRed1 = path.join(root, 'node-red') - const rootNodeRed2 = path.join(root, '.node-red') - const rootNodeRed3 = path.join(root, 'nodered') - const rootNodeRed4 = path.join(root, 'data') // common location for Node-RED data - const suggestedDirs = [process.cwd(), homeNodeRed, rootNodeRed1, rootNodeRed2, rootNodeRed3, rootNodeRed4] + } + } + + /** + * Handle the interactive registration flow + * @param {*} options + * @returns + */ + async function handleInteractiveRegistration (options) { + try { + // This handles the interactive registration flow with a platform. + // This requires the platform URL to connect to. + + logSetupStart() + // Steps: + // 1. Ask for the platform URL + let platformURL = await input({ + message: 'Enter the URL of your FlowFuse platform:', + default: 'https://app.flowfuse.com', + prefill: 'tab', + required: true, + validate: (input) => { try { - // get an array of .node-red dirs in the home directories - const parentDirNodeRedDirs = fs.readdirSync(parentOfHome, { withFileTypes: true }) - .filter(dir => dir.isDirectory()) - .map(dir => path.join(parentOfHome, dir.name, '.node-red')) - .filter(dir => fs.existsSync(dir) && fs.statSync(dir).isDirectory()) - suggestedDirs.push(...parentDirNodeRedDirs) - } catch (_err) { - // If we can't read the parent directory, just ignore it + // eslint-disable-next-line no-unused-vars + const url = new URL(input) + return true + } catch (err) { + return 'Please enter a valid URL (e.g. https://app.flowfuse.com)' + } + }, + theme: defaultInquirerTheme + }, { clearPromptOnDone: true }) + platformURL = platformURL.trim() + if (!platformURL.endsWith('/')) { + platformURL += '/' + } + console.log() + console.log(`Connecting to ${chalk.cyan(platformURL)}`) + + // 2. Ask if they have an OTC (one-time code) or need to register + const answer = await select({ + message: 'Are you registering a new instance or connecting with an existing One-Time Code (OTC)?', + choices: [ + { + name: 'Register as a new instance', + value: 'register' + // description: '' + }, + { + name: 'Connect with an existing One-Time Code (OTC)', + value: 'otc' + // description: '' + } + ], + theme: defaultInquirerTheme + }, { clearPromptOnDone: true }) + + // 3. If they have an OTC, contiue with handleOTCSetup + if (answer === 'otc') { + const otc = await input({ + message: 'Enter your One-Time Code (OTC):', + required: true, + theme: defaultInquirerTheme + }, { clearPromptOnDone: true }) + options.otc = otc + options.ffUrl = platformURL.replace(/\/$/, '') // remove trailing slash + await handleOTCSetup(options) + } else if (answer === 'register') { + // 4. If they want to register, start the magic flow + // New steps: + // 1. HTTP POST to registration end point - get registerUrl and doneUrl + const { default: got } = require('got') + const client = got.extend({ + // platformURL is already normalized to have a trailing slash + prefixUrl: platformURL + }) + const registrationResponse = await client.post('api/v1/devices/_/register', {}) + const { registerUrl, doneUrl } = JSON.parse(registrationResponse.body) + + // 2. prompt user to open registerUrl (attempt to open ourselves) + const fullRegisterUrl = platformURL + registerUrl.replace(/^\//, '') + await confirm({ + message: chalk.bold('To continue with registering your new instance, press ENTER to open the the following URL in your browser:') + `\n\n ${chalk.bold(figures.triangleRightSmall)} ${chalk.cyan(fullRegisterUrl)}`, + theme: { + prefix: '', + style: { + // We don't want the Y/n as this is a simple press-enter-to-continue prompt + defaultAnswer: (text, status) => { return ' ' }, + message: (text, status) => { return '\n' + text } + } } - // add common locations for FlowFuse Device Agent projects flows - suggestedDirs.push(path.join('/opt/flowfuse-device/project')) - suggestedDirs.push(path.join('/opt/flowforge-device/project')) - // if provided, add the dir option as a suggested directory - if (options.dir) { - suggestedDirs.push(options.dir) - suggestedDirs.push(path.join(options.dir, 'project')) + }, { clearPromptOnDone: true }) + + const browserOpened = await openBrowser(fullRegisterUrl) + + console.log() + if (browserOpened) { + console.log('Your browser has been opened. If it has not, please copy and paste this URL into a browser to continue:') + } else { + console.log('Please copy and paste this URL into a browser to continue:') + } + + console.log() + console.log(` ${chalk.bold(figures.triangleRightSmall)} ${chalk.cyan(fullRegisterUrl)}`) + console.log() + console.log(chalk.bold('Waiting for registration to complete...')) + const ac = new AbortController() + spinner(ac.signal) + let result + try { + result = await pollDoneUrl(client, doneUrl.replace(/^\//, ''), 5000, ac.signal) + } catch (err) { + if (err.name === 'AbortError') { + // Polling was cancelled - nothing more to do here + return } - const absoluteSuggestedDirs = suggestedDirs.map(dir => path.resolve(dir)) // absolute paths - const uniqueSuggestedDirs = [...new Set(absoluteSuggestedDirs)] - return flowImport(uniqueSuggestedDirs) + throw err + } finally { + // Stop the spinner whether we completed, cancelled or errored + ac.abort() + } + // If the user has been hitting enter, we don't want that to be in the stdin buffer when we prompt for the following steps + await clearStdinBuffer() + if (result && result.otc) { + options.otc = result.otc + options.ffUrl = platformURL.replace(/\/$/, '') // remove trailing slash + return handleOTCSetup(options) } + console.log('Something went wrong with the device registration. The platform returned an unexpected response:') + console.log(result) + console.log('Please try again, or contact support if the problem persists.') + quit('', 2) + } + } catch (err) { + console.info() + if (err instanceof Error && (err.name === 'HTTPError' || err.name === 'RequestError')) { + // An error when establishing the registration session with the platform. + // This could be: + // 1. A network error + // 2. An invalid platform URL + // 3. An unexpected response from the platform + quit(`Failed to connect to the platform. ${err.message}`, 2) + } else if (err instanceof Error && err.name === 'ExitPromptError') { + // User has hit ctrl-c or ctrl-d to exit the prompt. We don't need to log an error message in this case. + quit('', 2) + } else { + quit(err.message, 2) + } + } + } + + /** + * Setup step - handle importing existing flows + */ + async function handleFlowImport (options, deviceSettings) { + // Support for importing flows during initial state check-in was added after 2.16.0. + // Use semver.coerce to validate the ffVersion. This will, by default, strip off suffixes to ensure + // a clean x.y.z comparison. + const ffVersion = semver.coerce(deviceSettings.meta?.ffVersion || '0.0.0') // Strip suffixes like -beta.1 + const ffSupportsImport = (ffVersion && semver.gt(ffVersion, '2.16.0')) + + if (ffSupportsImport) { + const home = process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || '/' + const parentOfHome = path.dirname(home) + const root = path.parse(parentOfHome).root + const homeNodeRed = path.join(home, '.node-red') + const rootNodeRed1 = path.join(root, 'node-red') + const rootNodeRed2 = path.join(root, '.node-red') + const rootNodeRed3 = path.join(root, 'nodered') + const rootNodeRed4 = path.join(root, 'data') // common location for Node-RED data + const suggestedDirs = [process.cwd(), homeNodeRed, rootNodeRed1, rootNodeRed2, rootNodeRed3, rootNodeRed4] + + try { + // get an array of .node-red dirs in the home directories + const parentDirNodeRedDirs = fs.readdirSync(parentOfHome, { withFileTypes: true }) + .filter(dir => dir.isDirectory()) + .map(dir => path.join(parentOfHome, dir.name, '.node-red')) + .filter(dir => fs.existsSync(dir) && fs.statSync(dir).isDirectory()) + suggestedDirs.push(...parentDirNodeRedDirs) + } catch (_err) { + // If we can't read the parent directory, just ignore it + } + // add common locations for FlowFuse Device Agent projects flows + suggestedDirs.push(path.join('/opt/flowfuse-device/project')) + suggestedDirs.push(path.join('/opt/flowforge-device/project')) + // if provided, add the dir option as a suggested directory + if (options.dir) { + suggestedDirs.push(options.dir) + suggestedDirs.push(path.join(options.dir, 'project')) } - return Promise.resolve() - }).then((importOptions) => { - if (importOptions) { + const absoluteSuggestedDirs = suggestedDirs.map(dir => path.resolve(dir)) // absolute paths + const uniqueSuggestedDirs = [...new Set(absoluteSuggestedDirs)] + const importOptions = await flowImport(uniqueSuggestedDirs) + if (importOptions !== null) { const deviceConfig = { flows: importOptions.flows || [], credentials: importOptions.credentials || {}, package: importOptions.package || {} } - print('Uploading snapshot as the target for this Device...') - return AgentManager.postState( + console.info() + const importResponse = await AgentManager.postState( { token: deviceSettings.credentials.token, deviceId: deviceSettings.id, forgeURL: options.ffUrl }, { provisioning: { @@ -211,44 +475,59 @@ Please ensure the parent directory is writable, or set a different path with -d` state: 'provisioning' } ) - } - return Promise.resolve() - }).then((importResponse) => { - if (importResponse) { if (importResponse.statusCode === 200) { + // The response doesn't contain any useful information to show the user - but it has worked // at this point, flowImport has successfully created a snapshot on the platform - we can safely clean up the local files // check to see if project dir exists & if so, clean it up const projectDir = path.join(options.dir, 'project') if (fs.existsSync(projectDir)) { - print('Cleaning up existing project directory...') fs.rmSync(projectDir, { force: true, recursive: true }) } let projectJson = path.join(options.dir, OLD_PROJECT_FILE) if (fs.existsSync(projectJson)) { - print('Cleaning up existing project file...') fs.rmSync(projectJson, { force: true }) } projectJson = path.join(options.dir, PROJECT_FILE) if (fs.existsSync(projectJson)) { - print('Cleaning up existing project file...') fs.rmSync(projectJson, { force: true }) } - print('Success!', chalk.green(figures.tick)) + console.info('Flow import successful') } else { - print(`Snapshot import was unsuccessful (${importResponse.statusCode})`, chalk.redBright(figures.cross)) + console.info(`Flow import failed: ${importResponse.body}`) } } + } + } + + /** + * Setup step - handle OTC setup + */ + async function handleOTCSetup (options) { + try { + // Quick Connect mode + // quickConnectDevice will throw if there are any issues + const deviceSettings = await AgentManager.quickConnectDevice() + const runCommandInfo = ['flowfuse-device-agent'] + if (options.dir !== '/opt/flowfuse-device') { + runCommandInfo.push(`-d ${options.dir}`) + } + console.log() + console.log(`Successfully registered as ${chalk.cyan(deviceSettings.name)} ${chalk.gray('(' + deviceSettings.id + ')')} in Team ${chalk.cyan(deviceSettings.team.name)}`) + + if (!options.otcNoImport) { + await handleFlowImport(options, deviceSettings) + } // If the user has set otcNoStart, then we don't want to start the agent console.info() - if (!options.otcNoStart) { - return confirm({ message: 'Do you want to start the Device Agent now?' }) - } else { - quit() + + if (!installerMode) { + console.log('The Device Agent can be launched at any time using the following command:') + console.log(` ${chalk.bold(runCommandInfo.join(' '))}`) } - }).then((startNow) => { - if (startNow) { - console.info() - info('Starting Device Agent with new configuration') + + if (!options.otcNoStart) { + console.clear() + console.log('Starting Device Agent with new configuration') delete options.otc delete options.ffUrl options.deviceFile = path.join(options.dir, 'device.yml') @@ -257,68 +536,18 @@ Please ensure the parent directory is writable, or set a different path with -d` console.info() quit() } - }).catch((err) => { + } catch (err) { console.info() quit(err.message, 2) - }) - return - } - - start(options, configFound) - - function start (options, configFound) { - info('FlowFuse Device Agent') - info('----------------------') - - if (options.ui) { - info('Starting Web UI') - if (!options.uiUser || !options.uiPass) { - quit('Web UI cannot run without a username and password. These are set via with --ui-user and --ui-pass', 2) - } - const uiRuntime = Number(options.uiRuntime) - if (isNaN(uiRuntime) || uiRuntime === Infinity || uiRuntime < 0) { - quit('Web UI runtime must be 0 or greater', 2) - } - const opts = { - port: options.uiPort || 1879, - host: options.uiHost || '0.0.0.0', - credentials: { - username: options.uiUser, - password: options.uiPass - }, - runtime: uiRuntime, - dir: options.dir, - config: options.config, - deviceFile: options.deviceFile - } - webServer.initialize(AgentManager, opts) - webServer.start().then().catch((err) => { - info(`Web UI failed to start: ${err.message}`) - }) - } - - process.on('SIGINT', closeAgentAndQuit) - process.on('SIGTERM', closeAgentAndQuit) - process.on('SIGQUIT', closeAgentAndQuit) - - const parsedConfig = configFound && (ConfigLoader.parseDeviceConfigFile(options.deviceFile) || { valid: false }) - const isValidDeviceConfig = !!parsedConfig.valid - - if (isValidDeviceConfig) { - AgentManager.startAgent() - } else if (configFound && options.ui === true) { - info(`Invalid config file '${options.deviceFile}'.`) - } else if (!configFound && options.ui === true) { - info(`No config file found at '${deviceFile1}' or '${deviceFile2}'`) - } else { - if (configFound) { - quit(`Invalid config file '${options.deviceFile}': ${parsedConfig?.message || 'Unknown error'}'.`, 9) // Exit Code 9 - Invalid config file - } else { - quit(`No config file found at '${deviceFile1}' or '${deviceFile2}'`, 2) // No such file or directory - } } } + /** + * Quit the process with an optional message and error code. + * If in testing mode, it will call the onExit callback instead of exiting. + * @param {*} msg + * @param {*} errCode + */ function quit (msg, errCode = 0) { if (msg) { console.error(msg) } if (TESTING) { @@ -331,22 +560,226 @@ Please ensure the parent directory is writable, or set a different path with -d` } } + /** + * Close the agent and quit with an optional message and error code. + * @param {*} msg + * @param {*} errCode + */ async function closeAgentAndQuit (msg, errCode = 0) { if (AgentManager) { await AgentManager.close() } quit(msg, errCode) } - if (TESTING) { - return { - AgentManager, - webServer, - options: { - ...ConfigLoader.defaults, - ...options + /** + * Log the common setup start message to the console + */ + async function logSetupStart () { + console.clear() + console.log(`${chalk.bold('Welcome to the')} ${chalk.cyan('FlowFuse Device Agent')} ${chalk.gray(`v${pkg.version}`)}`) + } + + /** + * Display an animated spinner in the terminal until the provided + * AbortSignal fires, at which point the spinner is cleared. No-ops when + * stdout is not a TTY. + * @param {AbortSignal} signal - The signal to listen for to stop the spinner. + */ + async function spinner (signal) { + if (!process.stdout.isTTY) { + return + } + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + let index = 0 + + // Hide the cursor while the spinner is running + process.stdout.write('\x1B[?25l') + + const timer = setInterval(() => { + process.stdout.write(frames[index]) + process.stdout.cursorTo(0) + index = (index + 1) % frames.length + }, 80) + + const stop = () => { + clearInterval(timer) + // Clear the spinner from the line and restore the cursor + process.stdout.cursorTo(0) + process.stdout.clearLine(1) + process.stdout.write('\x1B[?25h') + } + + if (signal.aborted) { + stop() + return + } + signal.addEventListener('abort', stop, { once: true }) + } + + /** + * Poll the given doneUrl until we get a 200 response, or throw an error if we get a 404. + * The polling will continue until the provided AbortSignal is aborted, at which point an AbortError will be thrown. + * @param {*} client + * @param {*} doneUrl + * @param {*} interval + * @param {*} signal + * @returns + */ + async function pollDoneUrl (client, doneUrl, interval = 5000, signal) { + while (true) { + // Throws an AbortError if the signal has been aborted, so the + // caller can distinguish cancellation from a completed poll. + signal?.throwIfAborted() + try { + const response = await client.get(doneUrl) + if (response.statusCode === 200) { + return JSON.parse(response.body) + } else if (response.statusCode === 404) { + throw new Error('Registration not found') + } + } catch (err) { + if (err.response?.statusCode === 404) { + throw new Error('Registration not found') + } + // ignore errors and retry + console.log('poll error:', err.name, err.message, err.response?.statusCode) // DEBUG } + // Wait for the interval, but wake early if the signal is aborted. + // The throwIfAborted() at the top of the loop then handles the exit. + await new Promise(resolve => { + if (signal?.aborted) { + resolve() + return + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, interval) + const onAbort = () => { + clearTimeout(timer) + resolve() + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) } } - return null + + /** + * Attempt to open the given URL in the user's default web browser in a + * cross-platform way. Returns a boolean indicating whether we *think* the + * browser was opened - this cannot be known for certain (for example when + * running over an SSH/remote session there may be no browser to open). + * @param {string} url - The URL to open in the browser. + * @returns {Promise} - Resolves to true if the browser was opened, false otherwise. + */ + async function openBrowser (url) { + const platform = os.platform() + + // On Linux there is no display to open a browser on when running + // headless or over a remote session, so don't even try. + if (platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + return false + } + + let command + let args + if (platform === 'darwin') { + command = 'open' + args = [url] + } else if (platform === 'win32') { + // Use cmd's `start` builtin. The empty '' is the window title + // argument, required so a quoted URL isn't treated as the title. + command = 'cmd' + args = ['/c', 'start', '', url] + } else { + // Assume a freedesktop.org compatible platform (Linux, BSD, etc.) + command = 'xdg-open' + args = [url] + } + + return new Promise((resolve) => { + try { + // Note: we intentionally do not detach/unref the child. The + // launcher commands (open/start/xdg-open) hand off and exit + // near-instantly, and we need to stay ref'd until the 'exit' + // event so we can read the exit code. Unref-ing here lets the + // event loop drain and the process exit mid-await. + const child = childProcess.spawn(command, args, { + stdio: 'ignore' + }) + child.on('error', () => resolve(false)) + // A non-zero exit code means the launcher command failed + child.on('exit', (code) => resolve(code === 0)) + } catch (err) { + resolve(false) + } + }) + } + + /** + * Drain the stdin buffer to ensure that any leftover input (e.g. from pressing + * enter during a previous prompt) does not interfere with subsequent prompts. + */ + async function clearStdinBuffer () { + return new Promise((resolve) => { + // Only need to do this for TTY terminals + if (!process.stdin.isTTY) { + resolve() + return + } + try { + process.stdin.resume() + while (process.stdin.read() !== null) { + // Consume from stdin until there's nothing left + } + // Pause a bit then resolve to continue + setTimeout(resolve, 25) + } catch { + resolve() + } + }) + } + + /** + * Checks if the port is open. + * Returns a promise that resolves to true if the port is open, false if it is closed. + * @param {number} port - The port to check. + * @param {string} host - The host to check. + * @returns {Promise} - Resolves to true if the port is open, false if it is closed. + */ + async function isPortAvailable (port, host) { + return new Promise((resolve, reject) => { + const client = new net.Socket() + function closeClient () { + try { + if (client) { + client.removeAllListeners('connect') + client.removeAllListeners('error') + client.end() + client.destroy() + client.unref() + } + } catch (err) { + // ignore + } + } + client.once('connect', () => { + // Managed to connect a socket; the port is in use + resolve(false) + closeClient() + }) + client.once('error', (err) => { + if (err.code === 'ECONNREFUSED') { + // Connection refused; the port is not in use + resolve(true) + } else { + // Something when wrong - reject + reject(err) + } + closeClient() + }) + client.connect({ port, host }, () => {}) + }) + } } // if we are testing, export the main function so we can call it directly, otherwise call it now diff --git a/lib/AgentManager.js b/lib/AgentManager.js index 9f09123b..ed1894b6 100644 --- a/lib/AgentManager.js +++ b/lib/AgentManager.js @@ -256,7 +256,6 @@ class AgentManager { forgeURL: this.options.ffUrl, token: Buffer.from(this.options.otc).toString('base64') } - let success = false let postResponse = null const url = new URL('/api/v1/devices/', provisioningConfig.forgeURL) @@ -288,30 +287,24 @@ class AgentManager { json: { setup: true, agentHost: host || ip }, agent: getHTTPProxyAgent(provisioningConfig.forgeURL, { timeout: 10000 }) }) - if (postResponse?.statusCode !== 200) { throw new Error(`${postResponse.statusMessage} (${postResponse.statusCode})`) } - success = true } catch (err) { - warn(`Problem encountered during provisioning: ${err.toString()}`) - success = false - } - - if (!success) { - return false + // err is an error from GOT. What is the status code of the response? + const statusCode = err?.response?.statusCode + if (statusCode === 401) { + throw new Error('One-Time Code is invalid or has already been used. Please generate a new one and try again.') + } else { + throw new Error(`Problem encountered during provisioning: ${err.toString()}`) + } } - try { - // * At this point, the one-time-code is spent (deleted) and we have all the info we need to update the config - const provisioningData = JSON.parse(postResponse.body) - provisioningData.forgeURL = provisioningData.forgeURL || provisioningConfig.forgeURL - await this._provisionDevice(provisioningData) - return provisioningData - } catch (err) { - warn(`Error provisioning device: ${err.toString()}`) - throw err - } + // * At this point, the one-time-code is spent (deleted) and we have all the info we need to update the config + const provisioningData = JSON.parse(postResponse.body) + provisioningData.forgeURL = provisioningData.forgeURL || provisioningConfig.forgeURL + await this._provisionDevice(provisioningData) + return provisioningData } async canBeProvisioned (provisioningConfig) { diff --git a/lib/cli/flowsImporter.js b/lib/cli/flowsImporter.js index c5a92913..4917f706 100644 --- a/lib/cli/flowsImporter.js +++ b/lib/cli/flowsImporter.js @@ -7,7 +7,6 @@ const chalk = require('yoctocolors-cjs') // switch to the lighter yoctocolors-cj const { createRawItem } = require('./fileSelector/utils/item.js') const select = require('@inquirer/select').default const figures = require('@inquirer/figures').default -const print = (message, /** @type {figures} */ figure = chalk.gray(figures.lineBold)) => console.info(figure ?? chalk.gray(figures.lineBold), message) const fileSelector = require('./fileSelector/index.js').fileSelector const CHOICES = { @@ -15,6 +14,14 @@ const CHOICES = { BROWSE: -2 // browse filesystem } +const defaultInquirerTheme = { + prefix: '', + style: { + // Give some spacing to the messages + message: (text, status) => { return '\n' + chalk.bold(text) + '\n' } + } +} + /** * Asks the user if they want to import existing Node-RED flows * @@ -88,16 +95,16 @@ async function askImport (suggestedDirs) { value: CHOICES.BROWSE, description: 'Press to browse the filesystem for Node-RED flows files' }) - console.info() // Present the options to the user choice = await select({ - message: 'Import existing Node-RED flows?', + message: 'We have found an existing set of Node-RED flows. Do you want to import these into your new instance?', choices, default: defaultChoice, pageSize: 10, - instructions: { navigation: chalk.gray('Use arrow keys to select an option, press to confirm') } - }) + instructions: { navigation: chalk.gray('Use arrow keys to select an option, press to confirm') }, + theme: defaultInquirerTheme + }, { clearPromptOnDone: true }) const suggestedFlowChosen = choice?.isFlowsFile === true const suggestedDirChosen = choice && !suggestedFlowChosen && suggestedDirDetails.find(dir => dir.userDir === choice.userDir && dir.valid) if (choice === CHOICES.BROWSE || suggestedDirChosen || suggestedFlowChosen) { @@ -284,12 +291,14 @@ async function flowImport (suggestedDirs) { } const selectedFlows = getFlowsFileDetails(importDetails.dir, importDetails.name) if (selectedFlows) { + console.info() + console.info('Importing flows:') + console.info(` ${figures.bullet} Flow file: ${chalk.cyan(selectedFlows.flowsFile)}`) const userDir = selectedFlows.userDir selectedFlows.credentials = {} // default to empty credentials selectedFlows.credentialSecret = null // default to null selectedFlows.packageFile = importDetails.packageFile if (selectedFlows.credsFile && existsSync(selectedFlows.credsFile)) { - print(`Importing credentials '${selectedFlows.credsFile}'...`) selectedFlows.credentials = await fsPromises.readFile(selectedFlows.credsFile, 'utf8') selectedFlows.credentials = JSON.parse(selectedFlows.credentials) // now, see if we can locate the credentialSecret for the creds file @@ -318,13 +327,17 @@ async function flowImport (suggestedDirs) { } if (!selectedFlows.credentialSecret) { - print('Could not determine the credentials secret. Flows will be imported without credentials.', chalk.yellow(figures.warning)) + // Not strictly true, but at this point, not much the user can do + console.info(` ${figures.bullet} No credentials file found`) selectedFlows.credentials = {} + } else { + console.info(` ${figures.bullet} Credentials file: ${chalk.cyan(selectedFlows.credsFile)}`) } } else { + console.info(` ${figures.bullet} No credentials file found`) selectedFlows.credentials = {} } - print(`Importing package '${selectedFlows.packageFile}'...`) + console.info(` ${figures.bullet} Package file: ${chalk.cyan(selectedFlows.packageFile)}`) selectedFlows.package = utils.getPackageData(selectedFlows.packageFile, { convertFileModulesToLatest: true }) return selectedFlows } diff --git a/package-lock.json b/package-lock.json index 4dca75cc..34a9cd1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@flowfuse/nr-theme": "^1.11.0", "@inquirer/confirm": "^5.1.9", + "@inquirer/input": "^5.1.2", "@inquirer/select": "^4.2.0", "bytes": "^3.1.2", "command-line-args": "^6.0.1", @@ -352,6 +353,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, "node_modules/@inquirer/confirm": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", @@ -373,18 +383,19 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.10.tgz", - "integrity": "sha512-roDaKeY1PYY0aCqhRmXihrHjoSW2A00pV3Ke5fTpMCkzcGF64R8e0lw3dK+eLEHwS4vB5RnW1wuQmvzoRul8Mw==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -398,6 +409,15 @@ } } }, + "node_modules/@inquirer/core/node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/core/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -442,24 +462,107 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", - "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@inquirer/select": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.0.tgz", - "integrity": "sha512-KkXQ4aSySWimpV4V/TUJWdB3tdfENZUU765GjOIZ0uPwdbGIG6jrxD4dDf1w68uP+DVtfNhr1A92B+0mbTZ8FA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.10", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -473,10 +576,20 @@ } } }, + "node_modules/@inquirer/select/node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/type": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", - "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -1481,31 +1594,6 @@ "node": ">=6" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3213,6 +3301,21 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-unique-numbers": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", @@ -3225,6 +3328,15 @@ "node": ">=16.1.0" } }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastfall": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", @@ -7239,9 +7351,9 @@ } }, "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "license": "MIT", "engines": { "node": ">=18" @@ -7446,6 +7558,11 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true }, + "@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==" + }, "@inquirer/confirm": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", @@ -7456,20 +7573,25 @@ } }, "@inquirer/core": { - "version": "10.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.10.tgz", - "integrity": "sha512-roDaKeY1PYY0aCqhRmXihrHjoSW2A00pV3Ke5fTpMCkzcGF64R8e0lw3dK+eLEHwS4vB5RnW1wuQmvzoRul8Mw==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "requires": { - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "yoctocolors-cjs": "^2.1.3" }, "dependencies": { + "@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==" + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -7504,26 +7626,74 @@ } }, "@inquirer/figures": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", - "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==" + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==" + }, + "@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "requires": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "dependencies": { + "@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "requires": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + } + }, + "@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==" + }, + "@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "requires": {} + }, + "mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==" + } + } }, "@inquirer/select": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.0.tgz", - "integrity": "sha512-KkXQ4aSySWimpV4V/TUJWdB3tdfENZUU765GjOIZ0uPwdbGIG6jrxD4dDf1w68uP+DVtfNhr1A92B+0mbTZ8FA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "requires": { - "@inquirer/core": "^10.1.10", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.6", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "dependencies": { + "@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==" + } } }, "@inquirer/type": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", - "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "requires": {} }, "@napi-rs/wasm-runtime": { @@ -8164,21 +8334,6 @@ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true }, - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "requires": { - "type-fest": "^0.21.3" - }, - "dependencies": { - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==" - } - } - }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -9331,6 +9486,19 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==" + }, + "fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "requires": { + "fast-string-truncated-width": "^3.0.2" + } + }, "fast-unique-numbers": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", @@ -9340,6 +9508,14 @@ "tslib": "^2.6.2" } }, + "fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "requires": { + "fast-string-width": "^3.0.2" + } + }, "fastfall": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", @@ -12082,9 +12258,9 @@ "dev": true }, "yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==" } } } diff --git a/package.json b/package.json index 2579c7ae..e2778945 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@flowfuse/nr-theme": "^1.11.0", "@inquirer/confirm": "^5.1.9", "@inquirer/select": "^4.2.0", + "@inquirer/input": "^5.1.2", "bytes": "^3.1.2", "command-line-args": "^6.0.1", "command-line-usage": "^7.0.3", From 6d86aa6b0d710eb1e59286792a8171289f1f6ee7 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 11:52:44 +0100 Subject: [PATCH 2/8] Add no-interative cli flag and fix up tests --- index.js | 19 +++--- lib/cli/args.js | 7 ++ test/unit/frontend/server.spec.js | 108 ++++++++++++++++++++++++++---- 3 files changed, 111 insertions(+), 23 deletions(-) diff --git a/index.js b/index.js index 4649774f..c6be7fb9 100755 --- a/index.js +++ b/index.js @@ -150,6 +150,9 @@ async function main (testOptions) { portMessage = `Port ${desiredPort} is not available. Use the --port option to select a different port to use.` } quit(portMessage, 2) + // In production quit() calls process.exit(); return here so test mode + // (where quit() does not halt) also stops rather than falling through. + return null } } catch (err) { // Error checking port availability; err on the side of caution and continue @@ -174,12 +177,12 @@ async function main (testOptions) { console.log() console.log(`Connecting to ${chalk.cyan(options.ffUrl)} with code ${chalk.cyan(options.otc)}`) handleOTCSetup(options) - } else if (!isValidDeviceConfig) { - // No valid config found - run the interactive setup flow + } else if (!isValidDeviceConfig && !options.ui && !options.noInteractive) { + // No valid config found, and ui option not set - run the interactive setup flow handleInteractiveRegistration(options) } else { // Valid config found - start the agent normally - start(options, configFound) + await start(options, configFound) if (TESTING) { return { AgentManager, @@ -520,11 +523,6 @@ async function main (testOptions) { // If the user has set otcNoStart, then we don't want to start the agent console.info() - if (!installerMode) { - console.log('The Device Agent can be launched at any time using the following command:') - console.log(` ${chalk.bold(runCommandInfo.join(' '))}`) - } - if (!options.otcNoStart) { console.clear() console.log('Starting Device Agent with new configuration') @@ -533,6 +531,10 @@ async function main (testOptions) { options.deviceFile = path.join(options.dir, 'device.yml') start(options, true) } else { + if (!installerMode) { + console.log('The Device Agent can be launched at any time using the following command:') + console.log(` ${chalk.bold(runCommandInfo.join(' '))}`) + } console.info() quit() } @@ -641,7 +643,6 @@ async function main (testOptions) { throw new Error('Registration not found') } // ignore errors and retry - console.log('poll error:', err.name, err.message, err.response?.statusCode) // DEBUG } // Wait for the interval, but wake early if the signal is aborted. // The throwIfAborted() at the top of the loop then handles the exit. diff --git a/lib/cli/args.js b/lib/cli/args.js index 75ee7e46..102b7181 100644 --- a/lib/cli/args.js +++ b/lib/cli/args.js @@ -151,5 +151,12 @@ module.exports = [ type: Boolean, alias: 'j', group: 'global' + }, + { + name: 'no-interactive', + description: 'disable interactive prompts', + type: Boolean, + defaultValue: false, + group: 'global' } ] diff --git a/test/unit/frontend/server.spec.js b/test/unit/frontend/server.spec.js index 5561dda2..680c1149 100644 --- a/test/unit/frontend/server.spec.js +++ b/test/unit/frontend/server.spec.js @@ -6,17 +6,66 @@ const path = require('path') const fs = require('fs/promises') const os = require('os') const http = require('http') +const net = require('net') const { AgentManager } = require('../../../lib/AgentManager') const { WebServer } = require('../../../frontend/server') const App = require('../../../index.js') +async function isPortAvailable (port, host) { + return new Promise((resolve, reject) => { + const client = new net.Socket() + function closeClient () { + try { + if (client) { + client.removeAllListeners('connect') + client.removeAllListeners('error') + client.end() + client.destroy() + client.unref() + } + } catch (err) { + // ignore + } + } + client.once('connect', () => { + // Managed to connect a socket; the port is in use + resolve(false) + closeClient() + }) + client.once('error', (err) => { + if (err.code === 'ECONNREFUSED') { + // Connection refused; the port is not in use + resolve(true) + } else { + // Something when wrong - reject + reject(err) + } + closeClient() + }) + client.connect({ port, host }, () => {}) + }) +} + describe('Device Agent Web Server', () => { /** @type {string} the config directory for the agent */ let configDir /** @type {App[]} */ const allApps = [] // used to track all apps so they can be cleaned up at the end of the test run + let NR_TEST_PORT = 1880 + beforeEach(async function () { + // The Device Agent checks if the NR port is available. By default this is 1880. + // If running on a dev machine with Node-RED running, the tests will fail unexpectedly as + // the default port is not available. + // So we will use a different port for the tests, and ensure that it is available before starting the app. + while (!await isPortAvailable(NR_TEST_PORT, '127.0.0.1')) { + NR_TEST_PORT++ + if (NR_TEST_PORT > 1900) { + throw new Error('No available port found in range 1881-1900 for testing') + } + } + // stub the console logging so that we don't get console output sinon.stub(console, 'log').callsFake((..._args) => {}) sinon.stub(console, 'info').callsFake((..._args) => {}) @@ -44,6 +93,8 @@ describe('Device Agent Web Server', () => { dummy: sinon.stub().returns('for ensuring the sandbox is used once') }) http.createServer().dummy() // ensure the sandbox is used once. + + process.env.TESTING_NO_INTERACTIVE = 'true' }) afterEach(async function () { await fs.rm(configDir, { recursive: true, force: true }) @@ -56,12 +107,13 @@ describe('Device Agent Web Server', () => { * @param {Array} args - an array of arrays, each containing a single or pair of CLI args * @returns App */ - function startApp (args, options = {}) { + async function startApp (args, options = {}) { process.argv = process.argv.slice(0, 2) for (const arg of args) { process.argv.push(...arg) } - const app = App.main(options) + process.argv.push('--port', NR_TEST_PORT.toString()) + const app = await App.main(options) allApps.push(app) return app } @@ -87,9 +139,10 @@ describe('Device Agent Web Server', () => { } it('by default, Web UI is not enabled', async () => { http.createServer.reset() - const app = startApp([ + const app = await startApp([ ['--dir', configDir], - ['--config', 'device-wont-exist.yml'] + ['--config', 'device-wont-exist.yml'], + ['--no-interactive'] ]) // check the CLI flag - should be false app.options.ui.should.be.false() @@ -105,9 +158,10 @@ describe('Device Agent Web Server', () => { const deviceYml = 'deviceId: abc123\ntoken: toktok\ncredentialSecret: A53CF37\nforgeURL:' await fs.writeFile(deviceFile, deviceYml) - const app = startApp([ + const app = await startApp([ ['--dir', configDir], - ['--config', 'device.yml'] + ['--config', 'device.yml'], + ['--no-interactive'] ], { onExit }) // check the CLI flag - should be false app.options.ui.should.be.false() @@ -116,17 +170,43 @@ describe('Device Agent Web Server', () => { }) it('quits if config is missing AND UI not enabled', async () => { const onExit = sinon.stub() - const app = startApp([ + const app = await startApp([ ['--dir', configDir], - ['--config', 'device-wont-exist.yml'] + ['--config', 'device-wont-exist.yml'], + ['--no-interactive'] ], { onExit }) // check the CLI flag - should be false app.options.ui.should.be.false() // ensure the app exited with an error onExit.calledOnceWith(sinon.match(/No config file found.*device-wont-exist.yml/s), 2).should.be.true() }) + it('quits if desired port is not available', async () => { + // Create a TCP listener on NR_TEST_PORT to simulate the port being in use. + // The port-availability check in index.js uses a real net.Socket to + // attempt a connection, so we need a real listener (http.createServer is + // stubbed, but net is not). + const listener = net.createServer() + await new Promise((resolve, reject) => { + listener.once('error', reject) + listener.listen(NR_TEST_PORT, '127.0.0.1', resolve) + }) + + try { + const onExit = sinon.stub() + await startApp([ + ['--dir', configDir], + ['--config', 'device-wont-exist.yml'], + ['--no-interactive'] + ], { onExit }) + // ensure the app exited with an error + onExit.calledOnceWith(sinon.match(/Port \d+ is not available/s), 2).should.be.true() + } finally { + // Ensure the TCP listener is closed after the test to free up the port + await new Promise((resolve) => listener.close(resolve)) + } + }) it('fails to run web server if user or pass is not specified', async () => { - const app = startApp([ + const app = await startApp([ ['--ui'], ['--ui-user', 'admin'] ]) @@ -151,7 +231,7 @@ describe('Device Agent Web Server', () => { app.webServer.listening.should.be.false() }) it('starts web server if a user and pass are specified', async () => { - const app = startApp([ + const app = await startApp([ ['--ui'], ['--ui-user', 'admin'], ['--ui-pass', 'admin'] @@ -166,7 +246,7 @@ describe('Device Agent Web Server', () => { }) it('omitted ui CLI options have correct defaults', async () => { - const app = startApp([]) + const app = await startApp([['--no-interactive']]) app.options.ui.should.be.false() app.options.uiPort.should.be.eql(1879) app.options.uiHost.should.be.eql('0.0.0.0') @@ -175,7 +255,7 @@ describe('Device Agent Web Server', () => { app.options.should.not.have.a.property('uiPass') }) it('ui CLI options are set correctly', async () => { - const app = startApp([ + const app = await startApp([ ['--ui'], ['--ui-port', '1234'], ['--ui-host', '127.0.0.1'], @@ -195,7 +275,7 @@ describe('Device Agent Web Server', () => { }) it('ui CLI rejects invalid ui-runtime value', async () => { const onExit = sinon.stub() - const app = startApp([ + const app = await startApp([ ['--ui'], ['--ui-user', 'admin'], ['--ui-pass', 'admin-pass'], @@ -212,7 +292,7 @@ describe('Device Agent Web Server', () => { it('server auto closes after runtime expires', async () => { // spy on the class methods WebServer.stop - need to know that it was called const wsStopSpy = sinon.spy(WebServer.prototype, 'stop') - const app = startApp([ + const app = await startApp([ ['--ui'], ['--ui-user', 'admin'], ['--ui-pass', 'admin-pass'], From d96e2800085275f0e26062ed13b7b0d2295d45b5 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 12:13:01 +0100 Subject: [PATCH 3/8] Backlevel inquirer and improve test coverage --- package-lock.json | 176 ++--------------------------- package.json | 2 +- test/unit/frontend/server.spec.js | 2 - test/unit/lib/AgentManager_spec.js | 80 +++++++++++++ 4 files changed, 93 insertions(+), 167 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34a9cd1a..00088610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@flowfuse/nr-theme": "^1.11.0", "@inquirer/confirm": "^5.1.9", - "@inquirer/input": "^5.1.2", + "@inquirer/input": "^4.2.0", "@inquirer/select": "^4.2.0", "bytes": "^3.1.2", "command-line-args": "^6.0.1", @@ -353,15 +353,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, "node_modules/@inquirer/confirm": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", @@ -471,68 +462,16 @@ } }, "node_modules/@inquirer/input": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", - "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/input/node_modules/@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/input/node_modules/@inquirer/figures": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", - "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input/node_modules/@inquirer/type": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", - "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + "node": ">=18" }, "peerDependencies": { "@types/node": ">=18" @@ -543,15 +482,6 @@ } } }, - "node_modules/@inquirer/input/node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@inquirer/select": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", @@ -3301,21 +3231,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, "node_modules/fast-unique-numbers": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", @@ -3328,15 +3243,6 @@ "node": ">=16.1.0" } }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, "node_modules/fastfall": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", @@ -7558,11 +7464,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true }, - "@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==" - }, "@inquirer/confirm": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", @@ -7631,44 +7532,12 @@ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==" }, "@inquirer/input": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", - "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "requires": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "dependencies": { - "@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", - "requires": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - } - }, - "@inquirer/figures": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", - "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==" - }, - "@inquirer/type": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", - "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", - "requires": {} - }, - "mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==" - } + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" } }, "@inquirer/select": { @@ -9486,19 +9355,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==" - }, - "fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "requires": { - "fast-string-truncated-width": "^3.0.2" - } - }, "fast-unique-numbers": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", @@ -9508,14 +9364,6 @@ "tslib": "^2.6.2" } }, - "fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "requires": { - "fast-string-width": "^3.0.2" - } - }, "fastfall": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", diff --git a/package.json b/package.json index e2778945..9a7818cb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@flowfuse/nr-theme": "^1.11.0", "@inquirer/confirm": "^5.1.9", "@inquirer/select": "^4.2.0", - "@inquirer/input": "^5.1.2", + "@inquirer/input": "^4.2.0", "bytes": "^3.1.2", "command-line-args": "^6.0.1", "command-line-usage": "^7.0.3", diff --git a/test/unit/frontend/server.spec.js b/test/unit/frontend/server.spec.js index 680c1149..c43554f5 100644 --- a/test/unit/frontend/server.spec.js +++ b/test/unit/frontend/server.spec.js @@ -93,8 +93,6 @@ describe('Device Agent Web Server', () => { dummy: sinon.stub().returns('for ensuring the sandbox is used once') }) http.createServer().dummy() // ensure the sandbox is used once. - - process.env.TESTING_NO_INTERACTIVE = 'true' }) afterEach(async function () { await fs.rm(configDir, { recursive: true, force: true }) diff --git a/test/unit/lib/AgentManager_spec.js b/test/unit/lib/AgentManager_spec.js index 68eb0669..33ee7f7b 100644 --- a/test/unit/lib/AgentManager_spec.js +++ b/test/unit/lib/AgentManager_spec.js @@ -280,6 +280,86 @@ my_data: AgentManager._provisionDevice.restore() } }) + it('quickConnectDevice throws a helpful error when the OTC is rejected (401)', async function () { + const deviceFile = path.join(configDir, 'project', 'device.yml') + // setup a web server to mock the FlowFuse server rejecting the OTC + let httpserver + try { + httpserver = require('http').createServer((req, res) => { + if (req.url === '/api/v1/devices/') { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end('{"error":"unauthorized"}') + } else { + res.writeHead(404) + res.end('{}') + } + }) + httpserver.listen(9753) + + // init the AgentManager in Quick Connect mode + const options = { + ffUrl: 'http://localhost:9753', + otc: 'one-time-code', + dir: configDir, + deviceFile + } + AgentManager.init(options) + sinon.spy(AgentManager, '_provisionDevice') + + // perform the Quick Connect - it should throw + await AgentManager.quickConnectDevice().should.be.rejectedWith(/One-Time Code is invalid or has already been used/) + + // the device should not have been provisioned + AgentManager._provisionDevice.called.should.be.false() + } catch (error) { + console.error(error) + throw error + } finally { + // cleanup + httpserver.close() + AgentManager._provisionDevice.restore() + } + }) + it('quickConnectDevice throws a provisioning error for other failures', async function () { + const deviceFile = path.join(configDir, 'project', 'device.yml') + // setup a web server to mock the FlowFuse server returning a server error + let httpserver + try { + httpserver = require('http').createServer((req, res) => { + if (req.url === '/api/v1/devices/') { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end('{"error":"boom"}') + } else { + res.writeHead(404) + res.end('{}') + } + }) + httpserver.listen(9753) + + // init the AgentManager in Quick Connect mode + const options = { + ffUrl: 'http://localhost:9753', + otc: 'one-time-code', + dir: configDir, + deviceFile + } + AgentManager.init(options) + sinon.spy(AgentManager, '_provisionDevice') + + // perform the Quick Connect - it should throw with the generic provisioning message + await AgentManager.quickConnectDevice().should.be.rejectedWith(/Problem encountered during provisioning/) + + // the device should not have been provisioned + AgentManager._provisionDevice.called.should.be.false() + } catch (error) { + console.error(error) + throw error + } finally { + // cleanup + httpserver.close() + AgentManager._provisionDevice.restore() + } + }) it('Agent Manager should call agent start (regular credentials)', async function () { this.skip() // TODO: fix this test const deviceFile = path.join(configDir, 'project', 'device.yml') From 2ecbaa497757904558ed54e302b29259bb6d9736 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 27 Jul 2026 12:17:08 +0100 Subject: [PATCH 4/8] Add timeout to port availability check --- index.js | 9 +++++++++ test/unit/frontend/server.spec.js | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/index.js b/index.js index c6be7fb9..a5126d80 100755 --- a/index.js +++ b/index.js @@ -755,6 +755,7 @@ async function main (testOptions) { if (client) { client.removeAllListeners('connect') client.removeAllListeners('error') + client.removeAllListeners('timeout') client.end() client.destroy() client.unref() @@ -763,6 +764,14 @@ async function main (testOptions) { // ignore } } + // Guard against the connection hanging (e.g. a firewalled port that + // neither accepts nor refuses). A timeout is indeterminate, so treat + // the port as available rather than blocking startup on a false positive. + client.setTimeout(5000) + client.once('timeout', () => { + resolve(true) + closeClient() + }) client.once('connect', () => { // Managed to connect a socket; the port is in use resolve(false) diff --git a/test/unit/frontend/server.spec.js b/test/unit/frontend/server.spec.js index c43554f5..17bb003c 100644 --- a/test/unit/frontend/server.spec.js +++ b/test/unit/frontend/server.spec.js @@ -19,6 +19,7 @@ async function isPortAvailable (port, host) { if (client) { client.removeAllListeners('connect') client.removeAllListeners('error') + client.removeAllListeners('timeout') client.end() client.destroy() client.unref() @@ -27,6 +28,12 @@ async function isPortAvailable (port, host) { // ignore } } + client.setTimeout(5000) + client.once('timeout', () => { + // Connection attempt timed out; treat the port as available + resolve(true) + closeClient() + }) client.once('connect', () => { // Managed to connect a socket; the port is in use resolve(false) From 8b74d2bbf69fab8cfa707170bc73f28fa682a2e2 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 29 Jul 2026 10:13:53 +0100 Subject: [PATCH 5/8] Do not show url until after attempting browser open --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index a5126d80..38dfceff 100755 --- a/index.js +++ b/index.js @@ -347,7 +347,7 @@ async function main (testOptions) { // 2. prompt user to open registerUrl (attempt to open ourselves) const fullRegisterUrl = platformURL + registerUrl.replace(/^\//, '') await confirm({ - message: chalk.bold('To continue with registering your new instance, press ENTER to open the the following URL in your browser:') + `\n\n ${chalk.bold(figures.triangleRightSmall)} ${chalk.cyan(fullRegisterUrl)}`, + message: chalk.bold('To continue with registering your new instance, press ENTER to open your browser'), theme: { prefix: '', style: { From 1cc3e152deb9904a111e3e42f2ace207fd79c7cd Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 29 Jul 2026 10:28:20 +0100 Subject: [PATCH 6/8] Fix up file import prompt if nothing found --- lib/cli/flowsImporter.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cli/flowsImporter.js b/lib/cli/flowsImporter.js index 4917f706..fde6fec0 100644 --- a/lib/cli/flowsImporter.js +++ b/lib/cli/flowsImporter.js @@ -96,9 +96,10 @@ async function askImport (suggestedDirs) { description: 'Press to browse the filesystem for Node-RED flows files' }) + const message = choices.length > 2 ? 'We have found existing Node-RED flows. Do you want to import these into your new instance?' : 'Do you want to import existing Node-RED flows into your new instance?' // Present the options to the user choice = await select({ - message: 'We have found an existing set of Node-RED flows. Do you want to import these into your new instance?', + message, choices, default: defaultChoice, pageSize: 10, From c7e29aa8eb513f4c23cdd1947d9a7d2f73b91760 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 29 Jul 2026 14:08:12 +0100 Subject: [PATCH 7/8] Insert "Press ENTER" pause to handle locked up TUI --- index.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/index.js b/index.js index 38dfceff..dc1f241d 100755 --- a/index.js +++ b/index.js @@ -518,6 +518,23 @@ async function main (testOptions) { console.log(`Successfully registered as ${chalk.cyan(deviceSettings.name)} ${chalk.gray('(' + deviceSettings.id + ')')} in Team ${chalk.cyan(deviceSettings.team.name)}`) if (!options.otcNoImport) { + // On some Windows terminals, a leftover console read from the previous prompt + // can still be pending in the background and silently absorb the first keystroke + // here (a known Windows/Node console quirk, not something we can fully prevent). + // Arrow keys on the flow-import prompt below never satisfy that leftover read (no + // line terminator), so it can look unresponsive; a plain ENTER always satisfies it. + // This gate gives that one "wasted" keystroke somewhere safe to land. + await confirm({ + message: chalk.bold('Press ENTER to continue...'), + theme: { + prefix: '', + style: { + defaultAnswer: (text, status) => { return ' ' }, + message: (text, status) => { return '\n' + text } + } + } + }, { clearPromptOnDone: true }) + await clearStdinBuffer() await handleFlowImport(options, deviceSettings) } // If the user has set otcNoStart, then we don't want to start the agent @@ -732,6 +749,9 @@ async function main (testOptions) { while (process.stdin.read() !== null) { // Consume from stdin until there's nothing left } + // resume() leaves the stream flowing; pause it again so the next prompt + // starts its own read from a clean, paused state. + process.stdin.pause() // Pause a bit then resolve to continue setTimeout(resolve, 25) } catch { From 6a5032e0d35a3123cbe5c116f9f0ca0df8f1105b Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 29 Jul 2026 14:29:14 +0100 Subject: [PATCH 8/8] Apply suggestion from @knolleary --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index dc1f241d..00031975 100755 --- a/index.js +++ b/index.js @@ -525,7 +525,7 @@ async function main (testOptions) { // line terminator), so it can look unresponsive; a plain ENTER always satisfies it. // This gate gives that one "wasted" keystroke somewhere safe to land. await confirm({ - message: chalk.bold('Press ENTER to continue...'), + message: chalk.bold('Press ENTER to continue'), theme: { prefix: '', style: {