From d0b1b6b18bff966215151e50cc7b5635736b9df0 Mon Sep 17 00:00:00 2001 From: Mark Lee Date: Thu, 24 Jan 2019 11:05:24 -0800 Subject: [PATCH] Convert to use the electron-installer-common ElectronInstaller class (#174) --- package.json | 18 +- src/installer.js | 359 ++++++++++++++++++----------------- test/cli.js | 26 +-- test/helpers/dependencies.js | 37 ---- test/installer.js | 84 -------- 5 files changed, 197 insertions(+), 327 deletions(-) delete mode 100644 test/helpers/dependencies.js diff --git a/package.json b/package.json index 59740a8..eb76eba 100644 --- a/package.json +++ b/package.json @@ -33,19 +33,19 @@ "node": ">=6.0.0" }, "dependencies": { - "debug": "^4.0.1", - "electron-installer-common": "^0.5.0", - "fs-extra": "^7.0.0", - "get-folder-size": "^2.0.0", + "debug": "^4.1.1", + "electron-installer-common": "^0.6.0", + "fs-extra": "^7.0.1", + "get-folder-size": "^2.0.1", "lodash": "^4.17.4", - "pify": "^4.0.0", - "semver": "^5.4.1", + "pify": "^4.0.1", + "semver": "^5.6.0", "word-wrap": "^1.2.3", - "yargs": "^12.0.2" + "yargs": "^12.0.5" }, "devDependencies": { "chai": "^4.1.2", - "eslint": "^5.6.1", + "eslint": "^5.12.1", "eslint-config-standard": "^12.0.0", "eslint-plugin-import": "^2.7.0", "eslint-plugin-node": "^8.0.1", @@ -53,7 +53,7 @@ "eslint-plugin-standard": "^4.0.0", "mocha": "^5.0.5", "mz": "^2.7.0", - "nyc": "^13.0.1", + "nyc": "^13.1.0", "promise-retry": "^1.1.1", "tmp-promise": "^1.0.5" } diff --git a/src/installer.js b/src/installer.js index 580ce76..880bcf0 100644 --- a/src/installer.js +++ b/src/installer.js @@ -5,6 +5,7 @@ const common = require('electron-installer-common') const debug = require('debug') const fs = require('fs-extra') const fsize = require('get-folder-size') +const parseAuthor = require('parse-author') const path = require('path') const pify = require('pify') const wrap = require('word-wrap') @@ -18,13 +19,6 @@ const defaultRename = (dest, src) => { return path.join(dest, '<%= name %>_<%= version %>_<%= arch %>.deb') } -/** - * Get the size of the app. - */ -function getSize (appDir) { - return pify(fsize)(appDir) -} - /** * Transforms a SemVer version into a Debian-style version. * @@ -35,187 +29,207 @@ function transformVersion (version) { return version.replace(/(\d)[_.+-]?((RC|rc|pre|dev|beta|alpha)[_.+-]?\d*)$/, '$1~$2') } -/** - * Get the hash of default options for the installer. Some come from the info - * read from `package.json`, and some are hardcoded. - */ -function getDefaults (data) { - return Promise.all([common.readMeta(data), getSize(data.src), common.readElectronVersion(data.src)]) - .then(results => { - const pkg = results[0] || {} - const size = results[1] || 0 - const electronVersion = results[2] - - return Object.assign(common.getDefaultsFromPackageJSON(pkg), { - version: transformVersion(pkg.version || '0.0.0'), +class DebianInstaller extends common.ElectronInstaller { + get contentFunctions () { + return [ + 'copyApplication', + 'copyLinuxIcons', + 'copyScripts', + 'createBinarySymlink', + 'createControl', + 'createCopyright', + 'createDesktopFile', + 'createOverrides' + ] + } - section: 'utils', - priority: 'optional', - size: Math.ceil(size / 1024), + get defaultDesktopTemplatePath () { + return path.resolve(__dirname, '../resources/desktop.ejs') + } - maintainer: pkg.author && (typeof pkg.author === 'string' - ? pkg.author.replace(/\s+\([^)]+\)/, '') - : pkg.author.name + - (pkg.author.email != null ? ` <${pkg.author.email}>` : '') - ), + get packagePattern () { + return path.join(this.stagingDir, '../*.deb') + } - icon: path.resolve(__dirname, '../resources/icon.png'), - lintianOverrides: [] - }, debianDependencies.forElectron(electronVersion)) - }) -} + /** + * Copy the application into the package. + */ + copyApplication () { + return super.copyApplication(src => src !== path.join(this.options.src, 'LICENSE')) + } -/** - * Sanitize package name per Debian docs: - * https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-source - */ -function sanitizeName (name) { - const sanitized = common.sanitizeName(name.toLowerCase(), '-+.a-z0-9') - if (sanitized.length < 2) { - throw new Error('Package name must be at least two characters') + /** + * Copy debian scripts. + */ + copyScripts () { + const scriptNames = ['preinst', 'postinst', 'prerm', 'postrm'] + + return Promise.all(_.map(this.options.scripts, (item, key) => { + if (_.includes(scriptNames, key)) { + const scriptFile = path.join(this.stagingDir, 'DEBIAN', key) + this.options.logger(`Creating script file at ${scriptFile}`) + + return fs.copy(item, scriptFile) + } else { + throw new Error(`Wrong executable script name: ${key}`) + } + })).catch(common.wrapError('creating script files')) } - if (/^[^a-z0-9]/.test(sanitized)) { - throw new Error('Package name must start with an ASCII number or letter') + + /** + * Creates the control file for the package. + * + * See: https://www.debian.org/doc/debian-policy/ch-controlfields.html + */ + createControl () { + const src = path.resolve(__dirname, '../resources/control.ejs') + const dest = path.join(this.stagingDir, 'DEBIAN', 'control') + this.options.logger(`Creating control file at ${dest}`) + + return this.createTemplatedFile(src, dest) + .catch(common.wrapError('creating control file')) } - return sanitized -} + /** + * Create lintian overrides for the package. + */ + createOverrides () { + const src = path.resolve(__dirname, '../resources/overrides.ejs') + const dest = path.join(this.stagingDir, this.baseAppDir, 'share/lintian/overrides', this.options.name) + this.options.logger(`Creating lintian overrides at ${dest}`) -/** - * Get the hash of options for the installer. - */ -function getOptions (data, defaults) { - // Flatten everything for ease of use. - const options = _.defaults({}, data, data.options, defaults) + return this.createTemplatedFile(src, dest, '0644') + .catch(common.wrapError('creating lintian overrides file')) + } - options.name = sanitizeName(options.name) + /** + * Package everything using `dpkg` and `fakeroot`. + */ + createPackage () { + this.options.logger(`Creating package at ${this.stagingDir}`) - if (!options.description && !options.productDescription) { - throw new Error(`No Description or ProductDescription provided. Please set either a description in the app's package.json or provide it in the options.`) + return spawn('fakeroot', ['dpkg-deb', '--build', this.stagingDir], this.options.logger) + .then(output => this.options.logger(`dpkg-deb output: ${output}`)) } - if (options.description) { - // Replace all newlines in the description with spaces, since it's supposed - // to be one line. - options.description = options.description.replace(/[\r\n]+/g, ' ') - } + /** + * Get the hash of default options for the installer. Some come from the info + * read from `package.json`, and some are hardcoded. + */ + generateDefaults () { + return Promise.all([ + common.readMetadata(this.userSupplied), + this.getSize(this.userSupplied.src), + common.readElectronVersion(this.userSupplied.src) + ]).then(([pkg, size, electronVersion]) => { + pkg = pkg || {} + + this.defaults = Object.assign(common.getDefaultsFromPackageJSON(pkg), { + version: transformVersion(pkg.version || '0.0.0'), - if (options.productDescription) { - // Ensure blank lines have the "." that denotes a blank line in the control file. - // Wrap any extended description lines to avoid lintian warning about - // `extended-description-line-too-long`. - options.productDescription = options.productDescription - .replace(/\r\n/g, '\n') // Fixes errors when finding blank lines in Windows - .replace(/^$/mg, '.') - .split('\n') - .map(line => wrap(line, { width: 80, indent: ' ' })) - .join('\n') - } + section: 'utils', + priority: 'optional', + size: Math.ceil((size || 0) / 1024), + + maintainer: this.getMaintainer(pkg.author), + + icon: path.resolve(__dirname, '../resources/icon.png'), + lintianOverrides: [] + }, debianDependencies.forElectron(electronVersion)) - // Create array with unique values from default & user-supplied dependencies - for (const prop of ['depends', 'recommends', 'suggests', 'enhances', 'preDepends']) { - options[prop] = common.mergeUserSpecified(data, prop, defaults) + return this.defaults + }) } - return options -} + /** + * Flattens and merges default values, CLI-supplied options, and API-supplied options. + */ + generateOptions () { + super.generateOptions() -/** - * Create the control file for the package. - * - * See: https://www.debian.org/doc/debian-policy/ch-controlfields.html - */ -function createControl (options, dir) { - const controlSrc = path.resolve(__dirname, '../resources/control.ejs') - const controlDest = path.join(dir, 'DEBIAN/control') - options.logger(`Creating control file at ${controlDest}`) - - return fs.ensureDir(path.dirname(controlDest), '0755') - .then(() => common.generateTemplate(options, controlSrc)) - .then(data => fs.outputFile(controlDest, data)) - .catch(common.wrapError('creating control file')) -} + this.options.name = this.sanitizeName(this.options.name) -/** - * Copy debian scripts. - */ -function copyScripts (options, dir) { - const scriptNames = ['preinst', 'postinst', 'prerm', 'postrm'] + if (!this.options.description && !this.options.productDescription) { + throw new Error(`No Description or ProductDescription provided. Please set either a description in the app's package.json or provide it in the this.options.`) + } - return Promise.all(_.map(options.scripts, (item, key) => { - if (_.includes(scriptNames, key)) { - const scriptFile = path.join(dir, 'DEBIAN', key) - options.logger(`Creating script file at ${scriptFile}`) + if (this.options.description) { + this.options.description = this.normalizeDescription(this.options.description) + } - return fs.copy(item, scriptFile) - } else { - throw new Error(`Wrong executable script name: ${key}`) + if (this.options.productDescription) { + this.options.productDescription = this.normalizeExtendedDescription(this.options.productDescription) } - })).catch(common.wrapError('creating script files')) -} -function createDesktop (options, dir) { - const desktopSrc = options.desktopTemplate || path.resolve(__dirname, '../resources/desktop.ejs') - return common.createDesktop(options, dir, desktopSrc) -} + // Create array with unique values from default & user-supplied dependencies + for (const prop of ['depends', 'recommends', 'suggests', 'enhances', 'preDepends']) { + this.options[prop] = common.mergeUserSpecified(this.userSupplied, prop, this.defaults) + } -/** - * Create lintian overrides for the package. - */ -function createOverrides (options, dir) { - const overridesSrc = path.resolve(__dirname, '../resources/overrides.ejs') - const overridesDest = path.join(dir, 'usr/share/lintian/overrides', options.name) - options.logger(`Creating lintian overrides at ${overridesDest}`) - - return fs.ensureDir(path.dirname(overridesDest), '0755') - .then(() => common.generateTemplate(options, overridesSrc)) - .then(data => fs.outputFile(overridesDest, data)) - .then(() => fs.chmod(overridesDest, '0644')) - .catch(common.wrapError('creating lintian overrides file')) -} + return this.options + } -/** - * Copy the application into the package. - */ -function createApplication (options, dir) { - return common.copyApplication(options, dir, null, src => src !== path.join(options.src, 'LICENSE')) -} + /** + * Generates a Debian-compliant maintainer value from a package.json author field. + */ + getMaintainer (author) { + if (author) { + if (typeof author === 'string') { + author = parseAuthor(author) + } + const maintainer = [author.name] + if (author.email) { + maintainer.push(`<${author.email}>`) + } + + return maintainer.join(' ') + } + } -/** - * Create the contents of the package. - */ -function createContents (options, dir) { - return common.createContents(options, dir, [ - createControl, - copyScripts, - common.createBinary, - createDesktop, - common.createIcon, - common.createCopyright, - createOverrides, - createApplication - ]) -} + /** + * Get the size of the app. + */ + getSize (appDir) { + return pify(fsize)(appDir) + } -/** - * Package everything using `dpkg` and `fakeroot`. - */ -function createPackage (options, dir) { - options.logger(`Creating package at ${dir}`) + /** + * Normalize the description by replacing all newlines in the description with spaces, since it's + * supposed to be one line. + */ + normalizeDescription (description) { + return description.replace(/[\r\n]+/g, ' ') + } - return spawn('fakeroot', ['dpkg-deb', '--build', dir], options.logger) - .then(output => { - options.logger(`dpkg-deb output: ${output}`) - return dir - }) -} + /** + * Ensure blank lines have the "." that denotes a blank line in the control file. Wrap any + * extended description lines to avoid lintian warnings about + * `extended-description-line-too-long`. + */ + normalizeExtendedDescription (extendedDescription) { + return extendedDescription + .replace(/\r\n/g, '\n') // Fixes errors when finding blank lines in Windows + .replace(/^$/mg, '.') + .split('\n') + .map(line => wrap(line, { width: 80, indent: ' ' })) + .join('\n') + } -/** - * Move the package to the specified destination. - */ -function movePackage (options, dir) { - const packagePattern = path.join(dir, '../*.deb') - return common.movePackage(packagePattern, options, dir) + /** + * Sanitize package name per Debian docs: + * https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-source + */ + sanitizeName (name) { + const sanitized = common.sanitizeName(name.toLowerCase(), '-+.a-z0-9') + if (sanitized.length < 2) { + throw new Error('Package name must be at least two characters') + } + if (/^[^a-z0-9]/.test(sanitized)) { + throw new Error('Package name must start with an ASCII number or letter') + } + + return sanitized + } } /* ************************************************************************** */ @@ -224,28 +238,27 @@ module.exports = data => { data.rename = data.rename || defaultRename data.logger = data.logger || defaultLogger - let options - if (process.umask() !== 0o0022 && process.umask() !== 0o0002) { console.warn(`The current umask, ${process.umask().toString(8)}, is not supported. You should use 0022 or 0002`) } - return getDefaults(data) - .then(defaults => getOptions(data, defaults)) - .then(generatedOptions => { - options = generatedOptions - return data.logger(`Creating package with options\n${JSON.stringify(options, null, 2)}`) - }).then(() => common.createDir(options)) - .then(dir => createContents(options, dir)) - .then(dir => createPackage(options, dir)) - .then(dir => movePackage(options, dir)) + const installer = new DebianInstaller(data) + + return installer.generateDefaults() + .then(() => installer.generateOptions()) + .then(() => data.logger(`Creating package with options\n${JSON.stringify(installer.options, null, 2)}`)) + .then(() => installer.createStagingDir()) + .then(() => installer.createContents()) + .then(() => installer.createPackage()) + .then(() => installer.movePackage()) .then(() => { - data.logger(`Successfully created package at ${options.dest}`) - return options + data.logger(`Successfully created package at ${installer.options.dest}`) + return installer.options }).catch(err => { data.logger(common.errorMessage('creating package', err)) throw err }) } +module.exports.Installer = DebianInstaller module.exports.transformVersion = transformVersion diff --git a/test/cli.js b/test/cli.js index 390dedc..d8ccb4d 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,16 +1,10 @@ 'use strict' -const fs = require('fs-extra') -const path = require('path') - const access = require('./helpers/access') -const dependencies = require('./helpers/dependencies') -const describeInstaller = require('./helpers/describe_installer') +const { cleanupOutputDir, tempOutputDir } = require('./helpers/describe_installer') +const path = require('path') const spawn = require('../src/spawn') -const cleanupOutputDir = describeInstaller.cleanupOutputDir -const tempOutputDir = describeInstaller.tempOutputDir - function runCLI (options) { const args = [ '--src', options.src, @@ -44,20 +38,4 @@ describe('cli', function () { cleanupOutputDir(outputDir) }) - - describe('with duplicate dependencies', function (test) { - const outputDir = tempOutputDir() - const config = 'test/fixtures/config.json' - - runCLI({ src: 'test/fixtures/app-with-asar/', dest: outputDir, arch: 'i386', config: config }) - - it('removes duplicate dependencies', () => - access(path.join(outputDir, 'footest_0.0.1_i386.deb')) - // object with both user and default dependencies based on src/installer.js - .then(() => fs.readJson(config)) - .then(configObj => dependencies.assertDependenciesEqual(outputDir, 'footest_0.0.1_i386.deb', configObj)) - ) - - cleanupOutputDir(outputDir) - }) }) diff --git a/test/helpers/dependencies.js b/test/helpers/dependencies.js deleted file mode 100644 index 9f3f6bf..0000000 --- a/test/helpers/dependencies.js +++ /dev/null @@ -1,37 +0,0 @@ -'use strict' - -const _ = require('lodash') -const { exec } = require('mz/child_process') - -const dependencies = require('../../src/dependencies') - -const defaults = dependencies.forElectron('v1.0.0') - -module.exports = { - assertDependenciesEqual: function assertDependenciesEqual (outputDir, debFilename, userDependencies) { - const dpkgDebCmd = `dpkg-deb -f ${debFilename} Depends Recommends Suggests Enhances Pre-Depends` - return exec(dpkgDebCmd, { cwd: outputDir }) - .then(stdout => { - const baseDependencies = { - Depends: _.sortBy(_.union(defaults.depends, userDependencies.depends)), - Recommends: _.sortBy(_.union(defaults.recommends, userDependencies.recommends)), - Suggests: _.sortBy(_.union(defaults.suggests, userDependencies.suggests)), - Enhances: _.sortBy(_.union(defaults.enhances, userDependencies.enhances)), - 'Pre-Depends': _.sortBy(_.union(defaults.preDepends, userDependencies.preDepends)) - } // object with both user and default dependencies based on src/installer.js - - // Creates object based on stdout (values are still strings) - let destDependencies = _.fromPairs(_.chunk(_.initial(stdout.toString().split(/\n|:\s/)), 2)) - // String values are mapped into sorted arrays - destDependencies = _.mapValues(destDependencies, function (value) { - if (value) return _.sortBy(value.split(', ')) - }) - - if (!_.isEqual(baseDependencies, destDependencies)) { - throw new Error(`There are duplicate dependencies\nExpected: ${JSON.stringify(baseDependencies)}\nActual: ${JSON.stringify(destDependencies)}`) - } - - return Promise.resolve() - }) - } -} diff --git a/test/installer.js b/test/installer.js index 18604b6..9f8176a 100644 --- a/test/installer.js +++ b/test/installer.js @@ -2,13 +2,11 @@ const chai = require('chai') const { exec } = require('mz/child_process') -const fs = require('fs-extra') const path = require('path') const installer = require('..') const access = require('./helpers/access') -const dependencies = require('./helpers/dependencies') const describeInstaller = require('./helpers/describe_installer') const { cleanupOutputDir, describeInstallerWithException, tempOutputDir, testInstallerOptions } = require('./helpers/describe_installer') @@ -71,16 +69,6 @@ describe('module', function () { assertNonASARDebExists ) - describeInstaller( - 'with an app with a scoped name', - { - src: 'test/fixtures/app-with-asar/', - options: { name: '@scoped/myapp' } - }, - 'generates a .deb package', - outputDir => access(path.join(outputDir, 'scoped-myapp_amd64.deb')) - ) - describeInstallerWithException( 'with a too-short name', { @@ -141,50 +129,6 @@ describe('module', function () { /^No Description or ProductDescription provided/ ) - describeInstaller( - 'with a custom desktop template', - { - src: 'test/fixtures/app-without-asar/', - options: { - desktopTemplate: 'test/fixtures/custom.desktop.ejs' - } - }, - 'generates a custom `.desktop` file', - outputDir => - assertNonASARDebExists(outputDir) - .then(() => exec('dpkg-deb -x bartest_amd64.deb .', { cwd: outputDir })) - .then(() => fs.readFile(path.join(outputDir, 'usr/share/applications/bartest.desktop'))) - .then(data => { - if (!data.toString().includes('Comment=Hardcoded comment')) { - throw new Error('Did not use custom template') - } - return Promise.resolve() - }) - ) - - describeInstaller( - 'move LICENSE to Debian-specific location', - { - src: 'test/fixtures/app-without-asar/' - }, - 'moves the LICENSE file to the appropriate location', - outputDir => - assertNonASARDebExists(outputDir) - .then(() => exec('dpkg-deb -x bartest_amd64.deb .', { cwd: outputDir })) - .then(() => fs.pathExists(path.join(outputDir, 'usr/lib/bartest/LICENSE'))) - .then(exists => { - if (exists) { - throw new Error('LICENSE was copied over erronenously') - } - return fs.pathExists(path.join(outputDir, 'usr/share/doc/bartest/copyright')) - }).then(exists => { - if (!exists) { - throw new Error('copyright file does not exist') - } - return Promise.resolve() - }) - ) - describeInstaller( 'with debian scripts and lintian overrides', { @@ -229,34 +173,6 @@ describe('module', function () { /^Wrong executable script name: invalid$/ ) - describe('with duplicate dependencies', test => { - const outputDir = tempOutputDir() - - // User options with duplicates (including default duplicates) - const userDependencies = { - depends: ['libnss3', 'libxtst6', 'dbus', 'dbus'], - recommends: ['pulseaudio | libasound2', 'bzip2', 'bzip2'], - suggests: ['lsb-release', 'gvfs', 'gvfs'], - enhances: ['libc6', 'libc6'], - preDepends: ['footest', 'footest'] - } - - before(() => { - const installerOptions = testInstallerOptions(outputDir, { - src: 'test/fixtures/app-with-asar/', - options: Object.assign({ arch: 'i386' }, userDependencies) - }) - return installer(installerOptions) - }) - - cleanupOutputDir(outputDir) - - it('removes duplicate dependencies', () => - assertASARDebExists(outputDir) - .then(() => dependencies.assertDependenciesEqual(outputDir, 'footest_i386.deb', userDependencies)) - ) - }) - describe('with restrictive umask', test => { const outputDir = tempOutputDir() let defaultMask