diff --git a/package.json b/package.json index d1fc598ae1..3a54478f90 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": [ @@ -71,11 +73,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..b10bbcaeda --- /dev/null +++ b/src/electron-forge-install.js @@ -0,0 +1,145 @@ +import 'colors'; +import debug from 'debug'; +import fetch from 'node-fetch'; +import fs from 'fs-promise'; +import inquirer from 'inquirer'; +import opn from 'opn'; +import os from 'os'; +import path from 'path'; +import pify from 'pify'; +import program from 'commander'; +import nugget from 'nugget'; +import ora from 'ora'; +import semver from 'semver'; + +import './util/terminate'; + +import darwinZipInstaller from './installers/darwin/zip'; + +const d = debug('electron-forge:lint'); + +const GITHUB_API = 'https://api.github.com'; + +const main = async () => { + const searchSpinner = ora.ora('Searching for Application').start(); + + let repo; + + program + .version(require('../package.json').version) + .arguments('[repository]') + .action((repository) => { + repo = repository; + }) + .parse(process.argv); + + if (!repo || repo.indexOf('/') === -1) { + searchSpinner.fail(); + console.error('Invalid repository name, must be in the format owner/name'.red); + process.exit(1); + } + + 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)) { + searchSpinner.fail(); + console.error(`Failed to find releases for repository "${repo}". Please check the name and try again.`.red); + process.exit(1); + } + + 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); + }); + const latestRelease = sortedReleases[0]; + + const assets = latestRelease.assets; + if (!assets || !Array.isArray(assets)) { + searchSpinner.fail(); + console.error('Could not find any assets for the latest release'.red); + process.exit(1); + } + + const installTargets = { + win32: ['.exe'], + darwin: ['OSX.zip', 'darwin.zip', 'macOS.zip', 'mac.zip'], + linux: ['.rpm', '.deb', '.flatpak'], + }; + + const possibleAssets = assets.filter((asset) => { + const targetSuffixes = installTargets[process.platform]; + for (const suffix of targetSuffixes) { + if (asset.name.endsWith(suffix)) return true; + } + return false; + }); + + if (possibleAssets.length === 0) { + searchSpinner.fail(); + console.error('Failed to find any installable assets for target platform:'.red, process.platform.cyan); + process.exit(1); + } + + searchSpinner.succeed(); + console.info('Found latest release:', `${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, '-').replace(/\\/g, '-'); + const filename = `${pathSafeRepo}-${latestRelease.tag_name}-${targetAsset.name}.forge-install`; + + 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); + } + + const installSpinner = ora.ora('Installing Application').start(); + + const installActions = { + win32: { + '.exe': async filePath => await opn(filePath, { wait: false }), + }, + darwin: { + '.zip': darwinZipInstaller, + }, + linux: { + '.deb': async () => {}, + '.rpm': async () => {}, + '.flatpak': async () => {}, + }, + }; + + const suffixFnIdent = Object.keys(installActions[process.platform]).find(suffix => targetAsset.name.endsWith(suffix)); + await installActions[process.platform][suffixFnIdent](fullFilePath, installSpinner); + + installSpinner.succeed(); +}; + +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/zip.js b/src/installers/darwin/zip.js new file mode 100644 index 0000000000..3ac728422e --- /dev/null +++ b/src/installers/darwin/zip.js @@ -0,0 +1,53 @@ +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) { + throw new Error('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 }); +};