From 05cb89725e2e53aae4686905c02f4d4a4c67e691 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 14 Oct 2019 00:52:43 +0100 Subject: [PATCH 1/3] Fix privilege elevation (sudo) for 'os initialize', 'join', 'leave' * sudo shell arguments required escaping for 'os initialize' * sudo was not working for standalone zip packages (incorrect Node.js path in argv[0]) * Interactive 'join' and 'leave' not working on Windows because 'windosu' does not capture stderr. Change-type: patch Signed-off-by: Paulo Castro --- lib/utils/helpers.ts | 94 ++++++++++++++++++--- lib/utils/promote.ts | 2 +- lib/utils/sudo.ts | 117 +++++++++++++++++++++----- npm-shrinkwrap.json | 68 ++++++++++----- package.json | 13 +-- typings/balena-device-init/index.d.ts | 2 + 6 files changed, 239 insertions(+), 57 deletions(-) diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index 9ef49193..86c506b0 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -20,11 +20,10 @@ import chalk from 'chalk'; import _ = require('lodash'); import os = require('os'); import visuals = require('resin-cli-visuals'); -import rindle = require('rindle'); +import * as ShellEscape from 'shell-escape'; import { InitializeEmitter, OperationState } from 'balena-device-init'; -const waitStreamAsync = Bluebird.promisify(rindle.wait); const balena = BalenaSdk.fromSharedOptions(); export function getGroupDefaults(group: { @@ -59,19 +58,44 @@ export function stateToString(state: OperationState) { } } -export function sudo( +/** + * Execute a child process with admin / superuser privileges, prompting the user for + * elevation as needed, and taking care of shell-escaping arguments in a suitable way + * for Windows and Linux/Mac. + * + * @param command Unescaped array of command and args to be executed. If isCLIcmd is + * true, the command should not include the 'node' or 'balena' components, for example: + * ['internal', 'osinit', ...]. This function will add argv[0] and argv[1] as needed + * (taking process.pkg into account -- CLI standalone zip package), and will also + * shell-escape the arguments as needed, taking into account the differences between + * bash/sh and the Windows cmd.exe in relation to escape characters. + * @param msg Optional message for the user, before the password prompt + * @param stderr Optional stream to which stderr should be piped + * @param isCLIcmd (default: true) Whether the command array is a balena CLI command + * (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are + * added as necessary, depending on whether the CLI is running as a standalone zip + * package (with Node built in). + */ +export async function sudo( command: string[], - { stderr, msg }: { stderr?: NodeJS.WritableStream; msg?: string } = {}, + { + stderr, + msg, + isCLIcmd, + }: { stderr?: NodeJS.WritableStream; msg?: string; isCLIcmd?: boolean } = {}, ) { - const { executeWithPrivileges } = require('./sudo'); + const { executeWithPrivileges } = await import('./sudo'); if (os.platform() !== 'win32') { console.log( - msg || 'If asked please type your computer password to continue', + msg || + 'Admin privileges required: you may be asked for your computer password to continue.', ); } - - return executeWithPrivileges(command, stderr); + if (isCLIcmd == null) { + isCLIcmd = true; + } + await executeWithPrivileges(command, stderr, isCLIcmd); } export function runCommand(command: string): Bluebird { @@ -106,7 +130,7 @@ export async function getOsVersion( return init.getImageOsVersion(image, manifest); } -export function osProgressHandler(step: InitializeEmitter) { +export async function osProgressHandler(step: InitializeEmitter) { step.on('stdout', process.stdout.write.bind(process.stdout)); step.on('stderr', process.stderr.write.bind(process.stderr)); @@ -124,7 +148,10 @@ export function osProgressHandler(step: InitializeEmitter) { step.on('burn', state => progressBars[state.type].update(state)); - return waitStreamAsync(step); + await new Promise((resolve, reject) => { + step.on('error', reject); + step.on('end', resolve); + }); } export function getArchAndDeviceType( @@ -272,3 +299,50 @@ export function getManualSortCompareFunction( } }; } + +/** + * Shell argument escaping compatible with sh, bash and Windows cmd.exe. + * @param arg Arguments to be escaped + * @param detectShell Whether to use the SHELL and ComSpec environment + * variables to determine the shell type (sh / bash / cmd.exe). This may be + * useful to detect MSYS / MSYS2, which use bash on Windows. However, if the + * purpose is to use child_process.spawn(..., {shell: true}) and related + * functions, set this to false because child_process.spawn() always uses + * env.ComSpec (cmd.exe) on Windows, even when running on MSYS / MSYS2. + */ +export function shellEscape(args: string[], detectShell = false): string[] { + let isWindowsCmdExeShell: boolean; + if (detectShell) { + isWindowsCmdExeShell = + // neither bash nor sh (e.g. not MSYS, MSYS2, WSL) + process.env.SHELL == null && + // Windows cmd.exe or PowerShell + process.env.ComSpec != null && + process.env.ComSpec.endsWith('cmd.exe'); + } else { + isWindowsCmdExeShell = process.platform === 'win32'; + } + if (isWindowsCmdExeShell) { + return args.map(v => windowsCmdExeEscapeArg(v)); + } else { + const shellEscapeFunc: typeof ShellEscape = require('shell-escape'); + return args.map(v => shellEscapeFunc([v])); + } +} + +/** + * Escape a string argument to be passed through the Windows cmd.exe shell. + * cmd.exe escaping has some peculiarities, like using the caret character + * instead of a backslash for reserved / metacharacters. Reference: + * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + */ +function windowsCmdExeEscapeArg(arg: string): string { + // if it is already double quoted, remove the double quotes + if (arg.length > 1 && arg.startsWith('"') && arg.endsWith('"')) { + arg = arg.slice(1, -1); + } + // escape cmd.exe metacharacters with the '^' (caret) character + arg = arg.replace(/[()%!^<>&|]/g, '^$&'); + // duplicate internal double quotes, and double quote overall + return `"${arg.replace(/["]/g, '""')}"`; +} diff --git a/lib/utils/promote.ts b/lib/utils/promote.ts index fcb48a6b..b26f5fea 100644 --- a/lib/utils/promote.ts +++ b/lib/utils/promote.ts @@ -174,7 +174,7 @@ async function getOrSelectLocalDevice(deviceIp?: string): Promise { stream.pipe(process.stderr); const { sudo } = await import('../utils/helpers'); - const command = process.argv.slice(0, 2).concat(['internal', 'scandevices']); + const command = ['internal', 'scandevices']; await sudo(command, { stderr: stream, msg: diff --git a/lib/utils/sudo.ts b/lib/utils/sudo.ts index 8c6cd9eb..30424fb6 100644 --- a/lib/utils/sudo.ts +++ b/lib/utils/sudo.ts @@ -14,34 +14,107 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { spawn, StdioOptions } from 'child_process'; -import * as Bluebird from 'bluebird'; -import * as rindle from 'rindle'; +import { ChildProcess, spawn, SpawnOptions } from 'child_process'; +import { stripIndent } from 'common-tags'; +import * as _ from 'lodash'; +/** + * Execute a child process with admin / superuser privileges, prompting the user for + * elevation as needed, and taking care of shell-escaping arguments in a suitable way + * for Windows and Linux/Mac. + * + * @param command Unescaped array of command and args to be executed. If isCLIcmd is + * true, the command should not include the 'node' or 'balena' components, for + * example: ['internal', 'osinit', ...]. This function will add argv[0] and argv[1] + * as needed (taking process.pkg into account -- CLI standalone zip package), and + * will also shell-escape the arguments as needed, taking into account the + * differences between bash/sh and the Windows cmd.exe in relation to escape + * characters. + * @param stderr Optional stream to which stderr should be piped + * @param isCLIcmd (default: true) Whether the command array is a balena CLI command + * (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are + * added as necessary, depending on whether the CLI is running as a standalone zip + * package (with Node built in). + */ export async function executeWithPrivileges( command: string[], stderr?: NodeJS.WritableStream, -): Promise { - const stdio: StdioOptions = [ - 'inherit', - 'inherit', - stderr ? 'pipe' : 'inherit', - ]; - const opts = { + isCLIcmd = true, +): Promise { + // whether the CLI is already running with admin / super user privileges + const isElevated = await (await import('is-elevated'))(); + const { shellEscape } = await import('./helpers'); + const opts: SpawnOptions = { env: process.env, - stdio, + stdio: ['inherit', 'inherit', stderr ? 'pipe' : 'inherit'], }; - - const args = process.argv - .slice(0, 2) - .concat(['internal', 'sudo', command.join(' ')]); - - const ps = spawn(args[0], args.slice(1), opts); - - if (stderr) { - ps.stderr.pipe(stderr); + if (isElevated) { + if (isCLIcmd) { + // opts.shell is false, so preserve pkg's '/snapshot' at argv[1] + command = [process.argv[0], process.argv[1], ...command]; + } + // already running with privileges: simply spawn the command + await spawnAndPipe(command[0], command.slice(1), opts, stderr); + } else { + if (isCLIcmd) { + // In the case of a CLI standalone zip package (process.pkg is truthy), + // the Node executable is bundled with the source code and node_modules + // folder in a single file named in argv[0]. In this case, argv[1] + // contains a "/snapshot" path that should be discarded when opts.shell + // is true. + command = (process as any).pkg + ? [process.argv[0], ...command] + : [process.argv[0], process.argv[1], ...command]; + } + opts.shell = true; + const escapedCmd = shellEscape(command); + // running as ordinary user: elevate privileges + if (process.platform === 'win32') { + await windosuExec(escapedCmd, stderr); + } else { + await spawnAndPipe('sudo', escapedCmd, opts, stderr); + } } - - return Bluebird.fromCallback(callback => rindle.wait(ps, callback)); +} + +async function spawnAndPipe( + spawnCmd: string, + spawnArgs: string[], + spawnOpts: SpawnOptions, + stderr?: NodeJS.WritableStream, +) { + await new Promise((resolve, reject) => { + const ps: ChildProcess = spawn(spawnCmd, spawnArgs, spawnOpts); + ps.on('error', reject); + ps.on('exit', codeOrSignal => { + if (codeOrSignal !== 0) { + const errMsgCmd = `[${[spawnCmd, ...spawnArgs].join()}]`; + reject( + new Error( + `Child process exited with error code "${codeOrSignal}" for command:\n${errMsgCmd}`, + ), + ); + } else { + resolve(); + } + }); + if (stderr) { + ps.stderr.pipe(stderr); + } + }); +} + +async function windosuExec( + escapedArgs: string[], + stderr?: NodeJS.WritableStream, +): Promise { + if (stderr) { + const msg = stripIndent` + Error: unable to elevate privileges. Please run the command prompt as an Administrator: + https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/ + `; + throw new Error(msg); + } + return require('windosu').exec(escapedArgs.join(' ')); } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index a55c2c31..2ec3868b 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2704,6 +2704,20 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "is-elevated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz", + "integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=", + "requires": { + "is-admin": "^1.0.2", + "is-root": "^1.0.0" + } + }, + "is-root": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz", + "integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU=" } } }, @@ -7599,12 +7613,22 @@ "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=" }, "is-elevated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz", - "integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-3.0.0.tgz", + "integrity": "sha512-wjcp6RkouU9jpg55zERl+BglvV5j4jx5c/EMvQ+d12j/+nIEenNWPu+qc0tCg3JkLodbKZMg1qhJzEwG4qjclg==", "requires": { - "is-admin": "^1.0.2", - "is-root": "^1.0.0" + "is-admin": "^3.0.0", + "is-root": "^2.1.0" + }, + "dependencies": { + "is-admin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-admin/-/is-admin-3.0.0.tgz", + "integrity": "sha512-wOa3CXFJAu8BZ2BDtG9xYOOrsq6oiSvc2jFPy4X/HINx5bmJUcW8e+apItVbU2E7GIfBVaFVO7Zit4oAWtTJcw==", + "requires": { + "execa": "^1.0.0" + } + } } }, "is-extendable": { @@ -7741,9 +7765,9 @@ "integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=" }, "is-root": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz", - "integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU=" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==" }, "is-scoped": { "version": "1.0.0", @@ -14993,15 +15017,15 @@ } }, "resin-cli-visuals": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/resin-cli-visuals/-/resin-cli-visuals-1.4.3.tgz", - "integrity": "sha512-vzj6iTdWd0zq1pRXNJZmgMPL6cKS66lLOZlT4lr2kam/ohWFRQNZfmwigZRbrtUOOtBwdVujqSqYAP9USjX9NQ==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/resin-cli-visuals/-/resin-cli-visuals-1.4.4.tgz", + "integrity": "sha512-EchgufKC2loXoxB4xjnFtR2bJZbR5xNtJ/pVLc8G2But45TwgkORC5RDbWNe6fCdXJvgWFW1RgZ0Z0tc7Zd+Nw==", "requires": { "bluebird": "^3.5.1", "chalk": "^2.3.1", "cli-spinner": "0.2.7", "columnify": "^1.5.1", - "drivelist": "^8.0.6", + "drivelist": "^8.0.7", "inquirer": "^0.11.0", "inquirer-dynamic-list": "^1.0.0", "is-promise": "^2.1.0", @@ -15041,14 +15065,14 @@ } }, "drivelist": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/drivelist/-/drivelist-8.0.6.tgz", - "integrity": "sha512-KkXaGslqZP7Y13rofyFZBUl9dQrs0wqT5aXq5PAr67uPE2IDXEUYH+LNGTv69xpd7cUImjQvvB9H4bkziLIs1w==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/drivelist/-/drivelist-8.0.7.tgz", + "integrity": "sha512-KgFVzXux+rdRjzt1b1Vv4btqCmB/XC0wOfodtUev9MkJcL8VyxjTsopt+lE3GlSv4M+XPgS9dqf9fBowJ8I/2w==", "requires": { "bindings": "^1.3.0", "debug": "^3.1.0", "mz": "^2.7.0", - "nan": "^2.10.0", + "nan": "^2.14.0", "prebuild-install": "^5.2.4" } }, @@ -15099,6 +15123,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" + }, "run-async": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", @@ -17839,13 +17868,14 @@ } }, "windosu": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/windosu/-/windosu-0.2.0.tgz", - "integrity": "sha1-oqGqymWHczDqCxtoXvPACQIKY4k=", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/windosu/-/windosu-0.3.0.tgz", + "integrity": "sha1-Bhx/5KgRikrZ9P420/kA0ivApHk=", "optional": true, "requires": { "cli-width": "^1.1.0", "q": "~0.9.7", + "rimraf": "^2.5.2", "tail": "~0.3.1", "temp": "~0.6.0" }, diff --git a/package.json b/package.json index e4e59b1c..e67568aa 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "build/actions-oclif", "build/auth/pages/*.ejs", "build/hooks", - "node_modules/resin-discoverable-services/services/**/*" + "node_modules/resin-discoverable-services/services/**/*", + "node_modules/windosu/*.bat", + "node_modules/windosu/*.cmd" ] }, "scripts": { @@ -135,7 +137,6 @@ "publish-release": "^1.6.0", "resin-lint": "^3.0.1", "rewire": "^3.0.2", - "shell-escape": "^0.2.0", "sinon": "^7.4.1", "ts-node": "^8.1.0", "typescript": "^3.4.5" @@ -184,7 +185,8 @@ "humanize": "0.0.9", "ignore": "^5.1.1", "inquirer": "^3.1.1", - "is-root": "^1.0.0", + "is-elevated": "^3.0.0", + "is-root": "^2.1.0", "js-yaml": "^3.13.1", "klaw": "^3.0.0", "livepush": "^2.0.1", @@ -205,7 +207,7 @@ "reconfix": "^0.1.0", "request": "^2.81.0", "resin-cli-form": "^2.0.1", - "resin-cli-visuals": "^1.4.0", + "resin-cli-visuals": "^1.4.4", "resin-compose-parse": "^2.1.0", "resin-doodles": "0.0.1", "resin-image-fs": "^5.0.8", @@ -216,6 +218,7 @@ "rimraf": "^2.4.3", "rindle": "^1.3.4", "semver": "^5.7.0", + "shell-escape": "^0.2.0", "split": "^1.0.1", "string-width": "^2.1.1", "strip-ansi-stream": "^1.0.0", @@ -231,6 +234,6 @@ }, "optionalDependencies": { "net-keepalive": "^1.2.1", - "windosu": "^0.2.0" + "windosu": "^0.3.0" } } diff --git a/typings/balena-device-init/index.d.ts b/typings/balena-device-init/index.d.ts index f3b27401..91cdc81e 100644 --- a/typings/balena-device-init/index.d.ts +++ b/typings/balena-device-init/index.d.ts @@ -75,6 +75,8 @@ declare module 'balena-device-init' { on(event: 'stdout' | 'stderr', callback: (msg: string) => void): void; on(event: 'state', callback: (state: OperationState) => void): void; on(event: 'burn', callback: (state: BurnProgress) => void): void; + on(event: 'end', callback: () => void): void; + on(event: 'error', callback: (error: Error) => void): void; } export function configure( From 69c97fed09b7956503b28d18e96fc2dd924521f4 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Tue, 15 Oct 2019 02:50:01 +0100 Subject: [PATCH 2/3] Remove 'internal sudo' command Change-type: patch Signed-off-by: Paulo Castro --- lib/actions/internal.coffee | 32 -------------------------------- lib/app-capitano.coffee | 1 - 2 files changed, 33 deletions(-) diff --git a/lib/actions/internal.coffee b/lib/actions/internal.coffee index 7a4a9984..e91bb086 100644 --- a/lib/actions/internal.coffee +++ b/lib/actions/internal.coffee @@ -54,35 +54,3 @@ exports.scanDevices = .then (hostnameOrIp) -> console.error("==> Selected device: #{hostnameOrIp}") .nodeify(done) - -exports.sudo = - signature: 'internal sudo ' - description: 'execute arbitrary commands in a privileged subprocess' - help: ''' - Don't use this command directly! - - must be passed as a single argument. That means, you need to make sure - you enclose in quotes (eg. balena internal sudo 'ls -alF') if for - whatever reason you invoke the command directly or, typically, pass - as a single argument to spawn (eg. `spawn('balena', [ 'internal', 'sudo', 'ls -alF' ])`). - - Furthermore, this command will naively split on whitespace and directly - forward the parts as arguments to `sudo`, so be careful. - ''' - hidden: true - action: (params, options, done) -> - os = require('os') - Promise = require('bluebird') - - return Promise.try -> - if os.platform() is 'win32' - windosu = require('windosu') - windosu.exec(params.command, {}) - else - { spawn } = require('child_process') - { wait } = require('rindle') - cmd = params.command.split(' ') - ps = spawn('sudo', cmd, stdio: 'inherit', env: process.env) - wait(ps) - - .nodeify(done) diff --git a/lib/app-capitano.coffee b/lib/app-capitano.coffee index 5cfb5188..0b04150e 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -128,7 +128,6 @@ capitano.command(actions.util.availableDrives) # ---------- Internal utils ---------- capitano.command(actions.internal.osInit) capitano.command(actions.internal.scanDevices) -capitano.command(actions.internal.sudo) #------------ Local build and deploy ------- capitano.command(actions.build) From 02b888f7c1af920757b96c75906d5b08acef786c Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Tue, 15 Oct 2019 01:05:40 +0100 Subject: [PATCH 3/3] Fix privilege elevation for standalone zip package on Windows (windosu) * Add pkgExec internal command * Patch windosu to be aware of process.pkg and use pkgExec Change-type: patch Signed-off-by: Paulo Castro --- lib/app.ts | 54 ++++++++++++++++++++++++++++++++++--- patches/windosu+0.3.0.patch | 38 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 patches/windosu+0.3.0.patch diff --git a/lib/app.ts b/lib/app.ts index 9eb2992c..9681f028 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -15,17 +15,63 @@ * limitations under the License. */ -import { globalInit } from './app-common'; -import { AppOptions, routeCliFramework } from './preparser'; - /** * CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which * call this function. */ -export async function run(cliArgs = process.argv, options: AppOptions = {}) { +export async function run( + cliArgs = process.argv, + options: import('./preparser').AppOptions = {}, +) { + // The 'pkgExec' special/internal command provides a Node.js interpreter + // for use of the standalone zip package. See pkgExec function. + if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') { + return pkgExec(cliArgs[3], cliArgs.slice(4)); + } + + const { globalInit } = await import('./app-common'); + const { routeCliFramework } = await import('./preparser'); + // globalInit() must be called very early on (before other imports) because // it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk // shared options, and performs node version requirement checks. globalInit(); await routeCliFramework(cliArgs, options); } + +/** + * Implements the 'pkgExec' command, used as a way to provide a Node.js + * interpreter for child_process.spawn()-like operations when the CLI is + * executing as a standalone zip package (built-in Node interpreter) and + * the system may not have a separate Node.js installation. A present use + * case is a patched version of the 'windosu' package that requires a + * Node.js interpreter to spawn a privileged child process. + * + * @param modFunc Path to a JS module that will be executed via require(). + * The modFunc argument may optionally contain a function name separated + * by '::', for example '::main' in: + * 'C:\\snapshot\\balena-cli\\node_modules\\windosu\\lib\\pipe.js::main' + * in which case that function is executed in the require'd module. + * @param args Optional arguments to passed through process.argv and as + * arguments to the function specified via modFunc. + */ +async function pkgExec(modFunc: string, args: string[]) { + const [modPath, funcName] = modFunc.split('::'); + let replacedModPath = modPath; + const match = modPath + .replace(/\\/g, '/') + .match(/\/snapshot\/balena-cli\/(.+)/); + if (match) { + replacedModPath = `../${match[1]}`; + } + process.argv = [process.argv[0], process.argv[1], ...args]; + try { + const mod: any = await import(replacedModPath); + if (funcName) { + await mod[funcName](...args); + } + } catch (err) { + console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`); + console.error(err); + } +} diff --git a/patches/windosu+0.3.0.patch b/patches/windosu+0.3.0.patch new file mode 100644 index 00000000..d55d5ab7 --- /dev/null +++ b/patches/windosu+0.3.0.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/windosu/lib/pipe.js b/node_modules/windosu/lib/pipe.js +index dc81fa5..a381cc7 100644 +--- a/node_modules/windosu/lib/pipe.js ++++ b/node_modules/windosu/lib/pipe.js +@@ -42,7 +42,8 @@ function pipe(path, options) { + return d.promise; + } + module.exports = pipe; +-if (module === require.main) { ++ ++function main() { + if (!process.argv[4]) { + console.error('Incorrect arguments!'); + process.exit(-1); +@@ -52,3 +53,8 @@ if (module === require.main) { + serve: process.argv[3] == 'server' + }); + } ++module.exports.main = main; ++ ++if (module === require.main) { ++ main(); ++} +diff --git a/node_modules/windosu/lib/windosu.js b/node_modules/windosu/lib/windosu.js +index 6502812..dd0391a 100644 +--- a/node_modules/windosu/lib/windosu.js ++++ b/node_modules/windosu/lib/windosu.js +@@ -16,7 +16,9 @@ module.exports.exec = function (command, options, callback) { + temp: temp, + command: command, + cliWidth: cliWidth(), +- pipe: '"' + process.execPath + '" "' + path.join(__dirname, 'pipe.js') + '"', ++ pipe: process.pkg ++ ? '"' + process.execPath + '" pkgExec "' + path.join(__dirname, 'pipe.js') + '::main"' ++ : '"' + process.execPath + '" "' + path.join(__dirname, 'pipe.js') + '"', + input: inputName = id + '-in', + output: outputName = id + '-out', + stderr_redir: process.stdout.isTTY ? '2>&1' : '2> %ERROR%'