diff --git a/.gitignore b/.gitignore index c3834c5..0122b51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea node_modules +coverage bin diff --git a/__test__/actions/install.spec.js b/__test__/actions/install.spec.js new file mode 100644 index 0000000..9bfaaaf --- /dev/null +++ b/__test__/actions/install.spec.js @@ -0,0 +1,91 @@ +const { EventEmitter } = require('events'); +const request = require('request'); +const common = require('../../src/common'); +const install = require('../../src/actions/install'); +const move = require('../../src/assets/move'); +const untar = require('../../src/assets/untar'); +const verifyAndPlaceCallback = require('../../src/assets/binary'); + +jest.mock('fs'); +jest.mock('mkdirp'); +jest.mock('request'); +jest.mock('../../src/common'); +jest.mock('../../src/assets/move'); +jest.mock('../../src/assets/untar'); +jest.mock('../../src/assets/binary'); + +describe('install()', () => { + + let callback, requestEvents; + + beforeEach(() => { + + callback = jest.fn(); + + requestEvents = new EventEmitter(); + }); + + it('should call callback with error if package.json did not return value' , () => { + common.parsePackageJson.mockReturnValueOnce(undefined); + + install(callback); + + expect(callback).toHaveBeenCalledWith('Invalid inputs'); + }); + + it('should call callback with error on request error', () => { + request.mockReturnValueOnce(requestEvents); + common.parsePackageJson.mockReturnValueOnce({ url: 'http://url' }); + + install(callback); + + requestEvents.emit('error'); + + expect(callback).toHaveBeenCalledWith('Error downloading from URL: http://url'); + }); + + it('should call callback with error on response with status code different than 200', () => { + request.mockReturnValueOnce(requestEvents); + common.parsePackageJson.mockReturnValueOnce({ url: 'http://url' }); + + install(callback); + + requestEvents.emit('response', { statusCode: 404 }); + + expect(callback).toHaveBeenCalledWith('Error downloading binary. HTTP Status Code: 404'); + }); + + it('should pick move strategy if url is an uncompressed binary', () => { + request.mockReturnValueOnce(requestEvents); + common.parsePackageJson.mockReturnValueOnce({ url: 'http://url' }); + + install(callback); + + requestEvents.emit('response', { statusCode: 200 }); + + expect(move).toHaveBeenCalled(); + }); + + it('should pick untar strategy if url ends with .tar.gz', () => { + request.mockReturnValueOnce(requestEvents); + common.parsePackageJson.mockReturnValueOnce({ url: 'http://url.tar.gz' }); + + install(callback); + + requestEvents.emit('response', { statusCode: 200 }); + + expect(untar).toHaveBeenCalled(); + }); + + it('should call verifyAndPlaceCallback on success', () => { + request.mockReturnValueOnce(requestEvents); + common.parsePackageJson.mockReturnValueOnce({ url: 'http://url', binName: 'command', binPath: './bin' }); + move.mockImplementationOnce(({ onSuccess }) => onSuccess()); + + install(callback); + + requestEvents.emit('response', { statusCode: 200 }); + + expect(verifyAndPlaceCallback).toHaveBeenCalledWith('command', './bin', callback); + }); +}); diff --git a/__test__/actions/uninstall.spec.js b/__test__/actions/uninstall.spec.js new file mode 100644 index 0000000..b95a75f --- /dev/null +++ b/__test__/actions/uninstall.spec.js @@ -0,0 +1,59 @@ +const fs = require('fs'); +const common = require('../../src/common'); +const uninstall = require('../../src/actions/uninstall'); + +jest.mock('fs'); +jest.mock('../../src/common'); + +describe('uninstall()', () => { + + let callback; + + beforeEach(() => { + + callback = jest.fn(); + + common.parsePackageJson.mockReturnValueOnce({ binName: 'command' }); + }); + + it('should call callback with error if binary not found', () => { + const error = new Error(); + + common.getInstallationPath.mockImplementationOnce((cb) => cb(error)); + + uninstall(callback); + + expect(callback).toHaveBeenCalledWith(error); + }); + + it('should call unlinkSync with binary and installation path', () => { + + common.getInstallationPath.mockImplementationOnce((cb) => cb(null, './bin')); + + uninstall(callback); + + expect(fs.unlinkSync).toHaveBeenCalledWith('bin/command'); + }); + + it('should call callback on success', () => { + + common.getInstallationPath.mockImplementationOnce((cb) => cb(null, './bin')); + + uninstall(callback); + + expect(callback).toHaveBeenCalledWith(null); + }); + + it('should call callback regardless of errors on unlink', () => { + + common.getInstallationPath.mockImplementationOnce((cb) => cb(null, './bin')); + + fs.unlinkSync.mockImplementationOnce(() => { + throw new Error(); + }); + + uninstall(callback); + + expect(callback).toHaveBeenCalledWith(null); + }); +}); diff --git a/__test__/assets/binary.spec.js b/__test__/assets/binary.spec.js new file mode 100644 index 0000000..79bf54a --- /dev/null +++ b/__test__/assets/binary.spec.js @@ -0,0 +1,51 @@ +const fs = require('fs'); +const common = require('../../src/common'); +const verifyAndPlaceBinary = require('../../src/assets/binary'); + +jest.mock('fs'); +jest.mock('../../src/common'); + +describe('verifyAndPlaceBinary()', () => { + let callback; + + beforeEach(() => { + callback = jest.fn(); + }); + + it('should call callback with error if binary downloaded differs from config', () => { + fs.existsSync.mockReturnValueOnce(false); + + verifyAndPlaceBinary('command', './bin', callback); + + expect(callback).toHaveBeenCalledWith('Downloaded binary does not contain the binary specified in configuration - command'); + }); + + it('should call callback with error if installation path cannot be found', () => { + const error = new Error(); + + fs.existsSync.mockReturnValueOnce(true); + common.getInstallationPath.mockImplementationOnce((cb) => cb(error)); + + verifyAndPlaceBinary('command', './bin', callback); + + expect(callback).toHaveBeenCalledWith(error); + }); + + it('should call callback with null on success', () => { + fs.existsSync.mockReturnValueOnce(true); + common.getInstallationPath.mockImplementationOnce((cb) => cb(null, '/usr/local/bin')); + + verifyAndPlaceBinary('command', './bin', callback); + + expect(callback).toHaveBeenCalledWith(null); + }); + + it('should move the binary to installation directory', () => { + fs.existsSync.mockReturnValueOnce(true); + common.getInstallationPath.mockImplementationOnce((cb) => cb(null, '/usr/local/bin')); + + verifyAndPlaceBinary('command', './bin', callback); + + expect(fs.renameSync).toHaveBeenCalledWith('bin/command', '/usr/local/bin/command'); + }); +}); diff --git a/__test__/assets/move.spec.js b/__test__/assets/move.spec.js new file mode 100644 index 0000000..5069cab --- /dev/null +++ b/__test__/assets/move.spec.js @@ -0,0 +1,49 @@ +const { EventEmitter } = require('events'); +const fs = require('fs'); +const move = require('../../src/assets/move'); + +jest.mock('fs'); + +describe('move()', () => { + + let streamEvents, pipe, onSuccess, onError; + + beforeEach(() => { + + streamEvents = new EventEmitter(); + + pipe = jest.fn(); + onSuccess = jest.fn(); + onError = jest.fn(); + createWriteStream = jest.fn(); + + fs.createWriteStream.mockReturnValueOnce(streamEvents); + }); + + it('should download resource to given binPath', () => { + + move({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + expect(fs.createWriteStream).toHaveBeenCalledWith('bin/command'); + }); + + it('should call onSuccess on stream closed', () => { + + move({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + streamEvents.emit('close'); + + expect(onSuccess).toHaveBeenCalled(); + }); + + it('should call onError with error on write stream error', () => { + + const error = new Error(); + + move({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + streamEvents.emit('error', error); + + expect(onError).toHaveBeenCalledWith(error); + }); +}); diff --git a/__test__/assets/untar.spec.js b/__test__/assets/untar.spec.js new file mode 100644 index 0000000..5f63273 --- /dev/null +++ b/__test__/assets/untar.spec.js @@ -0,0 +1,65 @@ +const { EventEmitter } = require('events'); +const zlib = require('zlib'); +const tar = require('tar'); +const untar = require('../../src/assets/untar'); + +jest.mock('zlib'); +jest.mock('tar', () => ({ + Extract: jest.fn() +})); + +describe('untar()', () => { + + let ungzEvents, untarEvents, pipe, onSuccess, onError; + + beforeEach(() => { + ungzEvents = new EventEmitter(); + untarEvents = new EventEmitter(); + + pipe = jest.fn(); + onSuccess = jest.fn(); + onError = jest.fn(); + + pipe.mockReturnValueOnce({ pipe }); + tar.Extract.mockReturnValueOnce(untarEvents); + zlib.createGunzip.mockReturnValueOnce(ungzEvents); + }); + + it('should download resource and untar to given binPath', () => { + + untar({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + expect(tar.Extract).toHaveBeenCalledWith({ path: './bin' }); + }); + + it('should call onSuccess on untar end', () => { + + untar({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + untarEvents.emit('end'); + + expect(onSuccess).toHaveBeenCalled(); + }); + + it('should call onError with error on ungz error', () => { + + const error = new Error(); + + untar({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + ungzEvents.emit('error', error); + + expect(onError).toHaveBeenCalledWith(error); + }); + + it('should call onError with error on untar error', () => { + + const error = new Error(); + + untar({ opts: { binPath: './bin', binName: 'command' }, req: { pipe }, onSuccess, onError }); + + untarEvents.emit('error', error); + + expect(onError).toHaveBeenCalledWith(error); + }); +}); diff --git a/__test__/cli.spec.js b/__test__/cli.spec.js new file mode 100644 index 0000000..85fb75c --- /dev/null +++ b/__test__/cli.spec.js @@ -0,0 +1,40 @@ +const cli = require('../src/cli'); +const install = require('../src/actions/install'); + +jest.mock('../src/actions/install'); + +describe('cli()', () => { + let exit; + + beforeEach(() => { + exit = jest.fn(); + }); + + it('should exit with error if not enough args are supplied', () => { + cli({ argv: [], exit }); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it('should exit with error if command does not exist', () => { + cli({ argv: [ '/usr/local/bin/node', 'index.js', 'command' ], exit }); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it('should exit with error if command returns error', () => { + install.mockImplementationOnce((cb) => cb(new Error())); + + cli({ argv: [ '/usr/local/bin/node', 'index.js', 'install' ], exit }); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it('should exit with success if command runs fine', () => { + install.mockImplementationOnce((cb) => cb(null)); + + cli({ argv: [ '/usr/local/bin/node', 'index.js', 'install' ], exit }); + + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/__test__/common.spec.js b/__test__/common.spec.js new file mode 100644 index 0000000..f6376c2 --- /dev/null +++ b/__test__/common.spec.js @@ -0,0 +1,123 @@ +const fs = require('fs'); +const childProcess = require('child_process'); +const common = require('../src/common'); + +jest.mock('fs'); +jest.mock('child_process'); +jest.mock('mkdirp'); + +describe('common', () => { + describe('getInstallationPath()', () => { + let callback, env; + + beforeEach(() => { + callback = jest.fn(); + + env = { ...process.env }; + }); + + afterEach(() => { + process.env = env; + }); + + it('should get binaries path from `npm bin`', () => { + childProcess.exec.mockImplementationOnce((_cmd, cb) => cb(null, '/usr/local/bin')); + + common.getInstallationPath(callback); + + expect(callback).toHaveBeenCalledWith(null, '/usr/local/bin'); + }); + + it('should get binaries path from env', () => { + childProcess.exec.mockImplementationOnce((_cmd, cb) => cb(new Error())); + + process.env.npm_config_prefix = '/usr/local'; + + common.getInstallationPath(callback); + + expect(callback).toHaveBeenCalledWith(null, '/usr/local/bin'); + }); + + it('should call callback with error if binaries path is not found', () => { + childProcess.exec.mockImplementationOnce((_cmd, cb) => cb(new Error())); + + process.env.npm_config_prefix = undefined; + + common.getInstallationPath(callback); + + expect(callback).toHaveBeenCalledWith(new Error('Error finding binary installation directory')); + }); + }); + + describe('parsePackageJson()', () => { + let _process; + + beforeEach(() => { + _process = { ...global.process }; + }); + + afterEach(() => { + global.process = _process; + }); + + describe('validation', () => { + it('should return if architecture is unsupported', () => { + process.arch = 'mips'; + + expect(common.parsePackageJson()).toBeUndefined(); + }); + + it('should return if platform is unsupported', () => { + process.platform = 'amiga'; + + expect(common.parsePackageJson()).toBeUndefined(); + }); + + it('should return if package.json does not exist', () => { + fs.existsSync.mockReturnValueOnce(false); + + expect(common.parsePackageJson()).toBeUndefined(); + }); + }); + + describe('variable replacement', () => { + it('should append .exe extension on windows platform', () => { + fs.existsSync.mockReturnValueOnce(true); + fs.readFileSync.mockReturnValueOnce(JSON.stringify({ + version: '1.0.0', + goBinary: { + name: 'command', + path: './bin', + url: 'https://github.com/foo/bar/releases/v{{version}}/assets/command{{win_ext}}' + } + })); + + process.platform = 'win32'; + + expect(common.parsePackageJson()).toMatchObject({ + binName: 'command.exe', + url: 'https://github.com/foo/bar/releases/v1.0.0/assets/command.exe' + }); + }); + + it('should not append .exe extension on platform different than windows', () => { + fs.existsSync.mockReturnValueOnce(true); + fs.readFileSync.mockReturnValueOnce(JSON.stringify({ + version: '1.0.0', + goBinary: { + name: 'command', + path: './bin', + url: 'https://github.com/foo/bar/releases/v{{version}}/assets/command{{win_ext}}' + } + })); + + process.platform = 'darwin'; + + expect(common.parsePackageJson()).toMatchObject({ + binName: 'command', + url: 'https://github.com/foo/bar/releases/v1.0.0/assets/command' + }); + }); + }); + }); +}); diff --git a/__test__/index.spec.js b/__test__/index.spec.js deleted file mode 100644 index 0b6bc4d..0000000 --- a/__test__/index.spec.js +++ /dev/null @@ -1,127 +0,0 @@ -const rewire = require('rewire'); -const EventEmitter = require('events').EventEmitter; - -describe('go-npm', function() { - let mod; - - beforeEach(function() { - mod = rewire('../src/index.js'); - }); - - describe('Resource handling strategies', function() { - - describe('untarStrategy()', function() { - - let untarStrategy, ungzEvents, untarEvents, pipe, callback, zlib, createGunzip, tar; - - beforeEach(function() { - untarStrategy = mod.__get__('untarStrategy'); - zlib = mod.__get__('zlib'); - tar = mod.__get__('tar'); - ungzEvents = new EventEmitter(); - untarEvents = new EventEmitter(); - - createGunzip = jest.fn(); - pipe = jest.fn(); - callback = jest.fn(); - - pipe.mockReturnValueOnce({ pipe }); - createGunzip.mockReturnValueOnce(ungzEvents); - jest.spyOn(tar, 'Extract').mockReturnValueOnce(untarEvents); - - // jest.spyOn not working on read-only properties - Object.defineProperty(zlib, 'createGunzip', { value: createGunzip }); - }); - - it('should download resource and untar to given binPath', function() { - - untarStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - expect(tar.Extract).toHaveBeenCalledWith({ path: './bin' }); - }); - - it('should call verifyAndPlaceBinary on untar end', function() { - - const verifyAndPlaceBinary = jest.fn(); - - mod.__set__('verifyAndPlaceBinary', verifyAndPlaceBinary); - - untarStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - untarEvents.emit('end'); - - expect(verifyAndPlaceBinary).toHaveBeenCalledWith('command', './bin', callback); - }); - - it('should call callback with error on ungz error', function() { - - const error = new Error(); - - untarStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - ungzEvents.emit('error', error); - - expect(callback).toHaveBeenCalledWith(error); - }); - - it('should call callback with error on untar error', function() { - - const error = new Error(); - - untarStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - untarEvents.emit('error', error); - - expect(callback).toHaveBeenCalledWith(error); - }); - }); - - describe('moveStrategy()', function() { - - let moveStrategy, streamEvents, pipe, callback, fs; - - beforeEach(function() { - - moveStrategy = mod.__get__('moveStrategy'); - fs = mod.__get__('fs'); - streamEvents = new EventEmitter(); - - pipe = jest.fn(); - callback = jest.fn(); - - jest.spyOn(fs, 'createWriteStream').mockReturnValueOnce(streamEvents); - }); - - it('should download resource to given binPath', function() { - - moveStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - expect(fs.createWriteStream).toHaveBeenCalledWith('bin/command'); - }); - - it('should call verifyAndPlaceBinary on stream closed', function() { - - const verifyAndPlaceBinary = jest.fn(); - - mod.__set__('verifyAndPlaceBinary', verifyAndPlaceBinary); - - moveStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - streamEvents.emit('close'); - - expect(verifyAndPlaceBinary).toHaveBeenCalledWith('command', './bin', callback); - }); - - it('should call callback with error on write stream error', function() { - - const error = new Error(); - - moveStrategy({ binPath: './bin', binName: 'command' }, { pipe }, callback); - - streamEvents.emit('error', error); - - expect(callback).toHaveBeenCalledWith(error); - }); - }); - }); -}); diff --git a/package.json b/package.json index dd8e3bb..f4f9618 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "babel-cli": "^6.24.1", "babel-core": "^6.25.0", "babel-preset-es2015": "^6.24.1", - "jest": "^24.5.0", - "rewire": "^4.0.1" + "jest": "^24.5.0" } } diff --git a/src/actions/install.js b/src/actions/install.js new file mode 100644 index 0000000..f1e928a --- /dev/null +++ b/src/actions/install.js @@ -0,0 +1,52 @@ +const mkdirp = require('mkdirp'); +const request = require('request'); +const { parsePackageJson } = require('../common'); +const verifyAndPlaceBinary = require('../assets/binary'); + +/** + * Select a resource handling strategy based on given options. + */ +function getStrategy({ url }) { + + if (url.endsWith('.tar.gz')) { + return require('../assets/untar'); + } else { + return require('../assets/move'); + } +} + +/** + * Reads the configuration from application's package.json, + * validates properties, downloads the binary, untars, and stores at + * ./bin in the package's root. NPM already has support to install binary files + * specific locations when invoked with "npm install -g" + * + * See: https://docs.npmjs.com/files/package.json#bin + */ +function install(callback) { + + const opts = parsePackageJson(); + if (!opts) return callback('Invalid inputs'); + + mkdirp.sync(opts.binPath); + + console.log('Downloading from URL: ' + opts.url); + + const req = request({ uri: opts.url }); + + req.on('error', () => callback('Error downloading from URL: ' + opts.url)); + req.on('response', (res) => { + if (res.statusCode !== 200) return callback('Error downloading binary. HTTP Status Code: ' + res.statusCode); + + const strategy = getStrategy(opts); + + strategy({ + opts, + req, + onSuccess: () => verifyAndPlaceBinary(opts.binName, opts.binPath, callback), + onError: callback + }); + }); +} + +module.exports = install; diff --git a/src/actions/uninstall.js b/src/actions/uninstall.js new file mode 100644 index 0000000..ee2e38f --- /dev/null +++ b/src/actions/uninstall.js @@ -0,0 +1,24 @@ +const { join } = require('path'); +const { unlinkSync } = require('fs'); +const { parsePackageJson, getInstallationPath } = require('../common'); + +function uninstall(callback) { + + const { binName } = parsePackageJson(); + + getInstallationPath((err, installationPath) => { + if (err) { + return callback(err); + } + + try { + unlinkSync(join(installationPath, binName)); + } catch(ex) { + // Ignore errors when deleting the file. + } + + return callback(null); + }); +} + +module.exports = uninstall; diff --git a/src/assets/binary.js b/src/assets/binary.js new file mode 100644 index 0000000..9f8063a --- /dev/null +++ b/src/assets/binary.js @@ -0,0 +1,25 @@ +const { join } = require('path'); +const { existsSync, renameSync, chmodSync } = require('fs'); +const { getInstallationPath } = require('../common'); + +function verifyAndPlaceBinary(binName, binPath, callback) { + if (!existsSync(join(binPath, binName))) { + return callback(`Downloaded binary does not contain the binary specified in configuration - ${binName}`); + } + + getInstallationPath((err, installationPath) => { + if (err) { + return callback(err); + } + + // Move the binary file and make sure it is executable + renameSync(join(binPath, binName), join(installationPath, binName)); + chmodSync(join(installationPath, binName), '755'); + + console.log('Placed binary on', join(installationPath, binName)); + + callback(null); + }); +} + +module.exports = verifyAndPlaceBinary; diff --git a/src/assets/move.js b/src/assets/move.js new file mode 100644 index 0000000..3f1cc19 --- /dev/null +++ b/src/assets/move.js @@ -0,0 +1,17 @@ +const { join } = require('path'); +const { createWriteStream } = require('fs'); + +/** + * Move strategy for binary resources without compression. + */ +function move({ opts, req, onSuccess, onError }) { + + const stream = createWriteStream(join(opts.binPath, opts.binName)); + + stream.on('error', onError); + stream.on('close', onSuccess); + + req.pipe(stream); +} + +module.exports = move; diff --git a/src/assets/untar.js b/src/assets/untar.js new file mode 100644 index 0000000..1272cdf --- /dev/null +++ b/src/assets/untar.js @@ -0,0 +1,25 @@ +const tar = require('tar'); +const zlib = require('zlib'); + +/** + * Unzip strategy for resources using `.tar.gz`. + * + * First we will Un-GZip, then we will untar. So once untar is completed, + * binary is downloaded into `binPath`. Verify the binary and call it good. + */ +function untar({ opts, req, onSuccess, onError }) { + + const ungz = zlib.createGunzip(); + const untar = tar.Extract({ path: opts.binPath }); + + ungz.on('error', onError); + untar.on('error', onError); + + // First we will Un-GZip, then we will untar. So once untar is completed, + // binary is downloaded into `binPath`. Verify the binary and call it good + untar.on('end', onSuccess); + + req.pipe(ungz).pipe(untar); +} + +module.exports = untar; diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..c083079 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,28 @@ +const actions = { + install: (callback) => require('./actions/install')(callback), + uninstall: (callback) => require('./actions/uninstall')(callback) +}; + +// Parse command line arguments and call the right action +module.exports = ({ argv, exit }) => { + if (argv && argv.length > 2) { + const cmd = argv[2]; + + if (!actions[cmd]) { + console.log('Invalid command to go-npm. `install` and `uninstall` are the only supported commands'); + exit(1); + } else { + actions[cmd]((err) => { + if (err) { + console.error(err); + exit(1); + } else { + exit(0); + } + }); + } + } else { + console.log('Invalid command to go-npm. `install` and `uninstall` are the only supported commands'); + exit(1); + } +}; diff --git a/src/common.js b/src/common.js new file mode 100644 index 0000000..27062c7 --- /dev/null +++ b/src/common.js @@ -0,0 +1,130 @@ +const { join } = require('path'); +const { exec } = require('child_process'); +const { existsSync, readFileSync } = require('fs'); +const mkdirp = require('mkdirp'); + +// Mapping from Node's `process.arch` to Golang's `$GOARCH` +const ARCH_MAPPING = { + ia32: '386', + x64: 'amd64', + arm: 'arm' +}; + +// Mapping between Node's `process.platform` to Golang's +const PLATFORM_MAPPING = { + darwin: 'darwin', + linux: 'linux', + win32: 'windows', + freebsd: 'freebsd' +}; + +function getInstallationPath(callback) { + + // `npm bin` will output the path where binary files should be installed + exec('npm bin', (err, stdout, stderr) => { + + let dir = null; + if (err || stderr || !stdout || stdout.length === 0) { + + // We couldn't infer path from `npm bin`. Let's try to get it from + // Environment variables set by NPM when it runs. + // npm_config_prefix points to NPM's installation directory where `bin` folder is available + // Ex: /Users/foo/.nvm/versions/node/v4.3.0 + const env = process.env; + + if (env && env.npm_config_prefix) { + dir = join(env.npm_config_prefix, 'bin'); + } else { + return callback(new Error('Error finding binary installation directory')); + } + } else { + dir = stdout.trim(); + } + + mkdirp.sync(dir); + + callback(null, dir); + }); +} + +function validateConfiguration({ version, goBinary }) { + + if (!version) { + return "'version' property must be specified"; + } + + if (!goBinary || typeof (goBinary) !== 'object') { + return "'goBinary' property must be defined and be an object"; + } + + if (!goBinary.name) { + return "'name' property is necessary"; + } + + if (!goBinary.path) { + return "'path' property is necessary"; + } + + if (!goBinary.url) { + return "'url' property is required"; + } +} + +function parsePackageJson() { + if (!(process.arch in ARCH_MAPPING)) { + console.error('Installation is not supported for this architecture: ' + process.arch); + return; + } + + if (!(process.platform in PLATFORM_MAPPING)) { + console.error('Installation is not supported for this platform: ' + process.platform); + return + } + + const packageJsonPath = join('.', 'package.json'); + if (!existsSync(packageJsonPath)) { + console.error('Unable to find package.json. ' + + 'Please run this script at root of the package you want to be installed'); + return + } + + const packageJson = JSON.parse(readFileSync(packageJsonPath)); + const error = validateConfiguration(packageJson); + + if (error && error.length > 0) { + console.error('Invalid package.json: ' + error); + return + } + + // We have validated the config. It exists in all its glory + const binPath = packageJson.goBinary.path; + let binName = packageJson.goBinary.name; + let url = packageJson.goBinary.url; + let version = packageJson.version; + + if (version[0] === 'v') version = version.substr(1); // strip the 'v' if necessary v0.0.1 => 0.0.1 + + // Binary name on Windows has .exe suffix + if (process.platform === 'win32') { + binName += '.exe' + + url = url.replace(/{{win_ext}}/g, '.exe'); + } else { + url = url.replace(/{{win_ext}}/g, ''); + } + + // Interpolate variables in URL, if necessary + url = url.replace(/{{arch}}/g, ARCH_MAPPING[process.arch]); + url = url.replace(/{{platform}}/g, PLATFORM_MAPPING[process.platform]); + url = url.replace(/{{version}}/g, version); + url = url.replace(/{{bin_name}}/g, binName); + + return { + binName, + binPath, + url, + version + }; +} + +module.exports = { parsePackageJson, getInstallationPath }; diff --git a/src/index.js b/src/index.js index 59ffc87..3e7d82a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,265 +1,3 @@ #!/usr/bin/env node -"use strict" - -const request = require('request'), - path = require('path'), - tar = require('tar'), - zlib = require('zlib'), - mkdirp = require('mkdirp'), - fs = require('fs'), - exec = require('child_process').exec; - -// Mapping from Node's `process.arch` to Golang's `$GOARCH` -const ARCH_MAPPING = { - "ia32": "386", - "x64": "amd64", - "arm": "arm" -}; - -// Mapping between Node's `process.platform` to Golang's -const PLATFORM_MAPPING = { - "darwin": "darwin", - "linux": "linux", - "win32": "windows", - "freebsd": "freebsd" -}; - -function getInstallationPath(callback) { - - // `npm bin` will output the path where binary files should be installed - exec("npm bin", function(err, stdout, stderr) { - - let dir = null; - if (err || stderr || !stdout || stdout.length === 0) { - - // We couldn't infer path from `npm bin`. Let's try to get it from - // Environment variables set by NPM when it runs. - // npm_config_prefix points to NPM's installation directory where `bin` folder is available - // Ex: /Users/foo/.nvm/versions/node/v4.3.0 - let env = process.env; - if (env && env.npm_config_prefix) { - dir = path.join(env.npm_config_prefix, "bin"); - } - } else { - dir = stdout.trim(); - } - - mkdirp.sync(dir); - - callback(null, dir); - }); - -} - -function verifyAndPlaceBinary(binName, binPath, callback) { - if (!fs.existsSync(path.join(binPath, binName))) return callback(`Downloaded binary does not contain the binary specified in configuration - ${binName}`); - - getInstallationPath(function(err, installationPath) { - if (err) return callback("Error getting binary installation path from `npm bin`"); - - // Move the binary file and make sure it is executable - fs.renameSync(path.join(binPath, binName), path.join(installationPath, binName)); - fs.chmodSync(path.join(installationPath, binName), "755"); - - console.log("Placed binary on", path.join(installationPath, binName)); - - callback(null); - }); -} - -function validateConfiguration(packageJson) { - - if (!packageJson.version) { - return "'version' property must be specified"; - } - - if (!packageJson.goBinary || typeof(packageJson.goBinary) !== "object") { - return "'goBinary' property must be defined and be an object"; - } - - if (!packageJson.goBinary.name) { - return "'name' property is necessary"; - } - - if (!packageJson.goBinary.path) { - return "'path' property is necessary"; - } - - if (!packageJson.goBinary.url) { - return "'url' property is required"; - } - - // if (!packageJson.bin || typeof(packageJson.bin) !== "object") { - // return "'bin' property of package.json must be defined and be an object"; - // } -} - -function parsePackageJson() { - if (!(process.arch in ARCH_MAPPING)) { - console.error("Installation is not supported for this architecture: " + process.arch); - return; - } - - if (!(process.platform in PLATFORM_MAPPING)) { - console.error("Installation is not supported for this platform: " + process.platform); - return - } - - const packageJsonPath = path.join(".", "package.json"); - if (!fs.existsSync(packageJsonPath)) { - console.error("Unable to find package.json. " + - "Please run this script at root of the package you want to be installed"); - return - } - - let packageJson = JSON.parse(fs.readFileSync(packageJsonPath)); - let error = validateConfiguration(packageJson); - if (error && error.length > 0) { - console.error("Invalid package.json: " + error); - return - } - - // We have validated the config. It exists in all its glory - let binName = packageJson.goBinary.name; - let binPath = packageJson.goBinary.path; - let url = packageJson.goBinary.url; - let version = packageJson.version; - if (version[0] === 'v') version = version.substr(1); // strip the 'v' if necessary v0.0.1 => 0.0.1 - - // Binary name on Windows has .exe suffix - if (process.platform === "win32") { - binName += ".exe"; - - url = url.replace(/{{win_ext}}/g, '.exe'); - } else { - url = url.replace(/{{win_ext}}/g, ''); - } - - // Interpolate variables in URL, if necessary - url = url.replace(/{{arch}}/g, ARCH_MAPPING[process.arch]); - url = url.replace(/{{platform}}/g, PLATFORM_MAPPING[process.platform]); - url = url.replace(/{{version}}/g, version); - url = url.replace(/{{bin_name}}/g, binName); - - return { - binName: binName, - binPath: binPath, - url: url, - version: version - }; -} - -/** - * Unzip strategy for resources using `.tar.gz`. - * - * First we will Un-GZip, then we will untar. So once untar is completed, - * binary is downloaded into `binPath`. Verify the binary and call it good. - */ -function untarStrategy(opts, req, callback) { - - const ungz = zlib.createGunzip(); - const untar = tar.Extract({path: opts.binPath}); - - ungz.on('error', callback); - untar.on('error', callback); - - // First we will Un-GZip, then we will untar. So once untar is completed, - // binary is downloaded into `binPath`. Verify the binary and call it good - untar.on('end', verifyAndPlaceBinary.bind(null, opts.binName, opts.binPath, callback)); - - req.pipe(ungz).pipe(untar); -} - -/** - * Move strategy for binary resources without compression. - */ -function moveStrategy(opts, req, callback) { - - const stream = fs.createWriteStream(path.join(opts.binPath, opts.binName)); - - stream.on('error', callback); - stream.on('close', verifyAndPlaceBinary.bind(null, opts.binName, opts.binPath, callback)); - - req.pipe(stream); -} - -/** - * Select a resource handling strategy based on given options. - */ -function getStrategy(opts) { - - if (opts.url.endsWith('.tar.gz')) { - return untarStrategy; - } else { - return moveStrategy; - } -} - -/** - * Reads the configuration from application's package.json, - * validates properties, downloads the binary, untars, and stores at - * ./bin in the package's root. NPM already has support to install binary files - * specific locations when invoked with "npm install -g" - * - * See: https://docs.npmjs.com/files/package.json#bin - */ -const INVALID_INPUT = "Invalid inputs"; -function install(callback) { - - const opts = parsePackageJson(); - if (!opts) return callback(INVALID_INPUT); - - const strategy = getStrategy(opts); - mkdirp.sync(opts.binPath); - - console.log("Downloading from URL: " + opts.url); - let req = request({uri: opts.url}); - req.on('error', callback.bind(null, "Error downloading from URL: " + opts.url)); - req.on('response', function(res) { - if (res.statusCode !== 200) return callback("Error downloading binary. HTTP Status Code: " + res.statusCode); - - strategy(opts, req, callback); - }); -} - -function uninstall(callback) { - - let opts = parsePackageJson(); - getInstallationPath(function(err, installationPath) { - if (err) callback("Error finding binary installation directory"); - - try { - fs.unlinkSync(path.join(installationPath, opts.binName)); - } catch(ex) { - // Ignore errors when deleting the file. - } - - return callback(null); - }); -} - - -// Parse command line arguments and call the right method -let actions = { - "install": install, - "uninstall": uninstall -}; - -let argv = process.argv; -if (argv && argv.length > 2) { - let cmd = process.argv[2]; - if (!actions[cmd]) { - console.log("Invalid command to go-npm. `install` and `uninstall` are the only supported commands"); - process.exit(1); - } - - actions[cmd](function(err) { - if (err) { - console.error(err); - process.exit(1); - } else { - process.exit(0); - } - }); -} +require('./cli')(process);