mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-19 21:57:51 +00:00
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 <paulo@balena.io>
This commit is contained in:
parent
43ae9b672c
commit
05cb89725e
@ -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<void> {
|
||||
@ -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<T, U = T>(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '""')}"`;
|
||||
}
|
||||
|
@ -174,7 +174,7 @@ async function getOrSelectLocalDevice(deviceIp?: string): Promise<string> {
|
||||
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:
|
||||
|
@ -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<string> {
|
||||
const stdio: StdioOptions = [
|
||||
'inherit',
|
||||
'inherit',
|
||||
stderr ? 'pipe' : 'inherit',
|
||||
];
|
||||
const opts = {
|
||||
isCLIcmd = true,
|
||||
): Promise<void> {
|
||||
// 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<string>(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<void> {
|
||||
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(' '));
|
||||
}
|
||||
|
68
npm-shrinkwrap.json
generated
68
npm-shrinkwrap.json
generated
@ -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"
|
||||
},
|
||||
|
13
package.json
13
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"
|
||||
}
|
||||
}
|
||||
|
2
typings/balena-device-init/index.d.ts
vendored
2
typings/balena-device-init/index.d.ts
vendored
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user