Merge pull request #1576 from shackbarth/publish
authorLinda Nichols <lynnaloo@gmail.com>
Tue, 24 Jun 2014 16:34:58 +0000 (12:34 -0400)
committerLinda Nichols <lynnaloo@gmail.com>
Tue, 24 Jun 2014 16:34:58 +0000 (12:34 -0400)
issue #23894: add ability to install third-party extensions using npm

15 files changed:
.travis.yml
enyo-client/application/source/en/strings.js
enyo-client/application/source/startup.js
enyo-client/application/source/views/workspace.js
enyo-client/database/source/grant_roles.sql [moved from enyo-client/database/source/priv.sql with 100% similarity]
enyo-client/database/source/manifest.js
enyo-client/database/source/public/tables/priv.sql [new file with mode: 0644]
node-datasource/main.js
node-datasource/routes/app.js
node-datasource/routes/install_extension.js [new file with mode: 0644]
node-datasource/routes/routes.js
package.json
scripts/lib/build_all.js
scripts/lib/build_client.js
scripts/lib/build_database.js

index b6af55e..da8ebf3 100644 (file)
@@ -12,6 +12,6 @@ before_script:
   - "cd .."
 
 script:
-  - "npm run-script test"
   - "npm run-script test-datasource"
+  - "npm run-script test"
   - "npm run-script jshint"
index e22a865..28e9885 100644 (file)
     "_closeDate": "Close Date",
     "_code": "Code",
     "_configure": "Configure",
+    "_commandCenter": "Command Center",
     "_commentType": "Comment Type",
     "_commentsEditable": "Comments Editable",
     "_company": "Company",
     "_extendedPrice": "Extended Price",
     "_extendedDescription": "Extended Description",
     "_extendedPriceScale": "Extended Price Scale",
+    "_extensionName": "Extension Name",
     "_externalReference": "External Reference",
     "_delivery": "Delivery",
     "_department": "Department",
     "_invoiceNumber": "Invoice #",
     "_invoices": "Invoices",
     "_initials": "Initials",
+    "_installExtension": "Install Extension",
     "_inventoryUnit": "Inventory Unit",
     "_isActive": "Active",
     "_isAddresses": "Addresses",
     "_customerOrProspect": "Would you like to create a new Customer or a new Prospect?",
     "_deleteLine?": "Are you sure you want to delete this line?",
     "_exitPageWarning": "You are about to leave the xTuple application.",
+    "_installExtensionWarning": "Extensions are very powerful and potentially have full access to your " +
+      "data. You should only install an extension from a source you trust.",
     "_insufficientPrivileges": "You have insufficient privileges to perform this action.",
     "_manualFreight": "Manually editing the freight will disable automatic freight recalculations.",
     "_mustSave": "You must save your changes before proceeding.",
index 7a7a2df..895065a 100644 (file)
@@ -23,6 +23,7 @@ white:true*/
         success: _.bind(this.didComplete, this)
       };
       var relevantPrivileges = [
+        "InstallExtension",
         "MaintainUsers",
         "MaintainPreferencesSelf",
         "MaintainWorkflowsSelf",
index 64c7a96..86bc045 100644 (file)
@@ -430,9 +430,62 @@ strict: false*/
             {kind: "onyx.GroupboxHeader", content: "_notes".loc()},
             {kind: "XV.TextArea", attr: "DatabaseComments"}
           ]}
+        ]},
+        {kind: "XV.Groupbox",
+          title: "_commandCenter".loc(), name: "commandPanel", components: [
+          {kind: "XV.ScrollableGroupbox",
+            classes: "in-panel", components: [
+            {kind: "onyx.GroupboxHeader", content: "_installExtension".loc()},
+            {kind: "XV.InputWidget", name: "extensionName", label: "_extensionName".loc()},
+            {kind: "FittableColumns", classes: "xv-buttons center", components: [
+              {kind: "onyx.Button", name: "extensionButton", classes: "icon-ok", ontap: "installExtension"},
+            ]},
+          ]}
         ]}
       ]}
