Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

electron-forge install #41

Merged
merged 19 commits into from
Dec 31, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7c89359
feat(installer): add inital app installer for macOS platform
MarshallOfSound Dec 28, 2016
56738dc
chore(generic): add installer to cz config
MarshallOfSound Dec 28, 2016
c3ed5b6
refactor(installer): update the ora text wh have resolved a repo but …
MarshallOfSound Dec 28, 2016
8fe867a
feat(installer): add DMG support for macOS installer
MarshallOfSound Dec 28, 2016
c539769
fix(installer): wildcard the extension matchers
MarshallOfSound Dec 29, 2016
61191d3
feat(installer): add deb installer
malept Dec 30, 2016
c29a635
refactor(installer): check that the linux installer program exists first
malept Dec 30, 2016
cf2db11
feat(installer): don't suffix temp install files with .forge-install
malept Dec 31, 2016
826dda1
feat(installer): add rpm installer
malept Dec 31, 2016
2ab37e3
refactor(installer): replace sudo-prompt with git branch of electron-…
malept Dec 31, 2016
a9088af
fix(installer): remove flatpak check
malept Dec 31, 2016
23ea0de
fix(installer): await promises through the linux install chain
MarshallOfSound Dec 31, 2016
0a21433
refactor(installer): finish replacing sudo-prompt with electron-sudo
malept Dec 31, 2016
134b5f9
Fix weird copy/paste
malept Dec 31, 2016
6557791
Revert "refactor(installer): finish replacing sudo-prompt with electr…
MarshallOfSound Dec 31, 2016
847696f
chore(installer): use the ora helper in the install command
MarshallOfSound Dec 31, 2016
17af8c1
fix(installer): dont fetch prerelease versions unless instructed
MarshallOfSound Dec 31, 2016
3b8155c
fix(installer): fix installer debug key
MarshallOfSound Dec 31, 2016
13605e1
refactor(installer): use single regexp to make repo path safe
MarshallOfSound Dec 31, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cz.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = {
{ name: 'tests' },
{ name: 'initializer' },
{ name: 'publisher' },
{ name: 'installer' },
{ name: 'generic' },
],
allowCustomScopes: true,
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand All @@ -64,18 +66,23 @@
"debug": "^2.3.3",
"electron-installer-dmg": "^0.1.2",
"electron-packager": "^8.4.0",
"electron-sudo": "malept/electron-sudo#fix-linux-sudo-detection",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just FYI, this can be changed to a version once automation-stack/electron-sudo#41 is merged & released.

"electron-winstaller": "^2.5.0",
"fs-promise": "^1.0.0",
"github": "^7.2.0",
"glob": "^7.1.1",
"inquirer": "^2.0.0",
"lodash.template": "^4.4.0",
"log-symbols": "^1.0.2",
"node-fetch": "^1.6.3",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels really weird to add two different modules that perform HTTP requests, even though I know that they're serving two different functions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nugget is just for the fancy UI, I could remove it but I think the feedback is good for such a big download.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I kind of wish nugget was built on fetch, or we used some fancy feedback module that was built on fetch. It's not that important.

"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"
Expand Down
150 changes: 150 additions & 0 deletions src/electron-forge-install.js
Original file line number Diff line number Diff line change
@@ -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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This folder should probably be removed when the command completes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, but it's in the tmp directory so the OS will wipe it when it needs to and if we leave it there reinstalls are a million times faster. Idm either way, but it was a godsend during development 😆

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();
1 change: 1 addition & 0 deletions src/electron-forge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/installers/darwin/dmg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import opn from 'opn';

export default async (filePath) => {
await opn(filePath, { wait: false });
};
54 changes: 54 additions & 0 deletions src/installers/darwin/zip.js
Original file line number Diff line number Diff line change
@@ -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 });
};
5 changes: 5 additions & 0 deletions src/installers/linux/deb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { sudo } from '../../util/linux-installer';

export default async (filePath) => {
await sudo('Debian', 'gdebi', `-n ${filePath}`);
};
5 changes: 5 additions & 0 deletions src/installers/linux/rpm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { sudo } from '../../util/linux-installer';

export default async (filePath) => {
await sudo('RPM', 'dnf', `--assumeyes --nogpgcheck install ${filePath}`);
};
29 changes: 29 additions & 0 deletions src/util/linux-installer.js
Original file line number Diff line number Diff line change
@@ -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;