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

Add support for Yarn workspaces (PoC for #189) #200

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions bin/node2nix.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ var switches = [
['--no-bypass-cache', 'Specifies that package builds do not need to bypass the content addressable cache (required for NPM 5.x)'],
['--no-copy-node-env', 'Do not create a copy of the Nix expression that builds NPM packages'],
['--use-fetchgit-private', 'Use fetchGitPrivate instead of fetchgit in the generated Nix expressions'],
['--strip-optional-dependencies', 'Strips the optional dependencies from the regular dependencies in the NPM registry']
['--strip-optional-dependencies', 'Strips the optional dependencies from the regular dependencies in the NPM registry'],
['--yarn-workspace FILE', 'Use a Yarn workspace package.json to find project interdependencies'],
];

var parser = new optparse.OptionParser(switches);
Expand All @@ -59,6 +60,7 @@ var noCopyNodeEnv = false;
var bypassCache = true;
var useFetchGitPrivate = false;
var stripOptionalDependencies = false;
var yarnWorkspaceJSON;
var executable;

/* Define process rules for option parameters */
Expand Down Expand Up @@ -185,6 +187,10 @@ parser.on('strip-optional-dependencies', function(arg, value) {
stripOptionalDependencies = true;
});

parser.on('yarn-workspace', function(arg, value) {
yarnWorkspaceJSON = value;
});

/* Define process rules for non-option parameters */

parser.on(1, function(opt) {
Expand Down Expand Up @@ -247,7 +253,7 @@ if(version) {
}

/* Perform the NPM to Nix conversion */
node2nix.npmToNix(inputJSON, outputNix, compositionNix, nodeEnvNix, lockJSON, supplementJSON, supplementNix, production, includePeerDependencies, flatten, nodePackage, registryURL, registryAuthToken, noCopyNodeEnv, bypassCache, useFetchGitPrivate, stripOptionalDependencies, function(err) {
node2nix.npmToNix(inputJSON, outputNix, compositionNix, nodeEnvNix, lockJSON, supplementJSON, supplementNix, production, includePeerDependencies, flatten, nodePackage, registryURL, registryAuthToken, noCopyNodeEnv, bypassCache, useFetchGitPrivate, stripOptionalDependencies, yarnWorkspaceJSON, function(err) {
if(err) {
process.stderr.write(err + "\n");
process.exit(1);
Expand Down
1 change: 1 addition & 0 deletions lib/DeploymentConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function DeploymentConfig(registryURL, registryAuthToken, production, includePee
this.outputDir = outputDir;
this.bypassCache = bypassCache;
this.stripOptionalDependencies = stripOptionalDependencies;
this.packageOverrides = {};
}

exports.DeploymentConfig = DeploymentConfig;
26 changes: 16 additions & 10 deletions lib/Package.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
var path = require('path');
var slasp = require('slasp');
var semver = require('semver');
var nijs = require('nijs');
var Source = require('./sources/Source.js').Source;
var inherit = require('nijs/lib/ast/util/inherit.js').inherit;
Expand Down Expand Up @@ -64,7 +63,7 @@ Package.prototype.findMatchingProvidedDependencyByParent = function(name, versio
} else if(dependency === null) {
return null; // If we have encountered a bundled dependency with the same name, consider it a conflict (is not a perfect resolution, but does not result in an error)
} else {
if(semver.satisfies(dependency.source.config.version, versionSpec, true)) { // If we found a dependency with the same name, see if the version fits
if(dependency.source.versionSatisfies(versionSpec)) { // If we found a dependency with the same name, see if the version fits
return dependency;
} else {
return null; // If there is a version mismatch, then a conflicting version has been encountered
Expand Down Expand Up @@ -144,20 +143,27 @@ Package.prototype.bundleDependencies = function(resolvedDependencies, dependenci
slasp.fromEach(function(callback) {
callback(null, dependencies);
}, function(dependencyName, callback) {
var versionSpec = dependencies[dependencyName];
var parentDependency = self.findMatchingProvidedDependencyByParent(dependencyName, versionSpec);

if(self.isBundledDependency(dependencyName)) {
self.requiredDependencies[dependencyName] = null;
callback();
} else if(parentDependency === null) {
var pkg = new Package(self.deploymentConfig, self.lock, self, dependencyName, versionSpec, self.source.baseDir, true /* Never include development dependencies of transitive dependencies */, self.sourcesCache);
return callback();
}

var pkg = self.deploymentConfig.packageOverrides[dependencyName];
var bundlePkg = !!pkg;
if(!pkg) {
var versionSpec = dependencies[dependencyName];
pkg = self.findMatchingProvidedDependencyByParent(dependencyName, versionSpec);
}
if(!pkg) {
pkg = new Package(self.deploymentConfig, self.lock, self, dependencyName, versionSpec, self.source.baseDir, true /* Never include development dependencies of transitive dependencies */, self.sourcesCache);
bundlePkg = true;
}

if(bundlePkg) {
slasp.sequence([
function(callback) {
pkg.source.fetch(callback);
},

function(callback) {
self.sourcesCache.addSource(pkg.source);
self.bundleDependency(dependencyName, pkg);
Expand All @@ -166,7 +172,7 @@ Package.prototype.bundleDependencies = function(resolvedDependencies, dependenci
}
], callback);
} else {
self.requiredDependencies[dependencyName] = parentDependency; // If there is a parent package that provides the requested dependency -> use it
self.requiredDependencies[dependencyName] = pkg; // If there is a parent package that provides the requested dependency -> use it
callback();
}

Expand Down
24 changes: 18 additions & 6 deletions lib/expressions/OutputExpression.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var nijs = require('nijs');
var inherit = require('nijs/lib/ast/util/inherit.js').inherit;
var SourcesCache = require('../SourcesCache.js').SourcesCache;
var WorkspaceSource = require('../sources/WorkspaceSource').WorkspaceSource;

/**
* Creates a new output expression instance.
Expand Down Expand Up @@ -34,13 +35,24 @@ OutputExpression.prototype.resolveDependencies = function(callback) {
* @see NixASTNode#toNixAST
*/
OutputExpression.prototype.toNixAST = function() {
var argSpec = {
nodeEnv: undefined,
fetchurl: undefined,
fetchgit: undefined,
globalBuildInputs: []
};

// Add arguments for workspace dependencies
for(var identifier in this.sourcesCache.sources) {
var source = this.sourcesCache.sources[identifier];
if(source instanceof WorkspaceSource && !source.symlink) {
var varName = source.variableName();
argSpec[varName] = source.fileExpression();
}
}

return new nijs.NixFunction({
argSpec: {
nodeEnv: undefined,
fetchurl: undefined,
fetchgit: undefined,
globalBuildInputs: []
},
argSpec: argSpec,
body: new nijs.NixLet({
value: {
sources: this.sourcesCache
Expand Down
116 changes: 113 additions & 3 deletions lib/node2nix.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ var fs = require('fs');
var path = require('path');
var slasp = require('slasp');
var nijs = require('nijs');
var glob = require('glob');

var CollectionExpression = require('./expressions/CollectionExpression.js').CollectionExpression;
var PackageExpression = require('./expressions/PackageExpression.js').PackageExpression;
var CompositionExpression = require('./expressions/CompositionExpression.js').CompositionExpression;
var DeploymentConfig = require('./DeploymentConfig.js').DeploymentConfig;
var Package = require('./Package.js').Package;

function copyNodeEnvExpr(nodeEnvNix, callback) {
/* Compose a read stream that reads the build expression */
Expand All @@ -32,6 +34,59 @@ function copyNodeEnvExpr(nodeEnvNix, callback) {
rs.pipe(ws);
}

/**
* Resolve a Yarn workspaces list to package paths.
*
* The result is a map of package names to paths relative to baseDir.
*
* @function
* @param {Array<String>} workspaces The workspaces field from package.json
* @param {String} baseDir Directory in which the referrer's package.json configuration resides
* @param {String} resolveDir Directory in which the workspace package.json configuration resides
* @param {function(String)} callback Callback that gets invoked when the work
* is done. In case of an error, the first parameter contains a string with
* an error message.
*/
function resolveYarnWorkspaces(workspaces, baseDir, resolveDir, callback) {
var pkgPaths = {};
slasp.sequence([
function(callback) {
// Resolve globs to unique paths relative to baseDir.
slasp.fromEach(function(callback) {
callback(null, workspaces);
}, function(idx, callback) {
slasp.sequence([
function(callback) {
glob(workspaces[idx] + '/package.json', {
cwd: resolveDir,
absolute: true,
}, callback);
},
function(callback, matches) {
matches.forEach(function(match) {
var matchRel = path.relative(baseDir, match);
if (matchRel !== 'package.json') { // ignore self
var pkgPath = matchRel.slice(0, -13)
pkgPaths[pkgPath] = true;
}
});
callback();
}
], callback);
}, callback);
},
function(callback) {
// Build a map of: name -> path
var pkgsMap = {};
Object.keys(pkgPaths).forEach(function(pkgPath) {
var config = JSON.parse(fs.readFileSync(pkgPath + '/package.json'));
pkgsMap[config.name] = pkgPath;
});
callback(null, pkgsMap);
}
], callback);
}

/**
* Writes a copy of node-env.nix to a specified path.
*
Expand All @@ -42,7 +97,7 @@ function copyNodeEnvExpr(nodeEnvNix, callback) {
*/
exports.copyNodeEnvExpr = copyNodeEnvExpr;

function npmToNix(inputJSON, outputNix, compositionNix, nodeEnvNix, lockJSON, supplementJSON, supplementNix, production, includePeerDependencies, flatten, nodePackage, registryURL, registryAuthToken, noCopyNodeEnv, bypassCache, useFetchGitPrivate, stripOptionalDependencies, callback) {
function npmToNix(inputJSON, outputNix, compositionNix, nodeEnvNix, lockJSON, supplementJSON, supplementNix, production, includePeerDependencies, flatten, nodePackage, registryURL, registryAuthToken, noCopyNodeEnv, bypassCache, useFetchGitPrivate, stripOptionalDependencies, yarnWorkspaceJSON, callback) {
var obj = JSON.parse(fs.readFileSync(inputJSON));
var version = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"))).version;
var disclaimer = "# This file has been generated by node2nix " + version + ". Do not edit!\n\n";
Expand All @@ -66,6 +121,7 @@ function npmToNix(inputJSON, outputNix, compositionNix, nodeEnvNix, lockJSON, su
if(typeof obj == "object" && obj !== null) {
if(Array.isArray(obj)) {
expr = new CollectionExpression(deploymentConfig, baseDir, obj);
callback();
} else {
// Display error if mandatory package.json attributes are not set
if(!obj.name) {
Expand All @@ -79,14 +135,68 @@ function npmToNix(inputJSON, outputNix, compositionNix, nodeEnvNix, lockJSON, su

// Display a warning if we expect a lock file to be used, but the user does not specify it
displayLockWarning = bypassCache && !lockJSON && fs.existsSync(path.join(path.dirname(inputJSON), path.basename(inputJSON, ".json")) + "-lock.json");
}

expr.resolveDependencies(callback);
// If this is a Yarn workspace, bundle all packages as virtual dependencies at the top level
if(obj.workspaces) {
slasp.sequence([
function(callback) {
resolveYarnWorkspaces(obj.workspaces, baseDir, baseDir, callback);
},
function(callback, pkgs) {
slasp.fromEach(function(callback) {
callback(null, pkgs);
}, function(pkgName, callback) {
var pkgPath = pkgs[pkgName];
var pkg = new Package(deploymentConfig, lock, expr.package, pkgName, "workspace:" + pkgPath, baseDir, production, expr.sourcesCache);
pkg.source.symlink = true; // Symlink workspace interdependencies
slasp.sequence([
function(callback) {
pkg.source.fetch(callback);
},
function(callback) {
expr.sourcesCache.addSource(pkg.source);
expr.package.providedDependencies[pkgName] = pkg;
pkg.resolveDependenciesAndSources(callback);
},
], callback);
}, callback);
},
], callback);
} else {
callback();
}
}
} else {
callback("The provided JSON file must consist of an object or an array");
}
},

/* If a Yarn workspace was specified, add source overrides for workspace packages. */
function(callback) {
if(yarnWorkspaceJSON !== undefined) {
var config = JSON.parse(fs.readFileSync(yarnWorkspaceJSON));
slasp.sequence([
function(callback) {
var resolveDir = path.dirname(yarnWorkspaceJSON);
resolveYarnWorkspaces(config.workspaces, baseDir, resolveDir, callback);
},
function(callback, pkgs) {
for(var pkgName in pkgs) {
var pkgPath = pkgs[pkgName];
deploymentConfig.packageOverrides[pkgName] = new Package(deploymentConfig, lock, null, pkgName, 'workspace:' + pkgPath, baseDir, true /* Never include development dependencies of transitive dependencies */, expr.sourcesCache);
}
callback();
}
], callback);
} else {
callback();
}
},

function(callback) {
expr.resolveDependencies(callback);
},

/* Write the output Nix expression to the specified output file */
function(callback) {
fs.writeFile(outputNix, disclaimer + nijs.jsToNix(expr, true), callback);
Expand Down
19 changes: 12 additions & 7 deletions lib/sources/LocalSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,24 @@ LocalSource.prototype.convertFromLockedDependency = function(dependencyObj, call
};

/**
* @see NixASTNode#toNixAST
* Create a NixFile AST node for the package source path.
*/
LocalSource.prototype.toNixAST = function() {
var ast = Source.prototype.toNixAST.call(this);

LocalSource.prototype.fileExpression = function() {
if(this.srcPath === "./") {
ast.src = new nijs.NixFile({ value: "./." }); // ./ is not valid in the Nix expression language
return new nijs.NixFile({ value: "./." }); // ./ is not valid in the Nix expression language
} else if(this.srcPath === "..") {
ast.src = new nijs.NixFile({ value: "./.." }); // .. is not valid in the Nix expression language
return new nijs.NixFile({ value: "./.." }); // .. is not valid in the Nix expression language
} else {
ast.src = new nijs.NixFile({ value: this.srcPath });
return new nijs.NixFile({ value: this.srcPath });
}
};

/**
* @see NixASTNode#toNixAST
*/
LocalSource.prototype.toNixAST = function() {
var ast = Source.prototype.toNixAST.call(this);
ast.src = this.fileExpression();
return ast;
};

Expand Down
24 changes: 23 additions & 1 deletion lib/sources/Source.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Source.constructSource = function(registryURL, registryAuthToken, baseDir, outpu
var GitSource = require('./GitSource.js').GitSource;
var HTTPSource = require('./HTTPSource.js').HTTPSource;
var LocalSource = require('./LocalSource.js').LocalSource;
var WorkspaceSource = require('./WorkspaceSource.js').WorkspaceSource;
var NPMRegistrySource = require('./NPMRegistrySource.js').NPMRegistrySource;

var parsedVersionSpec = semver.validRange(versionSpec, true);
Expand All @@ -66,6 +67,8 @@ Source.constructSource = function(registryURL, registryAuthToken, baseDir, outpu
return new GitSource(baseDir, dependencyName, "git://github.com/"+versionSpec);
} else if(parsedUrl.protocol == "file:") { // If the version is a file URL, simply compose a Nix path
return new LocalSource(baseDir, dependencyName, outputDir, parsedUrl.path);
} else if(parsedUrl.protocol == "workspace:") { // If the version is a workspace URL, the package is provided as a Nix expression
return new WorkspaceSource(baseDir, dependencyName, outputDir, versionSpec.slice(10));
} else if(versionSpec.substr(0, 3) == "../" || versionSpec.substr(0, 2) == "~/" || versionSpec.substr(0, 2) == "./" || versionSpec.substr(0, 1) == "/") { // If the version is a path, simply compose a Nix path
return new LocalSource(baseDir, dependencyName, outputDir, versionSpec);
} else { // In all other cases, just try the registry. Sometimes invalid semver ranges are encountered or a tag has been provided (e.g. 'latest', 'unstable')
Expand All @@ -85,6 +88,16 @@ Source.prototype.fetch = function(callback) {
callback("fetch() is not implemented, please use a prototype that inherits from Source");
};

/**
* Whether the version of this source satisfies the given version specifier.
*
* @method
* @param {String} versionSpec Version specifier to commpare
*/
Source.prototype.versionSatisfies = function(versionSpec) {
return semver.satisfies(this.config.version, versionSpec, true);
};

/**
* Takes a dependency object from a lock file and converts it into a source object.
*
Expand Down Expand Up @@ -117,12 +130,21 @@ Source.prototype.convertIntegrityStringToNixHash = function(integrity) {
}
};

/**
* Return a version of the package name suitable for use as a Nix variable.
*
* Escapes characters from scoped package names that aren't allowed.
*/
Source.prototype.variableName = function() {
return this.config.name.replace("@", "_at_").replace("/", "_slash_");
};

/**
* @see NixASTNode#toNixAST
*/
Source.prototype.toNixAST = function() {
return {
name: this.config.name.replace("@", "_at_").replace("/", "_slash_"), // Escape characters from scoped package names that aren't allowed
name: this.variableName(),
packageName: this.config.name,
version: this.config.version
};
Expand Down
Loading