-    ]
+    ],
+    create: function () {
+      this.inherited(arguments);
+      var hasPriv = XT.session.privileges.get("InstallExtension");
+      this.$.extensionName.setDisabled(!hasPriv);
+      this.$.extensionButton.setDisabled(!hasPriv);
+    },
+    installExtension: function () {
+      var that = this,
+        callback = function (response) {
+          if (!response.answer) {
+            return;
+          }
+
+          XT.dataSource.callRoute("install-extension",
+            {
+              extensionName: that.$.extensionName.getValue()
+            },
+            {
+              success: function (message) {
+                that.doNotify({message: message});
+              },
+              error: function (error) {
+                that.doNotify({message: error.message ? error.message() : error});
+              }
+            }
+          );
+        };
+
+      if (!this.$.extensionName.getValue()) {
+        this.doNotify({
+          type: XM.Model.WARNING,
+          message: "_attributeIsRequired".loc().replace("{attr}", "_extensionName".loc())
+        });
+        return;
+      }
+
+      this.doNotify({
+        type: XM.Model.QUESTION,
+        message: "_installExtensionWarning".loc() + "_confirmAction".loc(),
+        callback: callback
+      });
+    }
   });
 
   enyo.kind({
index 1f59a8b..d76a799 100644 (file)
@@ -55,7 +55,9 @@
     "public/tables/vendaddrinfo.sql",
     "public/tables/wo.sql",
     "public/tables/womatl.sql",
+    "xt/functions/grant_role_priv.sql",
     "xt/functions/add_priv.sql",
+    "public/tables/priv.sql",
     "xt/functions/add_role.sql",
     "xt/functions/add_report_definition.sql",
     "xt/functions/average_cost.sql",
@@ -85,7 +87,6 @@
     "xt/functions/cntctrestore.sql",
     "xt/functions/createuser.sql",
     "xt/functions/cust_outstanding_credit.sql",
-    "xt/functions/grant_role_priv.sql",
     "xt/functions/grant_role_ext.sql",
     "xt/functions/grant_user_role.sql",
     "xt/functions/install_guiscript.sql",
     "public/tables/comment_trigger.sql",
     "public/tables/pkghead.sql",
     "public/tables/schemaord.sql",
-    "priv.sql",
+    "grant_roles.sql",
     "update_version.sql"
   ]
 }
diff --git a/enyo-client/database/source/public/tables/priv.sql b/enyo-client/database/source/public/tables/priv.sql
new file mode 100644 (file)
index 0000000..0440815
--- /dev/null
@@ -0,0 +1 @@
+select xt.add_priv('InstallExtension', 'Can Install Extensions', 'command_center', 'CommandCenter');
index 6750a1a..29ec06b 100755 (executable)
@@ -398,13 +398,16 @@ require('./oauth2/passport');
 var that = this;
 
 app.use(express.favicon(__dirname + '/views/login/assets/favicon.ico'));
-_.each(X.options.datasource.databases, function (orgValue, orgKey, orgList) {
-  "use strict";
-  app.use("/" + orgValue + '/client', express.static('../enyo-client/application', { maxAge: 86400000 }));
-  app.use("/" + orgValue + '/core-extensions', express.static('../enyo-client/extensions', { maxAge: 86400000 }));
-  app.use("/" + orgValue + '/private-extensions', express.static('../../private-extensions', { maxAge: 86400000 }));
-  app.use("/" + orgValue + '/xtuple-extensions', express.static('../../xtuple-extensions', { maxAge: 86400000 }));
-});
+if (X.options.datasource.debugging) {
+  _.each(X.options.datasource.databases, function (orgValue, orgKey, orgList) {
+    "use strict";
+    app.use("/" + orgValue + '/client', express.static('../enyo-client/application', { maxAge: 86400000 }));
+    app.use("/" + orgValue + '/core-extensions', express.static('../enyo-client/extensions', { maxAge: 86400000 }));
+    app.use("/" + orgValue + '/private-extensions', express.static('../../private-extensions', { maxAge: 86400000 }));
+    app.use("/" + orgValue + '/xtuple-extensions', express.static('../../xtuple-extensions', { maxAge: 86400000 }));
+    app.use("/" + orgValue + '/npm', express.static('../node_modules', { maxAge: 86400000 }));
+  });
+}
 app.use('/assets', express.static('views/login/assets', { maxAge: 86400000 }));
 
 app.get('/:org/dialog/authorize', oauth2.authorization);
