Merge pull request #2936 from node-red/npm-install-hooks

Add pre/postInstall hooks to npm install handling
This commit is contained in:
Nick O'Leary
2021-04-27 10:57:14 +01:00
committed by GitHub
13 changed files with 410 additions and 85 deletions

View File

@@ -9,6 +9,7 @@ const path = require("path");
const clone = require("clone");
const exec = require("@node-red/util").exec;
const log = require("@node-red/util").log;
const hooks = require("@node-red/util").hooks;
const BUILTIN_MODULES = require('module').builtinModules;
const EXTERNAL_MODULES_DIR = "externalModules";
@@ -189,13 +190,29 @@ async function installModule(moduleDetails) {
await ensureModuleDir();
var args = ["install", installSpec, "--production"];
return exec.run(NPM_COMMAND, args, {
cwd: installDir
},true).then(result => {
let triggerPayload = {
"module": moduleDetails.module,
"version": moduleDetails.version,
"dir": installDir,
"args": ["--production"]
}
return hooks.trigger("preInstall", triggerPayload).then((result) => {
// preInstall passed
// - run install
if (result !== false) {
let extraArgs = triggerPayload.args || [];
let args = ['install', ...extraArgs, installSpec]
log.trace(NPM_COMMAND + JSON.stringify(args));
return exec.run(NPM_COMMAND, args, { cwd: installDir },true)
} else {
log.trace("skipping npm install");
}
}).then(() => {
return hooks.trigger("postInstall", triggerPayload)
}).then(() => {
log.info(log._("server.install.installed", { name: installSpec }));
}).catch(result => {
var output = result.stderr;
var output = result.stderr || result.toString();
var e;
if (/E404/.test(output) || /ETARGET/.test(output)) {
log.error(log._("server.install.install-failed-not-found",{name:installSpec}));

View File

@@ -23,7 +23,7 @@ const tar = require("tar");
const registry = require("./registry");
const registryUtil = require("./util");
const library = require("./library");
const {exec,log,events} = require("@node-red/util");
const {exec,log,events,hooks} = require("@node-red/util");
const child_process = require('child_process');
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
let installerEnabled = false;
@@ -168,11 +168,30 @@ async function installModule(module,version,url) {
}
var installDir = settings.userDir || process.env.NODE_RED_HOME || ".";
var args = ['install','--no-audit','--no-update-notifier','--no-fund','--save','--save-prefix=~','--production',installName];
log.trace(npmCommand + JSON.stringify(args));
return exec.run(npmCommand,args,{
cwd: installDir
}, true).then(result => {
let triggerPayload = {
"module": module,
"version": version,
"url": url,
"dir": installDir,
"isExisting": isExisting,
"isUpgrade": isUpgrade,
"args": ['--no-audit','--no-update-notifier','--no-fund','--save','--save-prefix=~','--production']
}
return hooks.trigger("preInstall", triggerPayload).then((result) => {
// preInstall passed
// - run install
if (result !== false) {
let extraArgs = triggerPayload.args || [];
let args = ['install', ...extraArgs, installName]
log.trace(npmCommand + JSON.stringify(args));
return exec.run(npmCommand,args,{ cwd: installDir}, true)
} else {
log.trace("skipping npm install");
}
}).then(() => {
return hooks.trigger("postInstall", triggerPayload)
}).then(() => {
if (isExisting) {
// This is a module we already have installed as a non-user module.
// That means it was discovered when loading, but was not listed
@@ -191,29 +210,45 @@ async function installModule(module,version,url) {
events.emit("runtime-event",{id:"restart-required",payload:{type:"warning",text:"notification.warnings.restartRequired"},retain:true});
return require("./registry").setModulePendingUpdated(module,version);
}
}).catch(result => {
var output = result.stderr;
var e;
var lookFor404 = new RegExp(" 404 .*"+module,"m");
var lookForVersionNotFound = new RegExp("version not found: "+module+"@"+version,"m");
if (lookFor404.test(output)) {
log.warn(log._("server.install.install-failed-not-found",{name:module}));
e = new Error("Module not found");
e.code = 404;
throw e;
} else if (isUpgrade && lookForVersionNotFound.test(output)) {
log.warn(log._("server.install.upgrade-failed-not-found",{name:module}));
e = new Error("Module not found");
e.code = 404;
throw e;
} else {
}).catch(err => {
let e;
if (err.hook) {
// preInstall failed
log.warn(log._("server.install.install-failed-long",{name:module}));
log.warn("------------------------------------------");
log.warn(output);
log.warn(err.toString());
log.warn("------------------------------------------");
throw new Error(log._("server.install.install-failed"));
e = new Error(log._("server.install.install-failed")+": "+err.toString());
if (err.hook === "postInstall") {
return exec.run(npmCommand,["remove",module],{ cwd: installDir}, false).finally(() => {
throw e;
})
}
} else {
// npm install failed
let output = err.stderr;
let lookFor404 = new RegExp(" 404 .*"+module,"m");
let lookForVersionNotFound = new RegExp("version not found: "+module+"@"+version,"m");
if (lookFor404.test(output)) {
log.warn(log._("server.install.install-failed-not-found",{name:module}));
e = new Error("Module not found");
e.code = 404;
} else if (isUpgrade && lookForVersionNotFound.test(output)) {
log.warn(log._("server.install.upgrade-failed-not-found",{name:module}));
e = new Error("Module not found");
e.code = 404;
} else {
log.warn(log._("server.install.install-failed-long",{name:module}));
log.warn("------------------------------------------");
log.warn(output);
log.warn("------------------------------------------");
e = new Error(log._("server.install.install-failed"));
}
}
})
if (e) {
throw e;
}
});
}).catch(err => {
// In case of error, reset activePromise to be resolvable
activePromise = Promise.resolve();
@@ -412,17 +447,29 @@ function uninstallModule(module) {
log.info(log._("server.install.uninstalling",{name:module}));
var args = ['remove','--no-audit','--no-update-notifier','--no-fund','--save',module];
log.trace(npmCommand + JSON.stringify(args));
exec.run(npmCommand,args,{
cwd: installDir,
},true).then(result => {
let triggerPayload = {
"module": module,
"dir": installDir,
}
return hooks.trigger("preUninstall", triggerPayload).then(() => {
// preUninstall passed
// - run uninstall
log.trace(npmCommand + JSON.stringify(args));
return exec.run(npmCommand,args,{ cwd: installDir}, true)
}).then(() => {
log.info(log._("server.install.uninstalled",{name:module}));
reportRemovedModules(list);
library.removeExamplesDir(module);
resolve(list);
return hooks.trigger("postUninstall", triggerPayload).catch((err)=>{
log.warn("------------------------------------------");
log.warn(err.toString());
log.warn("------------------------------------------");
}).finally(() => {
resolve(list);
})
}).catch(result => {
var output = result.stderr;
let output = result.stderr || result;
log.warn(log._("server.install.uninstall-failed-long",{name:module}));
log.warn("------------------------------------------");
log.warn(output.toString());

View File

@@ -19,7 +19,7 @@ var redUtil = require("@node-red/util").util;
const events = require("@node-red/util").events;
var flowUtil = require("./util");
const context = require('../nodes/context');
const hooks = require("../hooks");
const hooks = require("@node-red/util").hooks;
var Subflow;
var Log;

View File

@@ -20,7 +20,6 @@ var redNodes = require("./nodes");
var flows = require("./flows");
var storage = require("./storage");
var library = require("./library");
var hooks = require("./hooks");
var plugins = require("./plugins");
var settings = require("./settings");
@@ -29,7 +28,7 @@ var path = require('path');
var fs = require("fs");
var os = require("os");
const {log,i18n,events,exec,util} = require("@node-red/util");
const {log,i18n,events,exec,util,hooks} = require("@node-red/util");
var runtimeMetricInterval = null;

View File

@@ -21,7 +21,7 @@ var redUtil = require("@node-red/util").util;
var Log = require("@node-red/util").log;
var context = require("./context");
var flows = require("../flows");
const hooks = require("../hooks");
const hooks = require("@node-red/util").hooks;
const NOOP_SEND = function() {}

View File

@@ -34,6 +34,7 @@
"install-failed-not-found": "$t(server.install.install-failed-long) module not found",
"install-failed-name": "$t(server.install.install-failed-long) invalid module name: __name__",
"install-failed-url": "$t(server.install.install-failed-long) invalid url: __url__",
"post-install-error": "Error running 'postInstall' hook:",
"upgrading": "Upgrading module: __name__ to version: __version__",
"upgraded": "Upgraded module: __name__. Restart Node-RED to use the new version",
"upgrade-failed-not-found": "$t(server.install.install-failed-long) version not found",

View File

@@ -19,6 +19,7 @@ const i18n = require("./lib/i18n");
const util = require("./lib/util");
const events = require("./lib/events");
const exec = require("./lib/exec");
const hooks = require("./lib/hooks");
/**
* This module provides common utilities for the Node-RED runtime and editor
@@ -69,5 +70,12 @@ module.exports = {
* @mixes @node-red/util_exec
* @memberof @node-red/util
*/
exec: exec
exec: exec,
/**
* Runtime hooks
* @mixes @node-red/util_hooks
* @memberof @node-red/util
*/
hooks: hooks
}

View File

@@ -1,4 +1,4 @@
const Log = require("@node-red/util").log;
const Log = require("./log.js");
const VALID_HOOKS = [
// Message Routing Path
@@ -8,14 +8,28 @@ const VALID_HOOKS = [
"postDeliver",
"onReceive",
"postReceive",
"onComplete"
"onComplete",
// Module install hooks
"preInstall",
"postInstall",
"preUninstall",
"postUninstall"
]
// Flags for what hooks have handlers registered
let states = { }
// Hooks by id
// Doubly-LinkedList of hooks by id.
// - hooks[id] points to head of list
// - each list item looks like:
// {
// cb: the callback function
// location: filename/line of code that added the hook
// previousHook: reference to previous hook in list
// nextHook: reference to next hook in list
// removed: a flag that is set if the item was removed
// }
let hooks = { }
// Hooks by label
@@ -35,12 +49,12 @@ let labelledHooks = { }
* - `postReceive` - passed a `ReceiveEvent` when the message has been given to the node's `input` handler(s)
* - `onComplete` - passed a `CompleteEvent` when the node has completed with a message or logged an error
*
* @mixin @node-red/runtime_hooks
* @mixin @node-red/util_hooks
*/
/**
* Register a handler to a named hook
* @memberof @node-red/runtime_hooks
* @memberof @node-red/util_hooks
* @param {String} hookId - the name of the hook to attach to
* @param {Function} callback - the callback function for the hook
*/
@@ -49,26 +63,39 @@ function add(hookId, callback) {
if (VALID_HOOKS.indexOf(id) === -1) {
throw new Error(`Invalid hook '${id}'`);
}
if (label) {
if (labelledHooks[label] && labelledHooks[label][id]) {
throw new Error("Hook "+hookId+" already registered")
}
labelledHooks[label] = labelledHooks[label]||{};
labelledHooks[label][id] = callback;
if (label && labelledHooks[label] && labelledHooks[label][id]) {
throw new Error("Hook "+hookId+" already registered")
}
// Get location of calling code
const stack = new Error().stack;
const callModule = stack.split("\n")[2].split("(")[1].slice(0,-1);
Log.debug(`Adding hook '${hookId}' from ${callModule}`);
hooks[id] = hooks[id] || [];
hooks[id].push({cb:callback,location:callModule});
const hookItem = {cb:callback, location: callModule, previousHook: null, nextHook: null }
let tailItem = hooks[id];
if (tailItem === undefined) {
hooks[id] = hookItem;
} else {
while(tailItem.nextHook !== null) {
tailItem = tailItem.nextHook
}
tailItem.nextHook = hookItem;
hookItem.previousHook = tailItem;
}
if (label) {
labelledHooks[label] = labelledHooks[label]||{};
labelledHooks[label][id] = hookItem;
}
// TODO: get rid of this;
states[id] = true;
}
/**
* Remove a handled from a named hook
* @memberof @node-red/runtime_hooks
* @memberof @node-red/util_hooks
* @param {String} hookId - the name of the hook event to remove - must be `name.label`
*/
function remove(hookId) {
@@ -95,33 +122,66 @@ function remove(hookId) {
}
}
function removeHook(id,callback) {
let i = hooks[id].findIndex(hook => hook.cb === callback);
if (i !== -1) {
hooks[id].splice(i,1);
if (hooks[id].length === 0) {
delete hooks[id];
delete states[id];
}
function removeHook(id,hookItem) {
let previousHook = hookItem.previousHook;
let nextHook = hookItem.nextHook;
if (previousHook) {
previousHook.nextHook = nextHook;
} else {
hooks[id] = nextHook;
}
if (nextHook) {
nextHook.previousHook = previousHook;
}
hookItem.removed = true;
if (!previousHook && !nextHook) {
delete hooks[id];
delete states[id];
}
}
function trigger(hookId, payload, done) {
const hookStack = hooks[hookId];
if (!hookStack || hookStack.length === 0) {
done();
return;
let hookItem = hooks[hookId];
if (!hookItem) {
if (done) {
done();
return;
} else {
return Promise.resolve();
}
}
let i = 0;
if (!done) {
return new Promise((resolve,reject) => {
invokeStack(hookItem,payload,function(err) {
if (err !== undefined && err !== false) {
if (!(err instanceof Error)) {
err = new Error(err);
}
err.hook = hookId
reject(err);
} else {
resolve(err);
}
})
});
} else {
invokeStack(hookItem,payload,done)
}
}
function invokeStack(hookItem,payload,done) {
function callNextHook(err) {
if (i === hookStack.length || err) {
if (!hookItem || err) {
done(err);
return;
}
const hook = hookStack[i++];
const callback = hook.cb;
if (hookItem.removed) {
hookItem = hookItem.nextHook;
callNextHook();
return;
}
const callback = hookItem.cb;
if (callback.length === 1) {
try {
let result = callback(payload);
@@ -134,6 +194,7 @@ function trigger(hookId, payload, done) {
result.then(handleResolve, callNextHook)
return;
}
hookItem = hookItem.nextHook;
callNextHook();
} catch(err) {
done(err);
@@ -148,15 +209,15 @@ function trigger(hookId, payload, done) {
}
}
}
callNextHook();
function handleResolve(result) {
if (result === undefined) {
hookItem = hookItem.nextHook;
callNextHook();
} else {
done(result);
}
}
callNextHook();
}
function clear() {
@@ -179,4 +240,4 @@ module.exports = {
add,
remove,
trigger
}
}