enso/gui/build/run.js
2021-08-06 18:56:53 +02:00

616 lines
18 KiB
JavaScript
Executable File

const child_process = require('child_process')
const cmd = require('./cmd')
const fs = require('fs').promises
const fss = require('fs')
const glob = require('glob')
const ncp = require('ncp').ncp
const os = require('os')
const path = require('path')
const paths = require('./paths')
const prettier = require("prettier")
const release = require('./release')
const stream = require('stream')
const workflow = require('./workflow')
const yargs = require('yargs')
const zlib = require('zlib')
const { promisify } = require('util')
const pipe = promisify(stream.pipeline)
// ==============
// === Errors ===
// ==============
process.on('unhandledRejection', error => { throw(error) })
process.chdir(paths.root)
// ========================
// === Global Variables ===
// ========================
/// Arguments passed to cargo build system called from this script. This variable is set to a
/// specific value after the command line args get parsed.
let cargoArgs = undefined
/// Arguments passed to a target binary if any. This variable is set to a specific value after the
// command line args get parsed.
let targetArgs = undefined
// =============
// === Utils ===
// =============
async function gzip(input, output) {
const gzip = zlib.createGzip()
const source = fss.createReadStream(input)
const destination = fss.createWriteStream(output)
await pipe(source,gzip,destination)
}
/// Copy files and directories.
async function copy(src,tgt) {
return new Promise((resolve, reject) => {
ncp(src,tgt,(err) => {
if (err) { reject(`${err}`) }
resolve()
})
})
}
/// Run the command with the provided args and all args passed to this script after the `--` symbol.
async function run_cargo(command,args) {
await cmd.run(command,args.concat(cargoArgs))
}
/// Run the command with the provided args and all args passed to this script after the `--` symbol.
async function run(command,args) {
await cmd.run(command,args)
}
/// Defines a new command argument builder.
function command(docs) {
return {docs}
}
/// Build the project manager module, which downloads the project manager binary for the current
/// platform.
async function build_project_manager() {
console.log(`Getting project manager manager.`)
await cmd.with_cwd(paths.js.lib.projectManager , async () => {
await run('npm',['run-script build'])
})
}
/// Run the local project manager binary.
function run_project_manager() {
const bin_path = paths.get_project_manager_path(paths.dist.bin)
console.log(`Starting the project manager from "${bin_path}".`)
child_process.execFile(bin_path, [], (error, stdout, stderr) => {
console.error(stderr)
if (error) {
throw error
}
console.log(stdout)
console.log(`Project manager running.`)
})
}
// ================
// === Commands ===
// ================
const DEFAULT_CRATE = 'ide'
let commands = {}
// === Clean ===
commands.clean = command(`Clean all build artifacts`)
commands.clean.js = async function() {
await cmd.with_cwd(paths.js.root, async () => {
await run('npm',['run','clean'])
})
try {
await fs.unlink(paths.dist.init)
await fs.unlink(paths.dist.buildInit)
} catch {}
}
commands.clean.rust = async function() {
await run_cargo('cargo',['clean'])
}
// === Check ===
commands.check = command(`Fast check if project builds (only Rust target)`)
commands.check.rust = async function() {
await run_cargo('cargo',['check'])
}
// === Build ===
commands.build = command(`Build the sources in release mode`)
commands.build.options = {
'crate': {
describe : 'Target crate to build',
type : 'string',
}
}
commands.build.js = async function() {
await installJsDeps()
console.log(`Building JS target.`)
await run('npm',['run','build'])
}
commands.build.rust = async function(argv) {
let crate = argv.crate || DEFAULT_CRATE
let crate_sfx = crate ? ` '${crate}'` : ``
console.log(`Building WASM target${crate_sfx}.`)
let args = ['build','--target','web','--out-dir',paths.dist.wasm.root,'--out-name','ide',crate]
if (argv.dev) { args.push('--dev') }
await run_cargo('wasm-pack',args)
await patch_file(paths.dist.wasm.glue, js_workaround_patcher)
await fs.rename(paths.dist.wasm.mainRaw, paths.dist.wasm.main)
if (!argv.dev) {
// TODO: Enable after updating wasm-pack
// https://github.com/rustwasm/wasm-pack/issues/696
// console.log('Optimizing the WASM binary.')
// await cmd.run('npx',['wasm-opt','-O3','-o',paths.dist.wasm.mainOpt,paths.dist.wasm.main])
console.log('Minimizing the WASM binary.')
await gzip(paths.dist.wasm.main,paths.dist.wasm.mainOptGz) // TODO main -> mainOpt
console.log('Checking the resulting WASM size.')
let stats = fss.statSync(paths.dist.wasm.mainOptGz)
let limit = 4.60
let size = Math.round(100 * stats.size / 1024 / 1024) / 100
if (size > limit) {
throw(`Output file size exceeds the limit (${size}MB > ${limit}MB).`)
}
}
}
/// Workaround fix by wdanilo, see: https://github.com/rustwasm/wasm-pack/issues/790
function js_workaround_patcher(code) {
code = code.replace(/if \(\(typeof URL.*}\);/gs,'return imports')
code = code.replace(/if \(typeof module.*let result/gs,'let result')
code = code.replace(/export default init;/gs,'export default init')
code += '\nexport function after_load\(w,m\) { wasm = w; init.__wbindgen_wasm_module = m;}'
return code
}
async function patch_file(path,patcher) {
let code_to_patch = await fs.readFile(path,'utf8')
let patched_code = patcher(code_to_patch)
await fs.writeFile(path,patched_code)
}
// === Start ===
commands.start = command(`Build and start desktop client`)
commands.start.rust = async function(argv) {
let argv2 = Object.assign({},argv,{dev:true})
await commands.build.rust(argv2)
}
commands.start.js = async function (argv) {
await installJsDeps()
console.log(`Building JS target.` + argv)
// The backend path is being prepended here, as appending would be incorrect.
// That is because `targetArgs` might include `-- …` and appended args could
// end up being passed to the spawned backend process.
const args = ['--backend-path', paths.get_project_manager_path(paths.dist.bin)].concat(targetArgs)
if (argv.dev) { args.push('--dev') }
await cmd.with_cwd(paths.js.root, async () => {
await run('npm', ['run', 'start', '--'].concat(args))
})
}
// === Test ===
commands.test = command(`Run test suites`)
commands.test.rust = async function(argv) {
if (argv.native) {
console.log(`Running Rust test suite.`)
await run_cargo('cargo',['test'])
}
if (argv.wasm) {
console.log(`Running Rust WASM test suite.`)
let args = ['run','--manifest-path=test/Cargo.toml','--bin','test_all','--','--headless','--chrome']
await run_cargo('cargo',args)
}
}
// === Lint ===
commands.lint = command(`Lint the codebase`)
commands.lint.rust = async function() {
// We run clippy-preview due to https://github.com/rust-lang/rust-clippy/issues/4612
await run_cargo('cargo',['clippy','--','-D','warnings'])
}
// === TomlFmt ===
commands['toml-fmt'] = command(`Lint the codebase`)
commands['toml-fmt'].rust = async function() {
console.log("Looking for all TOML files.")
let files = glob.sync(paths.rust.root + "/**/*.toml", {cwd:paths.root});
console.log(`Found ${files.length} entries. Running auto-formatter.`)
for (let file of files) {
console.log(` Formatting '${file}'.`)
let text = fss.readFileSync(file, "utf8")
let out = prettier.format(text,{parser:'toml'})
fss.writeFileSync(file,out)
}
}
// === Watch ===
commands.watch = command(`Start a file-watch utility and run interactive mode`)
commands.watch.options = Object.assign({},commands.build.options)
commands.watch.parallel = false
commands.watch.common = async function(argv) {
argv.dev = true
// Init JS build and project manager.
await installJsDeps()
if (argv.backend !== 'false') {
await build_project_manager().then(run_project_manager)
}
// Run build processes.
await cmd.with_cwd(paths.rust.root, async () => {
return commands.build.rust(argv);
})
await cmd.with_cwd(paths.js.root, async () => {
return commands.build.js(argv)
})
// Run watch processes.
const rust_process = cmd.with_cwd(paths.rust.root, async () => {
let build_args = []
if (argv.crate !== undefined) {
build_args.push(`--crate=${argv.crate}`)
}
build_args = build_args.join(' ')
const target =
'"' +
`node ${paths.script.main} build --skip-version-validation --no-js --dev ${build_args} -- ` +
cargoArgs.join(' ') +
'"'
let args = ['watch', '-s', `${target}`]
return cmd.run('cargo',args)
})
const js_process = cmd.with_cwd(paths.js.root, async () => {
return run('npm',['run','watch']);
})
await rust_process
await js_process
}
// === Dist ===
commands.dist = command(`Build the sources and create distribution packages`)
commands.dist.rust = async function(argv) {
await commands.build.rust(argv)
}
commands.dist.js = async function() {
await installJsDeps()
await cmd.with_cwd(paths.js.root, async () => {
await run('npm',['run','dist'])
})
}
// === CI Gen ===
/// The command is used by CI to generate the file `CURRENT_RELEASE_CHANGELOG.json`, which contains
/// information about the newest release. It is then used by CI to generate version and description
/// of the product release.
commands['ci-gen'] = command(`Generate CI build related files`)
commands['ci-gen'].rust = async function(argv) {
let entry = release.changelog().newestEntry()
let body = entry.body
let version = entry.version.toString()
let prerelease = entry.isPrerelease()
let obj = {version,body,prerelease};
let json = JSON.stringify(obj)
fss.writeFileSync(path.join(paths.root,'CURRENT_RELEASE_CHANGELOG.json'),json)
}
/// Asserts whether the current version of the package (newest in CHANGELOG.md) is unstable.
commands['assert-version-unstable'] = command(`Assert the current version is unstable`)
commands['assert-version-unstable'].rust = async function(argv) {
let entry = release.changelog().newestEntry().assert_is_unstable()
}
/// Asserts whether the current version of the package (newest in CHANGELOG.md) is stable.
commands['assert-version-stable'] = command(`Assert the current version is stable`)
commands['assert-version-stable'].rust = async function(argv) {
let entry = release.changelog().newestEntry().assert_is_stable()
}
// ===========================
// === Command Line Parser ===
// ===========================
let usage = `run command [options]
All arguments after '--' will be passed to cargo build system.
All arguments after second '--' will be passed to target executable if any.
For example, 'run start -- --dev -- --debug-scene shapes' will pass '--dev' to cargo \
and '--debug-scene shapes' to the output binary.`
let optParser = yargs
.scriptName("")
.usage(usage)
.help()
.parserConfiguration({'populate--':true})
.demandCommand()
optParser.options('rust', {
describe : 'Run the Rust target',
type : 'bool',
default : true
})
optParser.options('js', {
describe : 'Run the JavaScript target',
type : 'bool',
default : true
})
optParser.options('release', {
describe : "Enable all optimizations",
type : 'bool',
})
optParser.options('dev', {
describe : "Optimize for fast builds",
type : 'bool',
})
optParser.options('target', {
describe:
'Set the build target. Defaults to the current platform. ' +
'Valid values are: "linux" "macos" and "win"',
type: 'string',
})
optParser.options('backend', {
describe: 'Start the backend process automatically [true]',
type: 'bool',
default: true,
})
let commandList = Object.keys(commands)
commandList.sort()
for (let command of commandList) {
let config = commands[command]
optParser.command(command,config.docs,(args) => {
for (let option in config.options) {
args.options(option,config.options[option])
}
for (let arg in config.args) {
args.positional(arg,config.args[arg])
}
args.options('native', {
describe : 'Run native tests',
type : 'bool',
default : true
})
args.options('wasm', {
describe : 'Run WASM tests',
type : 'bool',
default : true
})
})
}
// ======================
// === Package Config ===
// ======================
function defaultConfig() {
return {
version: `${release.currentVersion()}`,
author: {
name: "Enso Team",
email: "contact@enso.org"
},
homepage: "https://github.com/enso-org/ide",
repository: {
type: "git",
url: "git@github.com:enso-org/ide.git"
},
bugs: {
url: "https://github.com/enso-org/ide/issues"
},
}
}
async function processPackageConfigs() {
let files = []
files = files.concat(glob.sync(paths.js.root + "/package.js", {cwd:paths.root}))
files = files.concat(glob.sync(paths.js.root + "/lib/*/package.js", {cwd:paths.root}))
for (file of files) {
let dirPath = path.dirname(file)
let outPath = path.join(dirPath,'package.json')
let src = await fs.readFile(file,'utf8')
let modSrc = `module = {}\n${src}\nreturn module.exports`
let fn = new Function('require','paths',modSrc)
let mod = fn(require,paths)
let config = mod.config
if (!config) { throw(`Package config '${file}' do not export 'module.config'.`) }
config = Object.assign(defaultConfig(),config)
fs.writeFile(outPath,JSON.stringify(config,undefined,4))
}
}
// ============
// === Main ===
// ============
async function updateBuildVersion (argv) {
const target = get_target_platform(argv)
let config = {}
let configPath = paths.dist.buildInfo
let exists = fss.existsSync(configPath)
if(exists) {
let configFile = await fs.readFile(configPath)
config = JSON.parse(configFile)
}
let commitHashCmd = await cmd.run_read('git', [
'rev-parse',
'--short',
'HEAD'
])
let commitHash = commitHashCmd.trim()
if (config.buildVersion !== commitHash || config.target !== target){
config.target = target
config.buildVersion = commitHash
await fs.mkdir(paths.dist.root, { recursive: true })
await fs.writeFile(configPath, JSON.stringify(config, undefined, 2))
}
}
async function installJsDeps() {
let initialized = fss.existsSync(paths.dist.init)
if (!initialized) {
console.log('Downloading binary assets.')
await downloadJsAssets()
console.log('Installing application dependencies.')
await cmd.with_cwd(paths.js.root, async () => {
await cmd.run('npm',['run','install'])
})
await fs.mkdir(paths.dist.root, {recursive:true})
let handle = await fs.open(paths.dist.init,'w')
await handle.close()
}
}
async function downloadJsAssets() {
const workdir = path.join(paths.root, '.assets-temp')
await cmd.with_cwd(paths.root, async () => {
await cmd.run('mkdir', ['-p', workdir])
})
const ideAssetsMainZip = 'ide-assets-main.zip'
const unzippedAssets = path.join(workdir, 'ide-assets-main', 'content', 'assets')
const jsLibAssets = path.join(paths.js.lib.content, 'assets')
await cmd.with_cwd(workdir, async () => {
await cmd.run('curl', [
'--retry',
'4',
'--retry-connrefused',
'-fsSL',
'-o',
ideAssetsMainZip,
'https://github.com/enso-org/ide-assets/archive/refs/heads/main.zip',
])
await cmd.run('unzip', [ideAssetsMainZip])
})
await cmd.with_cwd(paths.root, async () => {
await cmd.run('cp', ['-r', unzippedAssets, paths.js.lib.content])
await cmd.run('rm', ['-r', workdir])
})
}
async function runCommand(command, argv) {
let config = commands[command]
cargoArgs = argv['--']
if (config === undefined) {
console.error(`Invalid command '${command}'.`)
return
}
if(cargoArgs === undefined) { cargoArgs = [] }
let index = cargoArgs.indexOf('--')
if (index == -1) {
targetArgs = []
}
else {
targetArgs = cargoArgs.slice(index + 1)
cargoArgs = cargoArgs.slice(0,index)
}
let runner = async function () {
let do_common = config.common
let do_rust = argv.rust && config.rust
let do_js = argv.js && config.js
let commonCmd = () => cmd.with_cwd(paths.root, async () => await config.common(argv))
let rustCmd = () => cmd.with_cwd(paths.rust.root, async () => await config.rust(argv))
let jsCmd = () => cmd.with_cwd(paths.js.root , async () => await config.js(argv))
if(config.parallel) {
let promises = []
if (do_common ) { promises.push(commonCmd()) }
if (do_rust) { promises.push(rustCmd()) }
if (do_js) { promises.push(jsCmd()) }
await Promise.all(promises)
} else {
if (do_common) { await commonCmd() }
if (do_rust) { await rustCmd() }
if (do_js) { await jsCmd() }
}
}
cmd.section(command)
runner()
}
function get_target_platform(argv) {
let target = argv.target
if (target === undefined) {
const local_platform = os.platform()
switch (local_platform) {
case 'darwin':
return 'macos'
case 'win32':
return 'win'
default:
return local_platform
}
}
return target
}
async function main () {
let argv = optParser.parse()
await updateBuildVersion(argv)
await processPackageConfigs()
workflow.generate()
let command = argv._[0]
await runCommand(command,argv)
}
main()