@@ -442,9 +445,10 @@ app.all('/:org/client/build/client-code', routes.clientCode);
 app.all('/:org/email', routes.email);
 app.all('/:org/export', routes.exxport);
 app.get('/:org/file', routes.file);
-app.all('/:org/oauth/generate-key', routes.generateOauthKey);
 app.get('/:org/generate-report', routes.generateReport);
+app.all('/:org/install-extension', routes.installExtension);
 app.get('/:org/locale', routes.locale);
+app.all('/:org/oauth/generate-key', routes.generateOauthKey);
 app.get('/:org/reset-password', routes.resetPassword);
 app.post('/:org/oauth/revoke-token', routes.revokeOauthToken);
 app.all('/:org/vcfExport', routes.vcfExport);
index 8762a12..6f639ba 100644 (file)
@@ -123,7 +123,10 @@ var async = require("async"),
           });
           uuids = _.compact(uuids); // eliminate any null values
           var extensionPaths = _.compact(_.map(extensions, function (ext) {
-            return path.join(ext.location, "source", ext.name);
+            var locationName = ext.location.indexOf("/") === 0 ?
+              path.join(ext.location, "source") :
+              "/" + ext.location;
+            return path.join(locationName, ext.name);
           }));
           getCoreUuid('js', req.session.passport.user.organization, function (err, jsUuid) {
             if (err) {
diff --git a/node-datasource/routes/install_extension.js b/node-datasource/routes/install_extension.js
new file mode 100644 (file)
index 0000000..bf2b398
--- /dev/null
@@ -0,0 +1,82 @@
+/*jshint node:true, indent:2, curly:false, eqeqeq:true, immed:true, latedef:true, newcap:true, noarg:true,
+regexp:true, undef:true, strict:true, trailing:true, white:true */
+/*global X:true, SYS:true, _:true */
+
+(function () {
+  "use strict";
+
+  var async = require("async"),
+    npm = require("npm"),
+    path = require("path"),
+    buildAll = require("../../scripts/lib/build_all");
+
+  exports.installExtension = function (req, res) {
+    var database = req.session.passport.user.organization,
+      extensionName = req.query.extensionName,
+      username = req.session.passport.user.id,
+      user = new SYS.User(),
+      validateInput = function (callback) {
+        if (!extensionName) {
+          callback("Error: empty extension name");
+          return;
+        }
+        callback();
+      },
+      validateUser = function (callback) {
+        user.fetch({
+          id: username,
+          username: X.options.databaseServer.user,
+          database: database,
+          success: function (model, results) {
+            var privCheck = _.find(model.get("grantedPrivileges"), function (model) {
+              return model.privilege === "InstallExtension";
+            });
+            if (!privCheck) {
+              callback({message: "_insufficientPrivileges"});
+              return;
+            }
+            callback(); // success!
+          },
+          error: function () {
+            callback({message: "_restoreError"});
+          }
+        });
+      },
+      npmLoad = function (callback) {
+        npm.load(callback);
+      },
+      npmInstall = function (callback) {
+        npm.commands.install([extensionName], callback);
+        npm.on("log", function (message) {
+          // log the progress of the installation
+          console.log(message);
+        });
+      },
+      buildExtension = function (callback) {
+        console.log("extension is", path.join(__dirname, "../../node_modules", extensionName));
+        buildAll.build({
+          database: database,
+          extension: path.join(__dirname, "../../node_modules", extensionName)
+        }, callback);
+      };
+
+    async.series([
+      validateInput,
+      validateUser,
+      npmLoad,
+      npmInstall,
+      buildExtension
+    ], function (err, results) {
+      if (err) {
+        console.log(err);
+        err.isError = true;
+        res.send(err);
+        return;
+      }
+      console.log("all done");
+      res.send({data: "_success"});
+    });
+  };
+}());
+
+
index 4ccea3c..b1c88a9 100644 (file)
@@ -37,6 +37,7 @@ regexp:true, undef:true, strict:true, trailing:true, white:true */
     file = require('./file'),
     generateReport = require('./generate_report'),
     generateOauthKey = require('./generate_oauth_key'),
+    installExtension = require('./install_extension'),
     locale = require('./locale'),
     passport = require('passport'),
     redirector = require('./redirector'),
@@ -94,6 +95,7 @@ regexp:true, undef:true, strict:true, trailing:true, white:true */
   exports.file = [ensureLogin, file.file];
   exports.generateOauthKey = [ensureLogin, generateOauthKey.generateKey];
   exports.generateReport = [ensureLogin, generateReport.generateReport];
+  exports.installExtension = [ensureLogin, installExtension.installExtension];
   exports.locale = [ensureLogin, locale.locale];
   exports.redirect = redirector.redirect;
   exports.analysis = [ensureLogin, analysis.analysis];
index 75ea3d8..867089e 100644 (file)
@@ -24,6 +24,7 @@
     "less": "1.5.0",
     "moment": "2.4.x",
     "nodemailer": "0.3.x",
+    "npm":"1.4.x",
     "node-forge": "0.6.x",
     "oauth2orize": "0.1.x",
     "oauth2orize-jwt-bearer": "0.1.x",
index 4ddeca2..2dfff67 100644 (file)
@@ -10,6 +10,7 @@ var _ = require('underscore'),
   dataSource = require('../../node-datasource/lib/ext/datasource').dataSource,
   exec = require('child_process').exec,
   fs = require('fs'),
+  npm = require('npm'),
   path = require('path'),
   unregister = buildDatabaseUtil.unregister,
   winston = require('winston');
@@ -71,6 +72,8 @@ var _ = require('underscore'),
                 extPath = path.join(__dirname, "../../../xtuple-extensions/source", name);
               } else if (location === '/private-extensions') {
                 extPath = path.join(__dirname, "../../../private-extensions/source", name);
+              } else if (location === 'npm') {
+                extPath = path.join(__dirname, "../../node_modules", name);
               }
               return extPath;
             });
