diff --git a/project/Build.scala b/project/Build.scala index e5ddd91d0de4..af52b03a7cec 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -985,11 +985,12 @@ object Build { includeFilter in unmanagedSources := NothingFilter | "*.ts" | "**.json", watchSources in Global ++= (unmanagedSources in Compile).value, resourceGenerators in Compile += Def.task { - val defaultIDEConfig = baseDirectory.value / "out" / "default-dotty-ide-config" - IO.write(defaultIDEConfig, dottyVersion) + // Resources that will be copied when bootstrapping a new project + val buildSbtFile = baseDirectory.value / "out" / "build.sbt" + IO.write(buildSbtFile, s"""scalaVersion := "$dottyVersion"""") val dottyPluginSbtFile = baseDirectory.value / "out" / "dotty-plugin.sbt" IO.write(dottyPluginSbtFile, s"""addSbtPlugin("$dottyOrganization" % "$sbtDottyName" % "$sbtDottyVersion")""") - Seq(defaultIDEConfig, dottyPluginSbtFile) + Seq(buildSbtFile, dottyPluginSbtFile) }, compile in Compile := Def.task { val workingDir = baseDirectory.value diff --git a/vscode-dotty/package-lock.json b/vscode-dotty/package-lock.json index f7f7b10e86cd..3c0d58df9fb6 100644 --- a/vscode-dotty/package-lock.json +++ b/vscode-dotty/package-lock.json @@ -210,16 +210,6 @@ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", "dev": true }, - "child-process-promise": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/child-process-promise/-/child-process-promise-2.2.1.tgz", - "integrity": "sha1-RzChHvYQ+tRQuPIjx50x172tgHQ=", - "requires": { - "cross-spawn": "^4.0.2", - "node-version": "^1.0.0", - "promise-polyfill": "^6.0.1" - } - }, "clone": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz", @@ -293,15 +283,6 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, - "cross-spawn": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", - "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", - "requires": { - "lru-cache": "^4.0.1", - "which": "^1.2.9" - } - }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1070,11 +1051,6 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" - }, "isobject": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", @@ -1163,15 +1139,6 @@ "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", "dev": true }, - "lru-cache": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.2.tgz", - "integrity": "sha512-wgeVXhrDwAWnIF/yZARsFnMBtdFXOg1b8RIrhilp+0iDYN4mdQcNZElDZ0e4B64BhaxeQ5zN7PMyvu7we1kPeQ==", - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, "map-stream": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", @@ -1340,11 +1307,6 @@ "minimatch": "^3.0.0" } }, - "node-version": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/node-version/-/node-version-1.2.0.tgz", - "integrity": "sha512-ma6oU4Sk0qOoKEAymVoTvk8EdXEobdS7m/mAGhDJ8Rouugho48crHBORAmy5BoOcv8wraPM6xumapQp5hl4iIQ==" - }, "node.extend": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.6.tgz", @@ -1485,15 +1447,20 @@ "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true }, - "promise-polyfill": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-6.1.0.tgz", - "integrity": "sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc=" - }, - "pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + "promisify-child-process": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/promisify-child-process/-/promisify-child-process-2.1.2.tgz", + "integrity": "sha512-j2BRwNaM7fUwrd67avtqSTRevQXZiqS+T4Ky3VVaQdvzkPpsTByBAv+ZyBxuXgV/eUrCe2qYrOZvPvd+sMryeg==", + "requires": { + "@types/node": "^10.11.3" + }, + "dependencies": { + "@types/node": { + "version": "10.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.11.6.tgz", + "integrity": "sha512-fnA7yvqg3oKQDb3skBif9w5RRKVKAaeKeNuLzZL37XcSiWL4IoSXQnnbchR3UnBu2EMLHBip7ZVEkqoIVBP8QQ==" + } + } }, "punycode": { "version": "1.4.1", @@ -2228,14 +2195,6 @@ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-1.0.6.tgz", "integrity": "sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==" }, - "which": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", - "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==", - "requires": { - "isexe": "^2.0.0" - } - }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2248,11 +2207,6 @@ "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", "dev": true }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" - }, "yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/vscode-dotty/package.json b/vscode-dotty/package.json index 682335ff5bbe..47afe1e6cc36 100644 --- a/vscode-dotty/package.json +++ b/vscode-dotty/package.json @@ -71,10 +71,11 @@ "scala-lang.scala" ], "dependencies": { - "child-process-promise": "^2.2.1", + "promisify-child-process": "^2.1.2", "compare-versions": "^3.4.0", "vscode-languageclient": "^5.1.0", - "vscode-languageserver": "^5.1.0" + "vscode-languageserver": "^5.1.0", + "vscode-jsonrpc": "4.0.0" }, "devDependencies": { "@types/compare-versions": "^3.0.0", diff --git a/vscode-dotty/src/extension.ts b/vscode-dotty/src/extension.ts index e9c25ad5ec2c..4a215bab4f43 100644 --- a/vscode-dotty/src/extension.ts +++ b/vscode-dotty/src/extension.ts @@ -3,9 +3,11 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as cpp from 'child-process-promise'; +import * as pcp from 'promisify-child-process'; import * as compareVersions from 'compare-versions'; +import { ChildProcess } from "child_process"; + import { ExtensionContext } from 'vscode'; import * as vscode from 'vscode'; import { LanguageClient, LanguageClientOptions, RevealOutputChannelOn, @@ -14,21 +16,40 @@ import { enableOldServerWorkaround } from './compat' import * as worksheet from './worksheet' +import * as rpc from 'vscode-jsonrpc' +import * as sbtserver from './sbt-server' + let extensionContext: ExtensionContext let outputChannel: vscode.OutputChannel export let client: LanguageClient +/** The sbt process that may have been started by this extension */ +let sbtProcess: ChildProcess | undefined + +const sbtVersion = "1.2.3" +const sbtArtifact = `org.scala-sbt:sbt-launch:${sbtVersion}` +export const workspaceRoot = `${vscode.workspace.rootPath}` +const disableDottyIDEFile = path.join(workspaceRoot, ".dotty-ide-disabled") +const sbtProjectDir = path.join(workspaceRoot, "project") +const sbtPluginFile = path.join(sbtProjectDir, "dotty-plugin.sbt") +const sbtBuildPropertiesFile = path.join(sbtProjectDir, "build.properties") +const sbtBuildSbtFile = path.join(workspaceRoot, "build.sbt") +const languageServerArtifactFile = path.join(workspaceRoot, ".dotty-ide-artifact") + +function isConfiguredProject() { + return ( fs.existsSync(sbtPluginFile) + || fs.existsSync(sbtBuildPropertiesFile) + || fs.existsSync(sbtBuildSbtFile) + ) +} + export function activate(context: ExtensionContext) { extensionContext = context outputChannel = vscode.window.createOutputChannel("Dotty"); - const sbtArtifact = "org.scala-sbt:sbt-launch:1.2.3" - const buildSbtFile = `${vscode.workspace.rootPath}/build.sbt` - const dottyPluginSbtFile = path.join(extensionContext.extensionPath, './out/dotty-plugin.sbt') - const disableDottyIDEFile = `${vscode.workspace.rootPath}/.dotty-ide-disabled` - const languageServerArtifactFile = `${vscode.workspace.rootPath}/.dotty-ide-artifact` - const languageServerDefaultConfigFile = path.join(extensionContext.extensionPath, './out/default-dotty-ide-config') - const coursierPath = path.join(extensionContext.extensionPath, './out/coursier'); + const coursierPath = path.join(extensionContext.extensionPath, "out", "coursier"); + const dottyPluginSbtFileSource = path.join(extensionContext.extensionPath, "out", "dotty-plugin.sbt") + const buildSbtFileSource = path.join(extensionContext.extensionPath, "out", "build.sbt") vscode.workspace.onWillSaveTextDocument(worksheet.prepareWorksheet) vscode.workspace.onDidSaveTextDocument(document => { @@ -43,7 +64,7 @@ export function activate(context: ExtensionContext) { }) if (process.env['DLS_DEV_MODE']) { - const portFile = `${vscode.workspace.rootPath}/.dotty-ide-dev-port` + const portFile = path.join(workspaceRoot, ".dotty-ide-dev-port") fs.readFile(portFile, (err, port) => { if (err) { outputChannel.appendLine(`Unable to parse ${portFile}`) @@ -51,37 +72,112 @@ export function activate(context: ExtensionContext) { } run({ - module: context.asAbsolutePath('out/src/passthrough-server.js'), + module: context.asAbsolutePath(path.join("out", "src", "passthrough-server.js")), args: [ port.toString() ] }, false) }) - } else { - // Check whether `.dotty-ide-artifact` exists. If it does, start the language server, - // otherwise, try propose to start it if there's no build.sbt - if (fs.existsSync(languageServerArtifactFile)) { - runLanguageServer(coursierPath, languageServerArtifactFile) - } else if (!fs.existsSync(disableDottyIDEFile) && !fs.existsSync(buildSbtFile)) { - vscode.window.showInformationMessage( - "This looks like an unconfigured Scala project. Would you like to start the Dotty IDE?", - "Yes", "No" + } else if (!fs.existsSync(disableDottyIDEFile)) { + let configuredProject: Thenable = Promise.resolve() + if (!isConfiguredProject()) { + configuredProject = vscode.window.showInformationMessage( + "This looks like an unconfigured Scala project. Would you like to start the Dotty IDE?", + "Yes", "No" ).then(choice => { - if (choice == "Yes") { - fs.readFile(languageServerDefaultConfigFile, (err, data) => { - if (err) throw err - else { - const languageServerScalaVersion = data.toString().trim() - fetchAndConfigure(coursierPath, sbtArtifact, languageServerScalaVersion, dottyPluginSbtFile).then(() => { - runLanguageServer(coursierPath, languageServerArtifactFile) - }) - } - }) - } else { + if (choice === "Yes") { + bootstrapSbtProject(buildSbtFileSource, dottyPluginSbtFileSource) + return Promise.resolve() + } else if (choice === "No") { fs.appendFile(disableDottyIDEFile, "", _ => {}) + return Promise.reject() } }) + .then(_ => connectToSbt(coursierPath)) + .then(sbt => { + return withProgress("Configuring Dotty IDE...", configureIDE(sbt)) + .then(_ => { sbtserver.tellSbt(outputChannel, sbt, "exit") }) + }) } + + configuredProject + .then(_ => runLanguageServer(coursierPath, languageServerArtifactFile)) + } +} + +/** + * Connect to sbt server (possibly by starting a new instance) and keep verifying that the + * connection is still alive. If it dies, restart sbt server. + */ +function connectToSbt(coursierPath: string): Thenable { + + return offeringToRetry(() => { + return withSbtInstance(coursierPath).then(connection => { + return connection + }) + }, "Couldn't connect to sbt server (see log for details)") +} + +export function deactivate() { + // If sbt was started by this extension, kill the process. + // FIXME: This will be a problem for other clients of this server. + if (sbtProcess) { + sbtProcess.kill() + } +} + +/** + * Display a progress bar with title `title` while `op` completes. + * + * @param title The title of the progress bar + * @param op The thenable that is monitored by the progress bar. + */ +function withProgress(title: string, op: Thenable): Thenable { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Window, + title: title + }, _ => op) +} + +/** Connect to an sbt server and run `configureIDE`. */ +function configureIDE(sbt: rpc.MessageConnection): Thenable { + + const tellSbt = (command: string) => { + return () => sbtserver.tellSbt(outputChannel, sbt, command) } + + const failMessage = "`configureIDE` failed (see log for details)" + + // `configureIDE` is a command, which means that upon failure, sbt won't tell us anything + // until sbt/sbt#4370 is fixed. + // We run `compile` and `test:compile` first because they're tasks (so we get feedback from sbt + // in case of failure), and we're pretty sure configureIDE will pass if they passed. + return offeringToRetry(tellSbt("compile"), failMessage).then(_ => { + return offeringToRetry(tellSbt("test:compile"), failMessage).then(_ => { + return offeringToRetry(tellSbt("configureIDE"), failMessage) + }) + }) +} + +/** + * Present the user with a dialog to retry `op` after a failure, returns its result in case of + * success. + * + * @param op The operation to perform + * @param failMessage The message to display in the dialog offering to retry `op`. + * @return A promise that will either resolve to the result of `op`, or a dialog that will let + * the user retry the operation. + */ +function offeringToRetry(op: () => Thenable, failMessage: string): Thenable { + return op() + .then(success => Promise.resolve(success), + _ => { + outputChannel.show() + return vscode.window.showErrorMessage(failMessage, "Retry?") + .then(retry => { + if (retry) return offeringToRetry(op, failMessage) + else return Promise.reject() + }) + }) } function runLanguageServer(coursierPath: string, languageServerArtifactFile: string) { @@ -101,25 +197,54 @@ function runLanguageServer(coursierPath: string, languageServerArtifactFile: str }) } -function fetchAndConfigure(coursierPath: string, sbtArtifact: string, languageServerScalaVersion: string, dottyPluginSbtFile: string) { - return fetchWithCoursier(coursierPath, sbtArtifact).then((sbtClasspath) => { - return configureIDE(sbtClasspath, languageServerScalaVersion, dottyPluginSbtFile) +function startNewSbtInstance(coursierPath: string) { + fetchWithCoursier(coursierPath, sbtArtifact).then((sbtClasspath) => { + sbtProcess = pcp.spawn("java", [ + "-Dsbt.log.noformat=true", + "-classpath", sbtClasspath, + "xsbt.boot.Boot" + ], { + cwd: workspaceRoot + }) + + // Close stdin, otherwise in case of error sbt will block waiting for the + // user input to reload or exit the build. + sbtProcess.stdin.end() + + sbtProcess.stdout.on('data', data => { + outputChannel.append(data.toString()) + }) + sbtProcess.stderr.on('data', data => { + outputChannel.append(data.toString()) }) + }) +} + +/** + * Connects to an existing sbt server, or boots up one instance and connects to it. + */ +function withSbtInstance(coursierPath: string): Thenable { + const serverSocketInfo = path.join(workspaceRoot, "project", "target", "active.json") + + if (!fs.existsSync(serverSocketInfo)) { + startNewSbtInstance(coursierPath) + } + + return sbtserver.connectToSbtServer(outputChannel) } function fetchWithCoursier(coursierPath: string, artifact: string, extra: string[] = []) { return vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: `Fetching ${ artifact }` - }, (progress) => { + }, _ => { const args = [ "-jar", coursierPath, "fetch", "-p", artifact ].concat(extra) - const coursierPromise = cpp.spawn("java", args) - const coursierProc = coursierPromise.childProcess + const coursierProc = pcp.spawn("java", args) let classPath = "" @@ -138,52 +263,16 @@ function fetchWithCoursier(coursierPath: string, artifact: string, extra: string throw new Error(msg) } }) - return coursierPromise.then(() => { return classPath }) + return coursierProc.then(() => { return classPath }) }) } -function configureIDE(sbtClasspath: string, languageServerScalaVersion: string, dottyPluginSbtFile: string) { - return vscode.window.withProgress({ - location: vscode.ProgressLocation.Window, - title: 'Configuring the IDE for Dotty...' - }, (progress) => { - - // Run sbt to configure the IDE. If the `DottyPlugin` is not present, dynamically load it and - // eventually run `configureIDE`. - const sbtPromise = - cpp.spawn("java", [ - "-Dsbt.log.noformat=true", - "-classpath", sbtClasspath, - "xsbt.boot.Boot", - `--addPluginSbtFile=${dottyPluginSbtFile}`, - `set every scalaVersion := "${languageServerScalaVersion}"`, - "configureIDE" - ]) - - const sbtProc = sbtPromise.childProcess - // Close stdin, otherwise in case of error sbt will block waiting for the - // user input to reload or exit the build. - sbtProc.stdin.end() - - sbtProc.stdout.on('data', (data: Buffer) => { - let msg = data.toString().trim() - outputChannel.appendLine(msg) - }) - sbtProc.stderr.on('data', (data: Buffer) => { - let msg = data.toString().trim() - outputChannel.appendLine(msg) - }) - - sbtProc.on('close', (code: number) => { - if (code != 0) { - const msg = "Configuring the IDE failed." - outputChannel.appendLine(msg) - throw new Error(msg) - } - }) - - return sbtPromise - }) +function bootstrapSbtProject(buildSbtFileSource: string, + dottyPluginSbtFileSource: string) { + fs.mkdirSync(sbtProjectDir) + fs.appendFileSync(sbtBuildPropertiesFile, `sbt.version=${sbtVersion}`) + fs.copyFileSync(buildSbtFileSource, sbtBuildSbtFile) + fs.copyFileSync(dottyPluginSbtFileSource, path.join(sbtProjectDir, "plugins.sbt")) } function run(serverOptions: ServerOptions, isOldServer: boolean) { diff --git a/vscode-dotty/src/sbt-server.ts b/vscode-dotty/src/sbt-server.ts new file mode 100644 index 000000000000..087e6a88d8b1 --- /dev/null +++ b/vscode-dotty/src/sbt-server.ts @@ -0,0 +1,129 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ +// Copy pasted from vscode-sbt-scala + +'use strict' + +import * as fs from 'fs' +import * as net from 'net' +import * as os from 'os' +import * as path from 'path' +import * as url from 'url' + +import * as rpc from 'vscode-jsonrpc' + +import * as vscode from 'vscode' + +import { workspaceRoot } from './extension' + +/** The result of successful `sbt/exec` call. */ +export interface ExecResult { + status: string + channelName: string + execId: number + commandQueue: string[] + exitCode: number +} + +class CommandLine { + commandLine: string + constructor(commandLine: string) { + this.commandLine = commandLine + } +} + +/** + * Sends `command` to sbt with `sbt/exec`. + * + * @param log Where to log messages between this client and sbt server + * @param connection The connection to sbt server to use + * @param command The command to send to sbt + * + * @return The result of executing `command`. + */ +export async function tellSbt(log: vscode.OutputChannel, + connection: rpc.MessageConnection, + command: string): Promise { + log.appendLine(`>>> ${command}`) + const req = new rpc.RequestType("sbt/exec") + return await connection.sendRequest(req, new CommandLine(command)) +} + +/** + * Attempts to connect to an sbt server running in this workspace. + * + * @param log Where to log messages between VSCode and sbt server. + */ +export function connectToSbtServer(log: vscode.OutputChannel): Promise { + return waitForServer().then(socket => { + let connection = rpc.createMessageConnection( + new rpc.StreamMessageReader(socket), + new rpc.StreamMessageWriter(socket)) + + connection.listen() + + connection.onNotification("window/logMessage", (params) => { + log.appendLine(`<<< [${messageTypeToString(params.type)}] ${params.message}`) + }) + + return connection + }) +} + +function connectSocket(socket: net.Socket): net.Socket { + let u = discoverUrl(); + if (u.protocol == 'tcp:' && u.port) { + socket.connect(+u.port, '127.0.0.1'); + } else if (u.protocol == 'local:' && u.hostname && os.platform() == 'win32') { + let pipePath = '\\\\.\\pipe\\' + u.hostname; + socket.connect(pipePath); + } else if (u.protocol == 'local:' && u.path) { + socket.connect(u.path); + } else { + throw 'Unknown protocol ' + u.protocol; + } + return socket; +} + +// the port file is hardcoded to a particular location relative to the build. +function discoverUrl(): url.Url { + let pf = path.join(workspaceRoot, 'project', 'target', 'active.json'); + let portfile = JSON.parse(fs.readFileSync(pf).toString()); + return url.parse(portfile.uri); +} + +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForServer(): Promise { + let socket: net.Socket + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Window, + title: "Connecting to sbt server..." + }, async _ => { + let retries = 60; + while (!socket && retries > 0) { + try { socket = connectSocket(new net.Socket()) } + catch (e) { + retries--; + await delay(1000); + } + } + if (socket) return Promise.resolve(socket) + else return Promise.reject() + }) +} + +function messageTypeToString(messageType: number): string { + if (messageType == 1) return "error" + else if (messageType == 2) return "warn" + else if (messageType == 3) return "info" + else if (messageType == 4) return "log" + else return "???" +} + diff --git a/vscode-dotty/src/types.d.ts b/vscode-dotty/src/types.d.ts deleted file mode 100644 index a15062140cf4..000000000000 --- a/vscode-dotty/src/types.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare module 'child-process-promise' { - - import {ChildProcess} from "child_process"; - - interface ChildPromiseResult { - code: number; - } - - interface ChildProcessPromise extends Promise { - childProcess: ChildProcess; - } - - function spawn(command: string, args: string[]): ChildProcessPromise; -}