diff --git a/CHANGELOG.md b/CHANGELOG.md index 8459d32ef6..e927ecfc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## Unreleased + + +### Bug Fixes + +* fix `util._extend` deprecation warning when running `heroku local` ([#3742](https://github.com/heroku/cli/pull/3742)) + + +### Code Refactoring + +* vendor node-foreman, used by `heroku local`, into the CLI as an internal dependency ([#3742](https://github.com/heroku/cli/pull/3742)) + ## [11.4.0](https://github.com/heroku/cli/compare/v11.3.0...v11.4.0) (2026-05-13) diff --git a/cspell.json b/cspell.json index bdd7705a41..69c0ea4721 100644 --- a/cspell.json +++ b/cspell.json @@ -14,6 +14,7 @@ "**/package-lock.json", "**/node_modules/**", "**/coverage/**", - "**/fixtures/**" + "**/fixtures/**", + "src/lib/local/foreman/**" ] } diff --git a/eslint.config.js b/eslint.config.js index 27173bfa9a..459500c36a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,6 @@ export default [ }, // Ignore patterns (in addition to shared ignores) { - ignores: ['**/test/**/*.js', '**/*.d.ts', '.github/**'], + ignores: ['**/test/**/*.js', '**/*.d.ts', '.github/**', 'src/lib/local/foreman/**'], }, ] diff --git a/package-lock.json b/package-lock.json index 58817e70db..5ff8bab91d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,6 @@ "eventsource": "^4", "execa": "^9.6.1", "filesize": "^10.1", - "foreman": "^3.0.1", "fs-extra": "^11.3.0", "glob": "^13.0.2", "got": "^13.0.0", @@ -13463,12 +13462,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -13989,26 +13982,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -14042,24 +14015,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreman": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/foreman/-/foreman-3.0.1.tgz", - "integrity": "sha512-ek/qoM0vVKpxzkBUQN9k4Fs7l0XsHv4bqxuEW6oqIS4s0ouYKsQ19YjBzUJKTFRumFiSpUv7jySkrI6lfbhjlw==", - "license": "MIT", - "dependencies": { - "commander": "^2.15.1", - "http-proxy": "^1.17.0", - "mustache": "^2.2.1", - "shell-quote": "^1.6.1" - }, - "bin": { - "nf": "nf.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -15010,20 +14965,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -17485,18 +17426,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mustache": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", - "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==", - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - }, - "engines": { - "npm": ">=1.4.0" - } - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -21423,12 +21352,6 @@ "node": ">=8.6.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", diff --git a/package.json b/package.json index 865738eb58..5075dc76b5 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "eventsource": "^4", "execa": "^9.6.1", "filesize": "^10.1", - "foreman": "^3.0.1", + "fs-extra": "^11.3.0", "glob": "^13.0.2", "got": "^13.0.0", diff --git a/src/lib/local/foreman/colors.cjs b/src/lib/local/foreman/colors.cjs new file mode 100644 index 0000000000..0c3af07558 --- /dev/null +++ b/src/lib/local/foreman/colors.cjs @@ -0,0 +1,45 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var reset = '\x1B[0m'; +var colors = { + magenta: '\x1B[35m', + blue: '\x1B[34m', + cyan: '\x1B[36m', + green: '\x1B[32m', + yellow: '\x1B[33m', + red: '\x1B[31m', + bright_magenta: '\x1B[35m', + bright_cyan: '\x1B[36m', + bright_blue: '\x1B[34m', + bright_green: '\x1B[32m', + bright_yellow: '\x1B[33m', + bright_red: '\x1B[31m', +}; + +function identity(self) { + return self; +} + +function colorizer(color) { + if (process.stdout.isTTY) { + return function (str) { + return colors[color] + str + reset; + }; + } else { + return identity; + } +} + +module.exports.colors = []; + +var colorKeys = Object.keys(colors); +colorKeys.forEach(function(name) { + var colorFn = colorizer(name); + module.exports[name] = colorFn; + module.exports.colors.push(colorFn); +}); + +module.exports.colors_max = module.exports.colors.length; diff --git a/src/lib/local/foreman/console.cjs b/src/lib/local/foreman/console.cjs new file mode 100644 index 0000000000..bb543e306b --- /dev/null +++ b/src/lib/local/foreman/console.cjs @@ -0,0 +1,123 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var util = require('util'); +var colors = require('./colors.cjs'); + +function wrap(log, length, res) { + if(!res) { res = []; } + if(log.length <= length) { + res.push(log); + return res; + } else { + res.push(log.substr(0, length)); + return wrap(log.substr(length), length, res); + } +} + +var trimEnd = '\u2026'; +var ansiEscapes = /\x1b\[(\d+([A-GJKSTm]|;\d+[Hf])|6n|s|u|\?25[lh])/g; + +function stripANSI(str) { + return str.replace(ansiEscapes, ''); +} + +function trim(line, n) { + line = line.replace(/\s+$/, ''); + var stripped = stripANSI(line); + if (stripped.length <= n) { + return line; + } else { + return stripped.substr(0, n) + trimEnd; + } +} + +function Console(logger) { + logger = logger || console; + this.padding = 25; + + this.trimline = 10; + this.wrapline = 500; + + this.fmt = function fmt() { + return util.format.apply(null, arguments); + }; + + this.pad = function pad(string, n) { + var l = string.length; + var o = string; + for(var i = l; i < n; i++) { + o += " "; + } + return o; + }; + + this.trim = trim; + + this.info = function info(key, proc, string) { + var stamp = (new Date().toLocaleTimeString()) + " " + key; + logger.log(proc.color(this.pad(stamp,this.padding)), colors.cyan(string)); + }; + + this.error = function error(key, proc, string) { + var stamp = (new Date().toLocaleTimeString()) + " " + key; + logger.error(proc.color(this.pad(stamp,this.padding)), colors.red(string)); + }; + + this.log = function log(key, proc, string) { + var self = this; + + if(self.raw) { + logger.log(string); + return; + } + + string.split(/\n/).forEach(function(line) { + + if (line.trim().length === 0) { return; } + + var stamp = (new Date().toLocaleTimeString()) + " " + key; + + if(self.trimline>0){ + line = self.trim(line,self.trimline); + } + + var delimiter = " | "; + + var wrapline; + if(self.wrapline === 0) { + wrapline = line.length; + } else { + wrapline = self.wrapline; + } + + wrap(line, wrapline).forEach(function(l) { + logger.log(proc.color(self.pad(stamp,self.padding) + delimiter), l); + delimiter = " | > "; + }); + + }); + }; + + this.Alert = function Alert() { + logger.log(colors.green('[OKAY] '+ this.fmt.apply(null, arguments))); + }; + + this.Done = function Info() { + logger.log(colors.cyan('[DONE] ' + this.fmt.apply(null, arguments))); + }; + + this.Warn = function Warn() { + logger.warn(colors.yellow('[WARN] ' + this.fmt.apply(null, arguments))); + }; + + this.Error = function Error() { + logger.error(colors.bright_red('[FAIL] ' + this.fmt.apply(null,arguments))); + }; + +} + +module.exports = Console; +Console.Console = new Console(); diff --git a/src/lib/local/foreman/envs.cjs b/src/lib/local/foreman/envs.cjs new file mode 100644 index 0000000000..625e99e85d --- /dev/null +++ b/src/lib/local/foreman/envs.cjs @@ -0,0 +1,116 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var fs = require('fs'); +var cons = require('./console.cjs').Console; + +function method(name) { + return function(o) { + return o[name].apply(o); + }; +} + +// Parse a Key=Value File Containing Environmental Variables +function keyValue(data) { + var env = {}; + + data + .toString() + .replace(/^\s*\#.*$/gm,'') + .replace(/^\s*$/gm,'') + .split(/\n/) + .map(method('trim')) + .filter(notBlank) + .forEach(capturePair); + + return env; + + function notBlank(str) { + return str.length > 2; + } + + function capturePair(line) { + var pair = line.split('='); + var key = pair[0].trim(); + var rawVal = pair.slice(1).join('=').trim(); + env[key] = parseValue(rawVal); + } + + function parseValue(val) { + switch (val[0]) { + case '"': return /^"([^"]*)"/.exec(val)[1]; + case "'": return /^'([^']*)'/.exec(val)[1]; + default : return val.replace(/\s*\#.*$/, ''); + } + } +} + +// Flatten nested object structure into KEY_SUBKEY=value pairs +function flattenJSON(json) { + var flattened = {}; + + walk(json, function(path, item) { + flattened[path.join('_').toUpperCase()] = item; + }); + + return flattened; + + function walk(obj, visitor, path) { + var item; + path = path || []; + for (var key in obj) { + item = obj[key]; + if (typeof item === 'object') { + walk(item, visitor, path.concat(key)); + } else { + visitor(path.concat(key), item); + } + } + } +} + +function dumpEnv(conf) { + var output = []; + for (var key in conf) { + output.push(key + '=' + conf[key]); + } + return output.sort().join('\n') + '\n'; +} + +function loadEnvsFile(path) { + var env, data; + + if(!fs.existsSync(path)) { + env = {}; + } else { + data = fs.readFileSync(path); + try { + var envs_json = JSON.parse(data); + env = flattenJSON(envs_json, "", {}); + cons.Alert("Loaded ENV %s File as JSON Format", path); + } catch (e) { + env = keyValue(data); + cons.Alert("Loaded ENV %s File as KEY=VALUE Format", path); + } + } + env.PATH = env.PATH || process.env.PATH; + return env; +} + +function loadEnvs(path) { + var envs = path.split(',').map(loadEnvsFile).reduce(function(acc, obj) { + return Object.assign(acc, obj); + }, {}); + var sorted = Object.create(null); + Object.keys(envs).sort().forEach(function(k) { + sorted[k] = envs[k]; + }); + return sorted; +} + +module.exports.loadEnvs = loadEnvs; +module.exports.flattenJSON = flattenJSON; +module.exports.keyValue = keyValue; +module.exports.dumpEnv = dumpEnv; diff --git a/src/lib/local/foreman/forward.cjs b/src/lib/local/foreman/forward.cjs new file mode 100644 index 0000000000..aef05183a6 --- /dev/null +++ b/src/lib/local/foreman/forward.cjs @@ -0,0 +1,29 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var prog = require('child_process'); + +var cons = require('./console.cjs').Console; + +function startForward(port, hostname, emitter) { + var proc = prog.fork(__dirname + '/../forward.js', [], { + env: { + PROXY_PORT: port, + PROXY_HOST: hostname || ' ' + } + }); + cons.Alert('Forward Proxy Started in Port %d', port); + if(hostname) { + cons.Alert('Intercepting requests to %s through forward proxy', hostname); + } else { + cons.Alert('Intercepting ALL requests through forward proxy'); + } + emitter.once('killall', function(signal) { + cons.Done('Killing Forward Proxy Server on Port %d',port); + proc.kill(signal); + }); +} + +module.exports.startForward = startForward; diff --git a/src/lib/local/foreman/proc.cjs b/src/lib/local/foreman/proc.cjs new file mode 100644 index 0000000000..0da3f100db --- /dev/null +++ b/src/lib/local/foreman/proc.cjs @@ -0,0 +1,141 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var prog = require('child_process'); + +var cons = require('./console.cjs').Console; + +var _colors = require('./colors.cjs'); +var colors_max = _colors.colors_max; +var colors = _colors.colors; + +var os = require('os'); +var platform = os.platform(); + +function run(key, proc, emitter) { + var file, args; + if (platform === 'win32') { + file = process.env.comspec || 'cmd.exe'; + args = ['/s', '/c', proc.command]; + } else { + file = '/bin/sh'; + args = ['-c', proc.command]; + } + var child = prog.spawn(file, args, { env: proc.env }); + var killallReceived = false; + + child.stdout.on('data', function(data) { + cons.log(key, proc, data.toString()); + }); + + child.stderr.on('data', function(data) { + cons.log(key, proc, data.toString()); + }); + + child.on('close', function(code, signal) { + if(code === 0) { + cons.info(key, proc, "Exited Successfully"); + } else { + cons.error(key, proc, "Exited with exit code " + signal || code); + } + }); + + child.on('exit', function(code, signal) { + if (!killallReceived) { + emitter.emit('killall', signal || 'SIGINT'); + } + }); + + emitter.on('killall', function(signal) { + killallReceived = true; + + try { + child.kill(signal); + } + catch (err) { + if (err.code === 'EPERM') { + cons.error(key, proc, "Process has become unkillable; returns EPERM."); + } + } + }); + +} + +function once(input, envs, callback) { + var file, args; + var proc = { + command : input, + env : merge(merge({}, process.env), envs) + }; + + if (platform === 'win32') { + file = process.env.comspec || 'cmd.exe'; + args = ['/s', '/c', proc.command]; + } else { + file = '/bin/sh'; + args = ['-c', proc.command]; + } + + var child = prog.spawn(file, args, { env: proc.env, stdio: 'inherit' }); + + child.on('close', function(code) { + callback(code); + }); +} + +function start(procs, requirements, envs, portarg, emitter){ + + var j = 0; + var k = 0; + var port = parseInt(portarg); + + if(port < 1024) { + return cons.Error('Only Proxies Can Bind to Privileged Ports - '+ + 'Try \'sudo nf start -x %s\'', port); + } + + for(var key in requirements) { + var n = parseInt(requirements[key]); + + for(var i = 0; i < n; i++) { + + var color_val = (j + k) % colors_max; + + if (!procs[key]) { + cons.Warn("Required Key '%s' Does Not Exist in Procfile Definition", key); + continue; + } + + var p = { + command : procs[key], + color : colors[color_val], + env : merge(merge({}, process.env), envs) + }; + + p.env.PORT = port + j + k * 100; + p.env.FOREMAN_WORKER_NAME = p.env.FOREMAN_WORKER_NAME || key + "." + (i + 1); + + run(key + "." + (i + 1), p, emitter); + + j++; + + } + j = 0; + k++; + } +} + +function merge(a, b) { + if (a && b) { + for (var key in b) { + a[key] = b[key]; + } + } + return a; +} + +module.exports.start = start; +module.exports.run = run; +module.exports.once = once; diff --git a/src/lib/local/foreman/procfile.cjs b/src/lib/local/foreman/procfile.cjs new file mode 100644 index 0000000000..1a5871c62e --- /dev/null +++ b/src/lib/local/foreman/procfile.cjs @@ -0,0 +1,55 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var fs = require('fs'); +var cons = require('./console.cjs').Console; +var path = require('path'); + +function procs(procdata){ + + var processes = {}; + + procdata.toString().split(/\n/).forEach(function(line) { + if(!line || line[0] === '#') { return; } + + var tuple = /^([A-Za-z0-9_-]+):\s*(.+)$/m.exec(line); + + var prockey = tuple[1].trim(); + var command = tuple[2].trim(); + + if(!prockey) { + throw new Error('Syntax Error in Procfile, Line %d: No Prockey Found'); + } + + if(!command) { + throw new Error('Syntax Error in Procfile, Line %d: No Command Found'); + } + + processes[prockey] = command; + }); + + return processes; +} + +function loadProc(filename) { + + try { + var data = fs.readFileSync(filename); + return procs(data); + } catch(e) { + cons.Warn(e.message); + if(fs.existsSync('package.json')) { + cons.Alert("package.json file found - trying 'npm start'"); + return procs("web: npm start"); + } else { + cons.Error("No Procfile and no package.json file found in Current Directory - See " + path.basename(process.argv[1]) + " --help"); + return; + } + } + +} + +module.exports.loadProc = loadProc; +module.exports.procs = procs; diff --git a/src/lib/local/foreman/proxy.cjs b/src/lib/local/foreman/proxy.cjs new file mode 100644 index 0000000000..f372fec633 --- /dev/null +++ b/src/lib/local/foreman/proxy.cjs @@ -0,0 +1,118 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var fs = require('fs'); +var path = require('path'); +var prog = require('child_process'); +var util = require('util'); + +var cons = require('./console.cjs').Console; + +function f(key, j, ports, proc, reqs, portargs, localhost, emitter, ssl) { + var port = parseInt(ports[j]); + var ssl_port = (port === 80 ? 443 : (port + 443)); + + if(port > 0 && port < 1024 && process.getuid() !== 0) { + return cons.Error('Cannot Bind to Privileged Port %s Without Permission - Try \'sudo\'',port); + } + + if(isNaN(port)) { + return cons.Warn('No Downstream Port Defined for \'%s\' Proxy', key); + } + + if(!(key in proc)) { + return cons.Warn('Proxy Not Started for Undefined Key \'%s\'', key); + } + + var upstream_size = reqs[key]; + var upstream_port = parseInt(portargs) + j * 100; + + var proxy = prog.fork(require.resolve('../proxy'), [], { + env: { + HOST: localhost, + PORT: port, + UPSTREAM_HOST: localhost, + UPSTREAM_PORT: upstream_port, + UPSTREAM_SIZE: upstream_size, + SSL_CERT: ssl.cert, + SSL_KEY: ssl.key, + SSL_PORT: port ? ssl_port : 0 + } + }); + + var port_targets; + + if(upstream_size === 1) { + port_targets = util.format('%d', upstream_port); + } else { + port_targets = util.format('(%d-%d)', upstream_port, upstream_port + upstream_size - 1); + } + + cons.Alert('Starting Proxy Server [%s] %s -> %s', key, port, port_targets); + if (ssl.cert && ssl.key) { + cons.Alert('Starting Secure Proxy Server [%s] %s -> %s', key, ssl_port, port_targets); + } + + proxy.on('message', function(msg) { + if ('http' in msg) { + emitter.emit('http', msg.http); + } + if ('https' in msg) { + emitter.emit('https', msg.https); + } + }); + + emitter.once('killall', function(signal) { + cons.Done('Killing Proxy Server on Port %s', port); + proxy.kill(signal); + }); + + proxy.on('exit', function(code, signal) { + emitter.emit('killall', signal); + }); + +} + +function startProxies(reqs, proc, command, emitter, portargs) { + + if ('proxy' in command) { + + var localhost = 'localhost'; + + var ports = command.proxy.split(','); + + var ssl = { + cert: '', + key: '' + }; + if ((command.sslKey && !command.sslCert) || + (command.sslCert && !command.sslKey)) { + cons.Warn('SSL key and cert must both be supplied for SSL support'); + } + if (command.sslKey && command.sslCert) { + command.sslKey = path.resolve(command.sslKey); + command.sslCert = path.resolve(command.sslCert); + if (!fs.existsSync(command.sslKey)) { + cons.Warn('SSL key (%s) does not exist', command.sslKey); + } + else { + ssl.key = command.sslKey; + } + if (!fs.existsSync(command.sslCert)) { + cons.Warn('SSL cert (%s) does not exist', command.sslCert); + } + else { + ssl.cert = command.sslCert; + } + } + + Object.keys(reqs).forEach(function(key, i) { + f(key, i, ports, proc, reqs, portargs, localhost, emitter, ssl); + }); + } + +} + +module.exports.startProxies = startProxies; diff --git a/src/lib/local/foreman/requirements.cjs b/src/lib/local/foreman/requirements.cjs new file mode 100644 index 0000000000..7a52d93eb9 --- /dev/null +++ b/src/lib/local/foreman/requirements.cjs @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2012,2016. All Rights Reserved. +// Node module: foreman +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +function parseRequirements(req) { + var requirements = {}; + req.toString().split(',').forEach(function(item) { + var tup = item.trim().split('='); + var key = tup[0]; + var val; + if(tup.length > 1) { + val = parseInt(tup[1]); + } else { + val = 1; + } + + requirements[key] = val; + }); + return requirements; +} + +function getreqs(args, proc) { + var req; + if(args && args.length > 0) { + req = parseRequirements(args); + } else { + req = {}; + for(var key in proc){ + req[key] = 1; + } + } + return req; +} + +function calculatePadding(reqs) { + var padding = 0; + for(var key in reqs){ + var num = reqs[key]; + var len = key.length + num.toString().length; + if(len > padding) { + padding = len; + } + } + return padding + 12; +} + +module.exports.calculatePadding = calculatePadding; +module.exports.getreqs = getreqs; diff --git a/src/lib/local/run-foreman.cjs b/src/lib/local/run-foreman.cjs index a46ba1d7b4..704dd2b613 100644 --- a/src/lib/local/run-foreman.cjs +++ b/src/lib/local/run-foreman.cjs @@ -3,9 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -// This file is copied from the Node Foreman package. It was copied in order to fix -// a bug related to the use of port 5000 as a default. It is not meant to be a -// long-term solution, we plan to eventually remove our dependency on Node Foreman. +// Based on the Node Foreman package (v3.0.1). The foreman library is vendored +// in ./foreman/ and is no longer an external npm dependency. The default port +// was changed from 5000 to 5006 to avoid conflicts with macOS AirPlay. // These rules are disabled in order to prevent the need for refactoring /* eslint-disable guard-for-in */ @@ -14,26 +14,21 @@ /* eslint-disable no-undef */ /* eslint-disable n/no-process-exit */ /* eslint-disable unicorn/no-process-exit */ -/* eslint-disable no-new */ -/* eslint-disable radix */ +/* eslint-disable perfectionist/sort-imports */ const program = require('commander') -const colors = require('foreman/lib/colors') +const colors = require('./foreman/colors.cjs') const events = require('node:events') -const fs = require('node:fs') -const path = require('node:path') const {quote} = require('shell-quote') -const display = require('foreman/lib/console').Console -const _envs = require('foreman/lib/envs') -const exporters = require('foreman/lib/exporters') -const {startForward} = require('foreman/lib/forward') -const _proc = require('foreman/lib/proc') -const _procfile = require('foreman/lib/procfile') -const {startProxies} = require('foreman/lib/proxy') -const _requirements = require('foreman/lib/requirements') -const foremanPjson = require('foreman/package.json') - -program.version(foremanPjson.version) +const display = require('./foreman/console.cjs').Console +const _envs = require('./foreman/envs.cjs') +const {startForward} = require('./foreman/forward.cjs') +const _proc = require('./foreman/proc.cjs') +const _procfile = require('./foreman/procfile.cjs') +const {startProxies} = require('./foreman/proxy.cjs') +const _requirements = require('./foreman/requirements.cjs') + +program.version('3.0.1') program.option('-j, --procfile ', 'load procfile FILE', 'Procfile') program.option('-e, --env ', 'load environment from FILE, a comma-separated list', '.env') program.option('-p, --port ', 'start indexing ports at number PORT', 0) @@ -146,145 +141,6 @@ program once(input, envs, callback) }) -program - .command('export [PROCS]') - .option('-a, --app ', 'export upstart application as NAME', 'foreman') - .option('-u, --user ', 'export upstart user as NAME', 'root') - .option('-o, --out ', 'export upstart files to DIR', '.') - .option('-c, --cwd ', 'change current working directory to DIR') - .option('-g, --gid ', 'set gid of upstart config to GID') - .option('-l, --log ', 'specify upstart log directory', '/var/log') - .option('-t, --type ', 'export file to TYPE (default upstart)', 'upstart') - .option('-m, --template ', 'use template folder') - .description('Export to an upstart job independent of foreman') - .action(function (procArgs) { - const envs = loadEnvs(program.env) - - const procs = loadProc(program.procfile) - - if (!procs) { - return - } - - const req = getreqs(procArgs, procs) - - // Variables for Upstart Template - const config = { - application: this.app, - cwd: path.resolve(process.cwd(), this.cwd || ''), - envs, - group: this.gid || this.user, - logs: this.log, - template: this.template, - user: this.user, - } - - config.envfile = path.resolve(program.env) - - let writeout - if (exporters[this.type]) { - writeout = exporters[this.type] - } else { - new display.Error('Unknown Export Format', this.type) - process.exit(1) - } - - // Check for Upstart User - // friendly warning - does not stop export - let userExists = false - for (const line of fs.readFileSync('/etc/passwd') - .toString().split(/\n/)) { - if (line.match(/^[^:]*/)[0] === config.user) { - userExists = true - } - } - - if (!userExists) { - display.Warn(display.fmt('User %s Does Not Exist on System', config.user)) - } - - // using port 5006 because it is not known to be used by other common software - const baseport = Number.parseInt(program.port || envs.PORT || process.env.PORT || 5006) - let baseport_i = 0 - let baseport_j = 0 - let envl = [] - - config.processes = [] - - // This is ugly because of shitty support for array copying - // Cleanup is definitely required - for (let key in req) { - const c = {} - const cmd = procs[key] - - if (!cmd) { - display.Warn("Required Key '%s' Does Not Exist in Procfile Definition", key) - continue - } - - const n = req[key] - - config.processes.push({n, process: key}) - c.process = key - c.command = cmd - - for (const _ in config) { - c[_] = config[_] - } - - c.numbers = [] - for (let i = 1; i <= n; i++) { - const port = (baseport + baseport_i + baseport_j) * 100 - - const envl = [] - for (key in envs) { - envl.push({ - key, - value: envs[key], - }) - } - - envl.push({key: 'PORT', value: conf.port}) - // eslint-disable-next-line unicorn/no-array-push-push - envl.push({key: 'FOREMAN_WORKER_NAME', value: conf.process + '.' + conf.number}) - - conf.envs = envl - - const conf = { - ...c, - envs: envl, - number: i, - port, - } - - // Write the APP-PROCESS-N.conf File - writeout.foreman_app_n(conf, this.out) - - baseport_i++ - c.numbers.push({number: i}) - } - - envl = [] - for (key in envs) { - envl.push({ - key, - value: envs[key], - }) - } - - c.envs = envl - - // Write the APP-Process.conf File - writeout.foreman_app(c, this.out) - - baseport_i = 0 - baseport_j++ - } - - // Write the APP.conf File - writeout.foreman(config, this.out) - }) - program.parse(process.argv) if (process.argv.slice(2).length === 0) { diff --git a/test/unit/lib/local/foreman/envs.unit.test.ts b/test/unit/lib/local/foreman/envs.unit.test.ts new file mode 100644 index 0000000000..9b551e8c22 --- /dev/null +++ b/test/unit/lib/local/foreman/envs.unit.test.ts @@ -0,0 +1,130 @@ +import {expect} from 'chai' +import fs from 'node:fs' +import {createRequire} from 'node:module' +import os from 'node:os' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const {flattenJSON, keyValue, loadEnvs} = require('../../../../../src/lib/local/foreman/envs.cjs') + +describe('vendored foreman envs', function () { + let tempDir: string + + beforeEach(function () { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'heroku-foreman-envs-')) + }) + + afterEach(function () { + fs.rmSync(tempDir, {force: true, recursive: true}) + }) + + describe('keyValue', function () { + it('parses basic key=value pairs', function () { + const result = keyValue('FOO=bar\nBAZ=qux\n') + expect(result).to.deep.equal({BAZ: 'qux', FOO: 'bar'}) + }) + + it('parses double-quoted values', function () { + const result = keyValue('FOO="hello world"\n') + expect(result).to.deep.equal({FOO: 'hello world'}) + }) + + it('parses single-quoted values', function () { + const result = keyValue("FOO='hello world'\n") + expect(result).to.deep.equal({FOO: 'hello world'}) + }) + + it('strips inline comments from unquoted values', function () { + const result = keyValue('FOO=bar # this is a comment\n') + expect(result).to.deep.equal({FOO: 'bar'}) + }) + + it('ignores comment lines', function () { + const result = keyValue('# a comment\nFOO=bar\n') + expect(result).to.deep.equal({FOO: 'bar'}) + }) + + it('ignores blank lines', function () { + const result = keyValue('\n\nFOO=bar\n\n') + expect(result).to.deep.equal({FOO: 'bar'}) + }) + + it('handles values containing equals signs', function () { + const result = keyValue('DATABASE_URL=postgres://user:pass@host/db?opt=val\n') + expect(result).to.deep.equal({DATABASE_URL: 'postgres://user:pass@host/db?opt=val'}) + }) + }) + + describe('flattenJSON', function () { + it('flattens a simple nested object', function () { + const result = flattenJSON({top: {middle: 'value'}}) + expect(result).to.deep.equal({TOP_MIDDLE: 'value'}) + }) + + it('flattens arrays by index', function () { + const result = flattenJSON({items: ['a', 'b', 'c']}) + expect(result).to.deep.equal({ITEMS_0: 'a', ITEMS_1: 'b', ITEMS_2: 'c'}) + }) + + it('flattens deeply nested structures', function () { + const result = flattenJSON({a: {b: {c: 42}}}) + expect(result).to.deep.equal({A_B_C: 42}) + }) + + it('flattens a flat object', function () { + const result = flattenJSON({KEY: 'val'}) + expect(result).to.deep.equal({KEY: 'val'}) + }) + }) + + describe('loadEnvs', function () { + it('loads a single env file', function () { + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'FOO=bar\nBAZ=qux\n') + + const result = loadEnvs(envPath) + expect(result.FOO).to.equal('bar') + expect(result.BAZ).to.equal('qux') + expect(result.PATH).to.be.a('string') + }) + + it('merges multiple comma-separated env files', function () { + const env1 = path.join(tempDir, 'first.env') + const env2 = path.join(tempDir, 'second.env') + fs.writeFileSync(env1, 'FOO=from_first\nSHARED=first\n') + fs.writeFileSync(env2, 'BAR=from_second\nSHARED=second\n') + + const result = loadEnvs(env1 + ',' + env2) + expect(result.FOO).to.equal('from_first') + expect(result.BAR).to.equal('from_second') + expect(result.SHARED).to.equal('second') + }) + + it('returns sorted keys', function () { + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'ZZZ=last\nAAA=first\nMMM=middle\n') + + const result = loadEnvs(envPath) + const keys = Object.keys(result) + const aIdx = keys.indexOf('AAA') + const mIdx = keys.indexOf('MMM') + const zIdx = keys.indexOf('ZZZ') + expect(aIdx).to.be.lessThan(mIdx) + expect(mIdx).to.be.lessThan(zIdx) + }) + + it('handles a missing env file gracefully', function () { + const missing = path.join(tempDir, 'nonexistent.env') + const result = loadEnvs(missing) + expect(result.PATH).to.be.a('string') + }) + + it('sets PATH from process.env when not in env file', function () { + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'FOO=bar\n') + + const result = loadEnvs(envPath) + expect(result.PATH).to.equal(process.env.PATH) + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 75da7be3e1..b6fb8d6cf7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "allowJs": true, // allowed to support run-foreman.js. Should be removed when that functionality is refactored. + "allowJs": true, // required to support run-foreman.cjs and vendored foreman .cjs files in src/lib/local/foreman/ "declaration": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true,