diff --git a/.cz.js b/.cz.js index ac3933dda6..108dc4a708 100644 --- a/.cz.js +++ b/.cz.js @@ -20,6 +20,7 @@ module.exports = { { name: 'tests' }, { name: 'initializer' }, { name: 'publisher' }, + { name: 'installer' }, { name: 'generic' }, ], allowCustomScopes: true, diff --git a/package.json b/package.json index d1fc598ae1..7f50f5b4ba 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "release:patch": "changelog -p && node ci/fix-changelog.js && git add CHANGELOG.md && git commit -m 'updated CHANGELOG.md' && npm version patch && git push origin && git push origin --tags", "release:minor": "changelog -m && node ci/fix-changelog.js && git add CHANGELOG.md && git commit -m 'updated CHANGELOG.md' && npm version minor && git push origin && git push origin --tags", "release:major": "changelog -M && node ci/fix-changelog.js && git add CHANGELOG.md && git commit -m 'updated CHANGELOG.md' && npm version major && git push origin && git push origin --tags", - "watch": "gulp watch" + "watch": "gulp watch", + "watch-link": "nodemon --watch src --exec \"npm link\"" }, "author": "Samuel Attard", "license": "MIT", @@ -38,7 +39,8 @@ "generate-changelog": "^1.0.2", "gulp": "^3.9.1", "gulp-babel": "^6.1.2", - "mocha": "^3.2.0" + "mocha": "^3.2.0", + "nodemon": "^1.11.0" }, "babel": { "presets": [ @@ -64,6 +66,7 @@ "debug": "^2.3.3", "electron-installer-dmg": "^0.1.2", "electron-packager": "^8.4.0", + "electron-sudo": "malept/electron-sudo#fix-linux-sudo-detection", "electron-winstaller": "^2.5.0", "fs-promise": "^1.0.0", "github": "^7.2.0", @@ -71,11 +74,15 @@ "inquirer": "^2.0.0", "lodash.template": "^4.4.0", "log-symbols": "^1.0.2", + "node-fetch": "^1.6.3", "node-gyp": "^3.4.0", + "nugget": "^2.0.1", + "opn": "^4.0.2", "ora": "^0.4.0", "pify": "^2.3.0", "resolve-package": "^1.0.1", "semver": "^5.3.0", + "sudo-prompt": "^6.2.1", "username": "^2.2.2", "yarn-or-npm": "^2.0.2", "zip-folder": "^1.0.0" diff --git a/src/electron-forge-install.js b/src/electron-forge-install.js new file mode 100644 index 0000000000..5708aa8d1e --- /dev/null +++ b/src/electron-forge-install.js @@ -0,0 +1,150 @@ +import 'colors'; +import debug from 'debug'; +import fetch from 'node-fetch'; +import fs from 'fs-promise'; +import inquirer from 'inquirer'; +import nugget from 'nugget'; +import opn from 'opn'; +import os from 'os'; +import path from 'path'; +import pify from 'pify'; +import program from 'commander'; +import semver from 'semver'; + +import './util/terminate'; +import asyncOra from './util/ora-handler'; + +import darwinDMGInstaller from './installers/darwin/dmg'; +import darwinZipInstaller from './installers/darwin/zip'; +import linuxDebInstaller from './installers/linux/deb'; +import linuxRPMInstaller from './installers/linux/rpm'; + +const d = debug('electron-forge:install'); + +const GITHUB_API = 'https://api.github.com'; + +const main = async () => { + let repo; + + program + .version(require('../package.json').version) + .arguments('[repository]') + .option('--prerelease', 'Fetch prerelease versions') + .action((repository) => { + repo = repository; + }) + .parse(process.argv); + + let latestRelease; + let possibleAssets = []; + + await asyncOra('Searching for Application', async (searchSpinner) => { + if (!repo || repo.indexOf('/') === -1) { + // eslint-disable-next-line no-throw-literal + throw 'Invalid repository name, must be in the format owner/name'; + } + + d('searching for repo:', repo); + let releases; + try { + releases = await (await fetch(`${GITHUB_API}/repos/${repo}/releases`)).json(); + } catch (err) { + // Ignore error + } + + if (!releases || releases.message === 'Not Found' || !Array.isArray(releases)) { + // eslint-disable-next-line no-throw-literal + throw `Failed to find releases for repository "${repo}". Please check the name and try again.`; + } + + releases = releases.filter(release => !release.prerelease || program.prerelease); + + const sortedReleases = releases.sort((releaseA, releaseB) => { + let tagA = releaseA.tag_name; + if (tagA.substr(0, 1) === 'v') tagA = tagA.substr(1); + let tagB = releaseB.tag_name; + if (tagB.substr(0, 1) === 'v') tagB = tagB.substr(1); + return (semver.gt(tagB, tagA) ? 1 : -1); + }); + latestRelease = sortedReleases[0]; + + searchSpinner.text = 'Searching for Releases'; // eslint-disable-line + + const assets = latestRelease.assets; + if (!assets || !Array.isArray(assets)) { + // eslint-disable-next-line no-throw-literal + throw 'Could not find any assets for the latest release'; + } + + const installTargets = { + win32: [/\.exe$/], + darwin: [/OSX.*\.zip$/, /darwin.*\.zip$/, /macOS.*\.zip$/, /mac.*\.zip$/, /\.dmg$/], + linux: [/\.rpm$/, /\.deb$/], + }; + + possibleAssets = assets.filter((asset) => { + const targetSuffixes = installTargets[process.platform]; + for (const suffix of targetSuffixes) { + if (suffix.test(asset.name)) return true; + } + return false; + }); + + if (possibleAssets.length === 0) { + // eslint-disable-next-line no-throw-literal + throw `Failed to find any installable assets for target platform: ${`${process.platform}`.cyan}`; + } + }); + + console.info(`Found latest release${program.prerelease ? ' (including prereleases)' : ''}: ${latestRelease.tag_name.cyan}`); + + let targetAsset = possibleAssets[0]; + if (possibleAssets.length > 1) { + const { assetID } = await inquirer.createPromptModule()({ + type: 'list', + name: 'assetID', + message: 'Multiple potential assets found, please choose one from the list below:'.cyan, + choices: possibleAssets.map(asset => ({ name: asset.name, value: asset.id })), + }); + + targetAsset = possibleAssets.find(asset => asset.id === assetID); + } + + const tmpdir = path.resolve(os.tmpdir(), 'forge-install'); + const pathSafeRepo = repo.replace(/[/\\]/g, '-'); + const filename = `${pathSafeRepo}-${latestRelease.tag_name}-${targetAsset.name}`; + + const fullFilePath = path.resolve(tmpdir, filename); + if (!await fs.exists(fullFilePath) || (await fs.stat(fullFilePath)).size !== targetAsset.size) { + await fs.mkdirs(tmpdir); + + const nuggetOpts = { + target: filename, + dir: tmpdir, + resume: true, + strictSSL: true, + }; + await pify(nugget)(targetAsset.browser_download_url, nuggetOpts); + } + + await asyncOra('Installing Application', async (installSpinner) => { + const installActions = { + win32: { + '.exe': async filePath => await opn(filePath, { wait: false }), + }, + darwin: { + '.zip': darwinZipInstaller, + '.dmg': darwinDMGInstaller, + }, + linux: { + '.deb': linuxDebInstaller, + '.rpm': linuxRPMInstaller, + }, + }; + + const suffixFnIdent = Object.keys(installActions[process.platform]).find(suffix => targetAsset.name.endsWith(suffix)); + await installActions[process.platform][suffixFnIdent](fullFilePath, installSpinner); + }); +}; + +main(); diff --git a/src/electron-forge.js b/src/electron-forge.js index 6e03ea262e..8afe4682e6 100644 --- a/src/electron-forge.js +++ b/src/electron-forge.js @@ -30,6 +30,7 @@ import config from './util/config'; .command('make', 'Generate distributables for the current Electron application') .command('start', 'Start the current Electron application') .command('publish', 'Publish the current Electron application to GitHub') + .command('install', 'Install an Electron application from GitHub') .parse(process.argv); config.reset(); diff --git a/src/installers/darwin/dmg.js b/src/installers/darwin/dmg.js new file mode 100644 index 0000000000..d3fb4197e7 --- /dev/null +++ b/src/installers/darwin/dmg.js @@ -0,0 +1,5 @@ +import opn from 'opn'; + +export default async (filePath) => { + await opn(filePath, { wait: false }); +}; diff --git a/src/installers/darwin/zip.js b/src/installers/darwin/zip.js new file mode 100644 index 0000000000..ef717baf03 --- /dev/null +++ b/src/installers/darwin/zip.js @@ -0,0 +1,54 @@ +import fs from 'fs-promise'; +import inquirer from 'inquirer'; +import path from 'path'; +import pify from 'pify'; +import sudo from 'sudo-prompt'; +import { exec, spawn } from 'child_process'; + +export default async (filePath, installSpinner) => { + await new Promise((resolve) => { + const child = spawn('unzip', ['-q', '-o', path.basename(filePath)], { + cwd: path.dirname(filePath), + }); + child.stdout.on('data', () => {}); + child.stderr.on('data', () => {}); + child.on('exit', () => resolve()); + }); + let writeAccess = true; + try { + await fs.access('/Applications', fs.W_OK); + } catch (err) { + writeAccess = false; + } + const appPath = (await fs.readdir(path.dirname(filePath))).filter(file => file.endsWith('.app')) + .map(file => path.resolve(path.dirname(filePath), file)) + .sort((fA, fB) => fs.statSync(fA).ctime.getTime() - fs.statSync(fB).ctime.getTime())[0]; + + const targetApplicationPath = `/Applications/${path.basename(appPath)}`; + if (await fs.exists(targetApplicationPath)) { + installSpinner.stop(); + const { confirm } = await inquirer.createPromptModule()({ + type: 'confirm', + name: 'confirm', + message: `The application "${path.basename(targetApplicationPath)}" appears to already exist in /Applications. Do you want to replace it?`, + }); + if (!confirm) { + // eslint-disable-next-line no-throw-literal + throw 'Installation stopped by user'; + } else { + installSpinner.start(); + await fs.remove(targetApplicationPath); + } + } + + const moveCommand = `mv "${appPath}" "${targetApplicationPath}"`; + if (writeAccess) { + await pify(exec)(moveCommand); + } else { + await pify(sudo.exec)(moveCommand, { + name: 'Electron Forge', + }); + } + + spawn('open', ['-R', targetApplicationPath], { detached: true }); +}; diff --git a/src/installers/linux/deb.js b/src/installers/linux/deb.js new file mode 100644 index 0000000000..04c4a7b7c7 --- /dev/null +++ b/src/installers/linux/deb.js @@ -0,0 +1,5 @@ +import { sudo } from '../../util/linux-installer'; + +export default async (filePath) => { + await sudo('Debian', 'gdebi', `-n ${filePath}`); +}; diff --git a/src/installers/linux/rpm.js b/src/installers/linux/rpm.js new file mode 100644 index 0000000000..687ce5e871 --- /dev/null +++ b/src/installers/linux/rpm.js @@ -0,0 +1,5 @@ +import { sudo } from '../../util/linux-installer'; + +export default async (filePath) => { + await sudo('RPM', 'dnf', `--assumeyes --nogpgcheck install ${filePath}`); +}; diff --git a/src/util/linux-installer.js b/src/util/linux-installer.js new file mode 100644 index 0000000000..54d1226447 --- /dev/null +++ b/src/util/linux-installer.js @@ -0,0 +1,29 @@ +import { spawnSync } from 'child_process'; +import Sudoer from 'electron-sudo'; + +const which = async (type, prog, promise) => { + if (spawnSync('which', [prog]).status === 0) { + await promise; + } else { + throw new Error(`${prog} is required to install ${type} packages`); + } +}; + +export const sudo = (type, prog, args) => + new Promise((resolve, reject) => { + const sudoer = new Sudoer({ name: 'Electron Forge' }); + + which(type, prog, sudoer.spawn(`${prog} ${args}`) + .then((child) => { + child.on('exit', async (code) => { + if (code !== 0) { + console.error(child.output.stdout.toString('utf8').red); + console.error(child.output.stderr.toString('utf8').red); + return reject(new Error(`${prog} failed with status code ${code}`)); + } + resolve(); + }); + })); + }); + +export default which;