ADD unit tests, split and refactor source code

This commit is contained in:
RecuencoJones
2019-03-25 20:38:29 +01:00
parent 6eaf5fc9af
commit 8257010937
18 changed files with 782 additions and 392 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.idea
node_modules
coverage
bin

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

40
__test__/cli.spec.js Normal file
View File

@@ -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);
});
});

123
__test__/common.spec.js Normal file
View File

@@ -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'
});
});
});
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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"
}
}

52
src/actions/install.js Normal file
View File

@@ -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;

24
src/actions/uninstall.js Normal file
View File

@@ -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;

25
src/assets/binary.js Normal file
View File

@@ -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;

17
src/assets/move.js Normal file
View File

@@ -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;

25
src/assets/untar.js Normal file
View File

@@ -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;

28
src/cli.js Normal file
View File

@@ -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);
}
};

130
src/common.js Normal file
View File

@@ -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 };

View File

@@ -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);