From a61d26c41bd38b30ce50aa586cb7962f6f2a4151 Mon Sep 17 00:00:00 2001 From: danrahn Date: Sun, 28 Apr 2024 22:10:28 -0700 Subject: [PATCH] Allow forced CLI-based setup There may be scenarios where a user doesn't want to (or can't) configure Marker Editor on the machine running Marker Editor, in which case the default localhost binding will prevent them from accessing the app without manually editing config.json. To get around this, add a --cli-setup argument that can be passed in and force fallback to the "legacy" CLI-based setup. Also includes some tangential changes that improves the CLI experience. * Add --cli-setup option that forces CLI first-run setup. * Add 'rebuild' build.cjs option that forces a rebuild without completely wiping out previous build outputs, allowing for incremental builds in cases where node source has changed (e.g. due to new patches). * Modify node source to forward node options to the script instead of forcing ` -- ` to be passed first. Also inject custom CLI options so we don't bail out early if an "invalid" argument is provided. --- Build/build.cjs | 282 +++++++++++++++++++++++++-------------- Server/FirstRunConfig.js | 72 ++++++---- Server/MarkerEditor.js | 23 +++- 3 files changed, 242 insertions(+), 135 deletions(-) diff --git a/Build/build.cjs b/Build/build.cjs index 581a615..78bdb6a 100644 --- a/Build/build.cjs +++ b/Build/build.cjs @@ -8,6 +8,7 @@ const { rollup } = require('rollup'); const { exec } = require('child_process'); const semver = require('semver'); +/** @typedef {!import('nexe/lib/options').NexePatch} NexePatchFunction */ const ReadMeGenerator = require('./ReadMeGenerator.cjs'); /** @@ -49,6 +50,22 @@ const fallbackNodeVersion = '20.11.1'; // LTS as of 2024/03/07 const args = process.argv.map(a => a.toLowerCase()); const verbose = args.includes('verbose'); +const isWin = process.platform === 'win32'; + +/** +NOTE: This won't work on its own with the current version on nexe, which appears to always +force a release build. To make this work, the nodeSrcBinPath path calculation in compiler.js +was replaced with the following: + +``` +const isDebug = util_1.isWindows ? this.options.vcBuild.includes('debug') : this.options.configure.includes('--debug'); +const outFolder = isDebug ? 'Debug' : 'Release'; +this.nodeSrcBinPath = util_1.isWindows + ? (0, path_1.join)(this.src, outFolder, 'node.exe') + : (0, path_1.join)(this.src, 'out', outFolder, 'node'); +``` +*/ +const debug = args.includes('debug'); /** * Uses rollup to transpile app.js to common-js, as nexe can't consume es6 modules. */ @@ -99,41 +116,27 @@ function getArch() { } /** - * Takes rollup's cjs output and writes the exe. */ -async function toExe() { - let platform; - let output = `../dist/${binaryName}`; + * Get the current platform as a more user-friendly value. */ +function getPlatform() { switch (process.platform) { case 'win32': - platform = 'windows'; - output += '.exe'; - break; + return 'windows'; case 'linux': - platform = 'linux'; - break; + return 'linux'; case 'darwin': - platform = 'mac'; - break; + return 'mac'; default: throw new Error(`Unsupported build platform "${process.platform}", exiting...`); } +} - const arch = getArch(); - - let nodeVersion = fallbackNodeVersion; - - // nexe doesn't appear to take into account that the currently cached build output is a different - // target architecture. To get around that, ignore the standard 'out' folder and check for - // architecture-specific output folders. If it doesn't exist, do a full build and rename the - // output to an architecture-specific folder, and link that to the standard 'out' folder. This - // relies on internal nexe behavior, but since it's dev-only, nothing user-facing should break if - // nexe changes, this section will just have to be updated. - const temp = process.env.NEXE_TEMP || join(homedir(), '.nexe'); - +/** + * Get the version of Node we should use when building Marker Editor. */ +async function getNodeVersion() { if (args.includes('version')) { const idx = args.indexOf('version'); if (idx < args.length - 1) { - nodeVersion = args[idx + 1]; + return args[idx + 1]; } } else { // Find the latest LTS version @@ -142,41 +145,166 @@ async function toExe() { const versions = await (await fetch('https://nodejs.org/download/release/index.json')).json(); versions.sort((a, b) => (!!a.lts === !!b.lts) ? (semver.lt(a.version, b.version) ? 1 : -1) : (a.lts ? -1 : 1)); - nodeVersion = versions[0].version.substring(1); + const nodeVersion = versions[0].version.substring(1); console.log(`Found latest LTS: ${nodeVersion}`); + return nodeVersion; } catch (ex) { console.warn(`Unable to find latest LTS version of Node.js, falling back to ${fallbackNodeVersion}`); } } - const oldOut = join(temp, nodeVersion, 'out'); + // Something went wrong. + return fallbackNodeVersion; +} - if (args.includes('clean')) { - const tryRm = out => { - try { - fs.rmSync(out, { recursive : true, force : true }); - } catch (ex) { - console.warn(`\tUnable to clear output ${out}`); - } - }; +/** + * Clears out all old output directories to allow for a full rebuild. + * @param {string} oldOut */ +function cleanBuild(oldOut) { + const tryRm = out => { + try { + fs.rmSync(out, { recursive : true, force : true }); + } catch (ex) { + console.warn(`\tUnable to clear output ${out}`); + } + }; - console.log('\nCleaning existing cached output'); - if (fs.existsSync(oldOut)) { - console.log('\tClearing old output directory'); - tryRm(oldOut); + console.log('\nCleaning existing cached output'); + if (fs.existsSync(oldOut)) { + console.log('\tClearing old output directory'); + tryRm(oldOut); + } + + for (const cachedOut of ['arm64', 'ia32', 'x64', 'arm64d', 'ia32d', 'x64d']) { + if (fs.existsSync(oldOut + cachedOut)) { + console.log(`\tClearing out ${cachedOut} cache`); + tryRm(oldOut + cachedOut); } + } +} - for (const cachedOut of ['arm64', 'ia32', 'x64']) { - if (fs.existsSync(oldOut + cachedOut)) { - console.log(`\tClearing out ${cachedOut} cache`); - tryRm(oldOut + cachedOut); - } +/** + * Delete the existing build node binary if 'rebuild' was passed to this script. + * @type {NexePatchFunction} */ +function deleteNodeBinaryIfRebuilding(compiler, next) { + if (!args.includes('rebuild')) { + return next(); + } + + // If we don't delete the node binary, nexe won't rebuild anything even if + // the source has changed (e.g. due to new patches). + if (verbose) console.log(`Attempting to delete node binary due to 'rebuild' parameter`); + const binaryPath = compiler.getNodeExecutableLocation(); + if (fs.existsSync(binaryPath)) { + fs.unlinkSync(binaryPath); + console.log(`\nDeleted "${binaryPath}" due to rebuild parameter.`); + } + + return next(); +} + +/** + * Inject custom CLI parsing logic to node source. + * @type {NexePatchFunction} */ +async function injectCliOptions(compiler, next) { + // Add marker editor version to NODE_VERSION + await compiler.replaceInFileAsync( + 'src/node.cc', + /\bNODE_VERSION\b/, + `"Marker Editor: v${version}\\n` + + `Node: " NODE_VERSION` + ); + + // Custom command line arguments. Otherwise Node will exit early + // if these arguments are provided as node options. + await compiler.replaceInFileAsync( + 'src/node_options.h', + ' bool print_version = false;', + ' bool print_version = false;\n' + + ' bool cli_setup = false;' + ); + await compiler.replaceInFileAsync( + 'src/node_options.cc', + ' AddAlias("-v", "--version");', + ' AddAlias("-v", "--version");\n' + + ' AddOption("--cli-setup",\n' + + ' "Use CLI setup for Marker Editor",\n' + + ' &PerProcessOptions::cli_setup);' + ); + + // Since we're really just running a slightly modified version of node, we have to + // use `--` to pass arguments to the actual program. Since we only ever want to run + // MarkerEditor though, forward node args to the script. + const hackyArgv = ` auto& argv = const_cast&>(env->argv());\n`; + const forwardArg = (variable, arg) => + ` if (per_process::cli_options->${variable}) {\n` + + ` argv.push_back("${arg}");\n` + + ` }\n\n`; + + await compiler.replaceInFileAsync( + 'src/node.cc', + 'return StartExecution(env, "internal/main/run_main_module"); }', + hackyArgv + + forwardArg('print_help', '--help') + + forwardArg('cli_setup', '--cli-setup') + + forwardArg('print_version', '--version') + + ` return StartExecution(env, "internal/main/run_main_module");\n }`); + + return next(); +} + +/** + * On Windows, use RCEdit to add the version and icon to the binary. + * @type {NexePatchFunction} */ +async function editWinResources(compiler, next) { + if (!isWin) { + return next(); + } + + const binaryPath = compiler.getNodeExecutableLocation(); + try { + // RC overrides are only applied if we're doing a clean build, + // hack around it by using rcedit on the binary to ensure they're added. + if (fs.statSync(binaryPath).size > 0) { + await rcedit(binaryPath, { + 'version-string' : rc, + 'file-version' : rcVersion, + 'product-version' : rcVersion, + icon : iconPath, + }); } + } catch { + console.log('\nUnable to modify exe resources with rcedit. This is expected if we\'re doing a clean build'); + } + + return next(); +} + +/** + * Takes rollup's cjs output and writes the exe. */ +async function toExe() { + const platform = getPlatform(); + const output = `../dist/${binaryName}` + (isWin ? '.exe' : ''); + const arch = getArch(); + const nodeVersion = await getNodeVersion(); + + // nexe doesn't appear to take into account that the currently cached build output is a different + // target architecture. To get around that, ignore the standard 'out' folder and check for + // architecture-specific output folders. If it doesn't exist, do a full build and rename the + // output to an architecture-specific folder, and link that to the standard 'out' folder. This + // relies on internal nexe behavior, but since it's dev-only, nothing user-facing should break if + // nexe changes, this section will just have to be updated. + const temp = process.env.NEXE_TEMP || join(homedir(), '.nexe'); + + const oldOut = join(temp, nodeVersion, 'out'); + + if (args.includes('clean')) { + cleanBuild(); } console.log(`Attempting to build ${platform}-${arch}-${nodeVersion}`); - const archOut = oldOut + arch; + const archOut = oldOut + arch + (debug ? 'd' : ''); const hadCache = fs.existsSync(archOut); if (hadCache) { console.log(`Found cached output for ${arch}-${nodeVersion}, using that.`); @@ -198,6 +326,9 @@ async function toExe() { input : resolve(__dirname, '../dist/built.cjs'), output : resolve(__dirname, output), build : true, + configure : (isWin || !debug) ? [] : ['--debug'], // non-Win + vcBuild : isWin ? ['nosign', debug ? 'debug' : 'release'] : [], // Win-only + make : ['-j4'], // 4 concurrent jobs targets : [ `${platform}-${arch}-${nodeVersion}` ], loglevel : verbose ? 'verbose' : 'info', ico : iconPath, @@ -215,62 +346,9 @@ async function toExe() { resolve(__dirname, '../dist/built.cjs'), ], patches : [ - async (compiler, next) => { - const isWin = process.platform === 'win32'; - const bin = isWin ? '.\\\\MarkerEditor.exe' : './MarkerEditor'; - await compiler.replaceInFileAsync( - 'src/node.cc', - /\bNODE_VERSION\b/, - `"Marker Editor: v${version}\\n` + - `Node.js: " NODE_VERSION ` + - `"\\n\\nUse '--' to pass arguments directly to Marker Editor (e.g. ${bin} -- -v)\\n"` - ); - - // nexe short-circuits StartExecution, skipping the print_help check, but we want that. - await compiler.replaceInFileAsync( - 'src/node.cc', - 'return StartExecution(env, "internal/main/run_main_module"); }', - ` if (per_process::cli_options->print_help) { - return StartExecution(env, "internal/main/print_help"); - } - - return StartExecution(env, "internal/main/run_main_module"); - }` - ); - - await compiler.replaceInFileAsync( - 'lib/internal/main/print_help.js', - ` 'Usage: node`, - ` 'NOTE: Printing Node help. Use \\'--\\' to pass arguments directly to Marker Editor\\n' +\n` + - ` ' e.g. ${bin} -- --help\\n\\n' +\n` + - ` 'Usage: node` - ); - - return next(); - }, - async (compiler, next) => { - if (process.platform !== 'win32') { - return next(); - } - - const binaryPath = compiler.getNodeExecutableLocation(); - try { - // RC overrides are only applied if we're doing a clean build, - // hack around it by using rcedit on the binary to ensure they're added. - if (fs.statSync(binaryPath).size > 0) { - await rcedit(binaryPath, { - 'version-string' : rc, - 'file-version' : rcVersion, - 'product-version' : rcVersion, - icon : iconPath, - }); - } - } catch { - console.log('\nUnable to modify exe resources with rcedit. This is expected if we\'re doing a clean build'); - } - - return next(); - } + deleteNodeBinaryIfRebuilding, + injectCliOptions, + editWinResources, ] }); // Don't catch, interrupt on failure. @@ -411,7 +489,7 @@ async function build() { msg('Writing README'); writeReadme(); - if (process.platform !== 'win32' && process.platform !== 'darwin') { + if (!isWin && process.platform !== 'darwin') { msg(`Writing Linux Start script`); writeStartSh(); } @@ -424,7 +502,7 @@ async function build() { const zipName = `${binaryName}.v${version}-${process.platform}-${arch}`; let cmd; /* eslint-disable max-len */ - if (process.platform === 'win32') { + if (isWin) { cmd = `powershell Compress-Archive "${dist}/node_modules", "${dist}/README.txt", "${dist}/${binaryName}.exe" "${dist}/${zipName}.zip" -Force`; } else if (process.platform === 'darwin') { cmd = `tar -C '${dist}' -czvf '${dist}/${zipName}.tar.gz' node_modules README.txt '${binaryName}'`; diff --git a/Server/FirstRunConfig.js b/Server/FirstRunConfig.js index 19149e7..c8e4181 100644 --- a/Server/FirstRunConfig.js +++ b/Server/FirstRunConfig.js @@ -17,42 +17,60 @@ const Log = new ContextualLog('FirstRun'); /** * Checks whether config.json exists. If it doesn't, asks the user * to go through first-run config setup. - * @param {string} dataRoot */ -async function FirstRunConfig(dataRoot) { + * @param {string} dataRoot + * @param {boolean} forceCli */ +async function FirstRunConfig(dataRoot, forceCli) { const configPath = join(dataRoot, 'config.json'); - if (existsSync(configPath)) { - Log.verbose('config.json exists, skipping first run config.'); - return; - } - - // The following has mostly been replaced with the new client-side configuration, - // but the once time we'll still use this is if our default host:port is already in use, - // so use the CLI to get values instead of trying to find an open port. - const serverTest = await testHostPort('localhost', 3232); - if (serverTest.valid) { - return; - } - - console.log(); - console.log(`[WARN]: Default host and port already in use, falling back to command line setup.`); - console.log(); + const configExists = existsSync(configPath); const rl = createReadlineInterface({ input : process.stdin, output : process.stdout }); - console.log(); - if (!await askUserYesNo('Welcome to Marker Editor for Plex! It looks like this is your first run, as config.json\n' + - 'could not be found. Would you like to go through the first-time setup', true, rl)) { - if (await askUserYesNo('Would you like to skip this check in the future', false, rl)) { - writeFileSync(configPath, '{}\n'); - console.log('Wrote default configuration file to avoid subsequent checks.'); + + if (configExists) { + if (forceCli) { + console.log('Welcome to Marker Editor for Plex!'); + console.log('The editor was launched with --cli-setup, but a config file was already found.'); + if (await askUserYesNo(`Do you want to exit configuration and start the app (Y), or continue with the \n` + + `configuration (N), overwriting your current config`, true, rl)) { + return; + } } else { - Log.warn('Not going through first-time setup, attempting to use defaults for everything.'); + Log.verbose('config.json exists, skipping first run config.'); + return; } + } - rl.close(); - return; + if (!forceCli) { + // The following has mostly been replaced with the new client-side configuration, + // but the once time we'll still use this is if our default host:port is already in use, + // so use the CLI to get values instead of trying to find an open port. + const serverTest = await testHostPort('localhost', 3232); + if (serverTest.valid) { + return; + } + + console.log(); + console.log(`[WARN]: Default host and port already in use, falling back to command line setup.`); + console.log(); + } + + console.log(); + + if (!configExists) { + if (!await askUserYesNo('Welcome to Marker Editor for Plex! It looks like this is your first run, as config.json\n' + + 'could not be found. Would you like to go through the first-time setup', true, rl)) { + if (await askUserYesNo('Would you like to skip this check in the future', false, rl)) { + writeFileSync(configPath, '{}\n'); + console.log('Wrote default configuration file to avoid subsequent checks.'); + } else { + Log.warn('Not going through first-time setup, attempting to use defaults for everything.'); + } + + rl.close(); + return; + } } console.log(); diff --git a/Server/MarkerEditor.js b/Server/MarkerEditor.js index 92e18c2..2d2357b 100644 --- a/Server/MarkerEditor.js +++ b/Server/MarkerEditor.js @@ -34,6 +34,7 @@ import { ThumbnailManager } from './ThumbnailManager.js'; * @property {string?} configOverride The path to a config file to override the existing one * @property {boolean} version The user passed `-v`/`--version` to the command line * @property {boolean} help The user passed `-h`/`--help`/`--?` to the command line + * @property {boolean} cliSetup The user wants to set up Marker Editor using the command line, not a browser. */ const Log = new ContextualLog('ServerCore'); @@ -57,7 +58,7 @@ async function run() { // In docker, the location of the config and backup data files are not the project root. const dataRoot = process.env.IS_DOCKER ? '/Data' : ProjectRoot(); if (!argInfo.isTest) { - await FirstRunConfig(dataRoot); + await FirstRunConfig(dataRoot, argInfo.cliSetup); } // If we don't have a config file, still launch the server using some default values. @@ -460,23 +461,32 @@ function handlePost(req, res) { * Parse command line arguments. * @returns {CLIArguments} */ function checkArgs() { - Log.info(`MarkerEditor - Command Line Arguments: ${process.argv.join(', ')}`); + /** @type {CLIArguments} */ const argInfo = { isTest : false, configOverride : null, version : false, help : false, + cliSetup : false, }; - const argsLower = process.argv.map(x => x.toLowerCase()); + const argsLower = process.argv.map(x => x.replace(/_/g, '-').toLowerCase()); if (argsLower.includes('-v') || argsLower.includes('--version')) { argInfo.version = true; } - if (argsLower.includes('-h') || argsLower.includes('--help') || argsLower.includes('-?') || argsLower.includes('/?')) { + if (argsLower.includes('-h') + || argsLower.includes('/h') + || argsLower.includes('--help') + || argsLower.includes('-?') + || argsLower.includes('/?')) { argInfo.help = true; } + if (argsLower.includes('--cli-setup')) { + argInfo.cliSetup = true; + } + if (argsLower.includes('--test')) { argInfo.isTest = true; IsTest = true; @@ -485,7 +495,7 @@ function checkArgs() { argInfo.configOverride = 'testConfig.json'; } - const coi = argsLower.indexOf('--config_override'); + const coi = argsLower.indexOf('--config-override'); if (coi !== -1) { if (process.argv.length <= coi - 1) { Log.critical('Invalid config override file detected, aborting...'); @@ -532,7 +542,8 @@ function shouldExitEarly(args) { console.log(` OPTIONS`); console.log(` -v | --version Print out the current version of MarkerEditor.`); console.log(` -h | --help Print out this help text.`); - console.log(` --config_override [config] Use the given config file instead of the standard config.json`); + console.log(` --cli-setup Set up Marker Editor using the command line instead of a browser.`); + console.log(` --config-override [config] Use the given config file instead of the standard config.json`); console.log(` --test Indicates we're launching MarkerEditor for tests. Do not set manually.`); console.log('\n For setup and usage instructions, visit https://github.com/danrahn/MarkerEditorForPlex/wiki.'); return true;