@@ -104,30 +107,59 @@ var _ = require('underscore'),
         });
       },
       buildAll = function (specs, creds, buildAllCallback) {
-        buildClient(specs, function (err, res) {
-          if (err) {
-            buildAllCallback(err);
-            return;
-          }
-          buildDatabase.buildDatabase(specs, creds, function (databaseErr, databaseRes) {
-            var returnMessage;
-            if (databaseErr && (specs[0].wipeViews || specs[0].initialize)) {
-              buildAllCallback(databaseErr);
-              return;
-
-            } else if (databaseErr) {
-              buildAllCallback("Build failed. Try wiping the views next time by running me without the -q flag.");
+        async.series([
+          function (done) {
+            // step 1: npm install extension if necessary
+            // an alternate approach would be only npm install these
+            // extensions on an npm install.
+            var allExtensions = _.reduce(specs, function (memo, spec) {
+              memo.push(spec.extensions);
+              return _.flatten(memo);
+            }, []);
+            var npmExtensions = _.filter(allExtensions, function (extName) {
+              return extName.indexOf("node_modules") >= 0;
+            });
+            if (npmExtensions.length === 0) {
+              done();
               return;
             }
-            returnMessage = "\n";
-            _.each(specs, function (spec) {
-              returnMessage += "Database: " + spec.database + '\nDirectories:\n';
-              _.each(spec.extensions, function (ext) {
-                returnMessage += '  ' + ext + '\n';
+            npm.load(function (err, res) {
+              if (err) {
+                done(err);
+                return;
+              }
+              npm.on("log", function (message) {
+                // log the progress of the installation
+                console.log(message);
               });
+              async.map(npmExtensions, function (extName, next) {
+                npm.commands.install([path.basename(extName)], next);
+              }, done);
             });
-            buildAllCallback(null, "Build succeeded." + returnMessage);
-          });
+          },
+          function (done) {
+            // step 2: build the client
+            buildClient(specs, done);
+          },
+          function (done) {
+            // step 3: build the database
+            buildDatabase.buildDatabase(specs, creds, function (databaseErr, databaseRes) {
+              if (databaseErr) {
+                buildAllCallback(databaseErr);
+                return;
+              }
+              var returnMessage = "\n";
+              _.each(specs, function (spec) {
+                returnMessage += "Database: " + spec.database + '\nDirectories:\n';
+                _.each(spec.extensions, function (ext) {
+                  returnMessage += '  ' + ext + '\n';
+                });
+              });
+              done(null, "Build succeeded." + returnMessage);
+            });
+          }
+        ], function (err, results) {
+          buildAllCallback(err, results && results[results.length - 1]);
         });
       },
       config;
index 7fe466a..f05f480 100755 (executable)
@@ -35,7 +35,7 @@ var _ = require('underscore'),
       callback(null, "");
       return;
 
-    } else if (extPath.indexOf("extensions") < 0) {
+    } else if (extPath.indexOf("extensions") < 0 && extPath.indexOf("node_modules") < 0) {
       // this is the core app, which has a slightly different process.
       fs.readFile(path.join(__dirname, "build/core.js"), "utf8", function (err, jsCode) {
         if (err) {
@@ -114,7 +114,7 @@ var _ = require('underscore'),
         return;
       }
       // run the enyo deployment method asyncronously
-      var rootDir = path.join(extPath, "../..");
+      var rootDir = path.join(extPath, extPath.indexOf("node_modules") >= 0 ? "../../enyo-client/extensions/" : "../..");
       // we run the command from /scripts/lib, so that is where build directories and other
       // temp files are going to go.
       console.log("building " + extName);
@@ -212,19 +212,21 @@ var _ = require('underscore'),
   };
 
   var build = function (extPath, callback) {
+    var isNodeModule = extPath.indexOf("node_modules") >= 0;
+
     if (extPath.indexOf("/lib/orm") >= 0 || extPath.indexOf("foundation-database") >= 0) {
       // There is nothing here to install on the client.
       callback();
       return;
     }
 
-    if (extPath.indexOf("extensions") < 0) {
+    if (extPath.indexOf("extensions") < 0 && !isNodeModule) {
       // this is the core app, which has a different deploy process.
       buildCore(callback);
       return;
     }
 
-    var enyoDir = path.join(extPath, "../../enyo");
+    var enyoDir = path.join(extPath, isNodeModule ? "../../enyo-client/extensions/enyo" : "../../enyo");
     fs.exists(path.join(extPath, "client"), function (exists) {
       if (!exists) {
         console.log(extPath + " has no client code. Not trying to build it.");
index f7f8934..c7b02c7 100644 (file)
@@ -82,7 +82,7 @@ var  async = require('async'),
           extensionCallback(null, "");
           return;
         }
-        //winston.info("Installing extension", databaseName, extension);
+        //console.log("Installing extension", databaseName, extension);
         // deal with directory structure quirks
         var baseName = path.basename(extension),
           isFoundation = extension.indexOf("foundation-database") >= 0,
@@ -96,6 +96,7 @@ var  async = require('async'),
             extension.indexOf("extension") >= 0,
           isPublicExtension = extension.indexOf("xtuple-extensions") >= 0,
           isPrivateExtension = extension.indexOf("private-extensions") >= 0,
+          isNpmExtension = baseName.indexOf("xtuple-") >= 0,
           dbSourceRoot = (isFoundation || isFoundationExtension) ? extension :
             isLibOrm ? path.join(extension, "source") :
             path.join(extension, "database/source"),
@@ -109,7 +110,8 @@ var  async = require('async'),
             wipeViews: isApplicationCore && spec.wipeViews,
             extensionLocation: isCoreExtension ? "/core-extensions" :
               isPublicExtension ? "/xtuple-extensions" :
-              isPrivateExtension ? "/private-extensions" : "not-applicable"
+              isPrivateExtension ? "/private-extensions" :
+              isNpmExtension ? "npm" : "not-applicable"
           };
 
         buildDatabaseUtil.explodeManifest(path.join(dbSourceRoot, "manifest.js"),