# $Id$ package main; use strict; use warnings; use FHEM::Meta; sub npmjs_Initialize($) { my ($modHash) = @_; $modHash->{SetFn} = "FHEM::npmjs::Set"; $modHash->{GetFn} = "FHEM::npmjs::Get"; $modHash->{DefFn} = "FHEM::npmjs::Define"; $modHash->{NotifyFn} = "FHEM::npmjs::Notify"; $modHash->{UndefFn} = "FHEM::npmjs::Undef"; $modHash->{AttrFn} = "FHEM::npmjs::Attr"; $modHash->{AttrList} = "disable:1,0 " . "disabledForIntervals " . "updateListReading:1,0 " . "npmglobal:1,0 " . $readingFnAttributes; return FHEM::Meta::InitMod( __FILE__, $modHash ); } # define package package FHEM::npmjs; use strict; use warnings; use POSIX; use FHEM::Meta; use GPUtils qw(GP_Import); use Data::Dumper; # Run before module compilation BEGIN { # Import from main:: GP_Import( qw( attr AttrVal CommandAttr Debug defs deviceEvents DoTrigger FW_webArgs gettimeofday init_done InternalTimer IsDisabled Log3 modules readingsBeginUpdate readingsBulkUpdate readingsBulkUpdateIfChanged readingsEndUpdate readingsSingleUpdate ReadingsTimestamp ReadingsVal RemoveInternalTimer Value ) ); } # try to use JSON::MaybeXS wrapper # for chance of better performance + open code eval { require JSON::MaybeXS; import JSON::MaybeXS qw( decode_json encode_json ); 1; }; if ($@) { $@ = undef; # try to use JSON wrapper # for chance of better performance eval { # JSON preference order local $ENV{PERL_JSON_BACKEND} = 'Cpanel::JSON::XS,JSON::XS,JSON::PP,JSON::backportPP' unless ( defined( $ENV{PERL_JSON_BACKEND} ) ); require JSON; import JSON qw( decode_json encode_json ); 1; }; if ($@) { $@ = undef; # In rare cases, Cpanel::JSON::XS may # be installed but JSON|JSON::MaybeXS not ... eval { require Cpanel::JSON::XS; import Cpanel::JSON::XS qw(decode_json encode_json); 1; }; if ($@) { $@ = undef; # In rare cases, JSON::XS may # be installed but JSON not ... eval { require JSON::XS; import JSON::XS qw(decode_json encode_json); 1; }; if ($@) { $@ = undef; # Fallback to built-in JSON which SHOULD # be available since 5.014 ... require JSON::PP; import JSON::PP qw(decode_json encode_json); 1; } } } } my %fhem_npm_modules = ( 'alexa-fhem' => { fhem_module => 'alexa', }, 'gassistant-fhem' => { fhem_module => 'gassistant', }, 'homebridge-fhem' => { fhem_module => 'siri', }, 'tradfri-fhem' => { fhem_module => 'tradfri', }, ); sub Define($$) { my ( $hash, $def ) = @_; my @a = split( "[ \t][ \t]*", $def ); # Initialize the module and the device return $@ unless ( FHEM::Meta::SetInternals($hash) ); use version 0.77; our $VERSION = FHEM::Meta::Get( $hash, 'version' ); my $name = $a[0]; my $host = $a[2] ? $a[2] : 'localhost'; Undef( $hash, undef ) if ( $hash->{OLDDEF} ); # modify $hash->{HOST} = $host; $hash->{NOTIFYDEV} = "global,$name"; return "Existing instance for host $hash->{HOST}: " . $modules{ $hash->{TYPE} }{defptr}{ $hash->{HOST} }{NAME} if ( defined( $modules{ $hash->{TYPE} }{defptr}{ $hash->{HOST} } ) ); $modules{ $hash->{TYPE} }{defptr}{ $hash->{HOST} } = $hash; if ( $init_done && !defined( $hash->{OLDDEF} ) ) { # presets for FHEMWEB $attr{$name}{alias} = 'Node.js Package Update Status'; $attr{$name}{devStateIcon} = 'npm.updates.available:security@red:outdated npm.is.up.to.date:security@green:outdated .*npm.outdated.*in.progress:system_fhem_reboot@orange .*in.progress:system_fhem_update@orange warning.*:message_attention@orange error.*:message_attention@red'; $attr{$name}{group} = 'Update'; $attr{$name}{icon} = 'npm-old'; $attr{$name}{room} = 'System'; } readingsSingleUpdate( $hash, "state", "initialized", 1 ) if ( ReadingsVal( $name, 'state', 'none' ) ne 'none' ); return undef; } sub Undef($$) { my ( $hash, $arg ) = @_; my $name = $hash->{NAME}; if ( exists( $hash->{".fhem"}{subprocess} ) ) { my $subprocess = $hash->{".fhem"}{subprocess}; $subprocess->terminate(); $subprocess->wait(); } RemoveInternalTimer($hash); delete( $modules{npmjs}{defptr}{ $hash->{HOST} } ); return undef; } sub Attr(@) { my ( $cmd, $name, $attrName, $attrVal ) = @_; my $hash = $defs{$name}; if ( $attrName eq "disable" ) { if ( $cmd eq "set" and $attrVal eq "1" ) { RemoveInternalTimer($hash); readingsSingleUpdate( $hash, "state", "disabled", 1 ); Log3 $name, 3, "npmjs ($name) - disabled"; } elsif ( $cmd eq "del" ) { Log3 $name, 3, "npmjs ($name) - enabled"; } } elsif ( $attrName eq "disabledForIntervals" ) { if ( $cmd eq "set" ) { return "check disabledForIntervals Syntax HH:MM-HH:MM or 'HH:MM-HH:MM HH:MM-HH:MM ...'" unless ( $attrVal =~ /^((\d{2}:\d{2})-(\d{2}:\d{2})\s?)+$/ ); Log3 $name, 3, "npmjs ($name) - disabledForIntervals"; readingsSingleUpdate( $hash, "state", "disabled", 1 ); } elsif ( $cmd eq "del" ) { Log3 $name, 3, "npmjs ($name) - enabled"; readingsSingleUpdate( $hash, "state", "active", 1 ); } } return undef; } sub Notify($$) { my ( $hash, $dev ) = @_; my $name = $hash->{NAME}; return if ( IsDisabled($name) ); my $devname = $dev->{NAME}; my $devtype = $dev->{TYPE}; my $events = deviceEvents( $dev, 1 ); return if ( !$events ); Log3 $name, 5, "npmjs ($name) - Notify: " . Dumper $events; if ( ( ( grep ( /^DEFINED.$name$/, @{$events} ) or grep ( /^DELETEATTR.$name.disable$/, @{$events} ) or grep ( /^ATTR.$name.disable.0$/, @{$events} ) ) and $devname eq 'global' and $init_done ) or ( ( grep ( /^INITIALIZED$/, @{$events} ) or grep ( /^REREADCFG$/, @{$events} ) or grep ( /^MODIFIED.$name$/, @{$events} ) ) and $devname eq 'global' ) ) { # restore from packageList my $decode_json = eval { decode_json( ReadingsVal( $name, '.packageList', '' ) ) }; unless ($@) { $hash->{".fhem"}{npm}{nodejsversions} = $decode_json->{versions} if ( defined( $decode_json->{versions} ) ); $hash->{".fhem"}{npm}{listedpackages} = $decode_json->{listed} if ( defined( $decode_json->{listed} ) ); $hash->{".fhem"}{npm}{outdatedpackages} = $decode_json->{outdated} if ( defined( $decode_json->{outdated} ) ); } $decode_json = undef; # restore from installedList $decode_json = eval { decode_json( ReadingsVal( $name, '.installedList', '' ) ) }; unless ($@) { $hash->{".fhem"}{npm}{installedpackages} = $decode_json; } $decode_json = undef; # restore from uninstalledList $decode_json = eval { decode_json( ReadingsVal( $name, '.uninstalledList', '' ) ) }; unless ($@) { $hash->{".fhem"}{npm}{uninstalledpackages} = $decode_json; } $decode_json = undef; # restore from updatedList $decode_json = eval { decode_json( ReadingsVal( $name, '.updatedList', '' ) ) }; unless ($@) { $hash->{".fhem"}{npm}{updatedpackages} = $decode_json; } $decode_json = undef; # Trigger update if ( ReadingsVal( $name, 'nodejsVersion', 'none' ) ne 'none' ) { ProcessUpdateTimer($hash); } else { $hash->{".fhem"}{npm}{cmd} = 'getNodeVersion'; AsynchronousExecuteNpmCommand($hash); } } if ( $devname eq $name and ( grep ( /^installed:.successful$/, @{$events} ) or grep ( /^uninstalled:.successful$/, @{$events} ) or grep ( /^updated:.successful$/, @{$events} ) ) ) { $hash->{".fhem"}{npm}{cmd} = 'outdated'; AsynchronousExecuteNpmCommand($hash); } return; } sub Set($$@) { my ( $hash, $name, @aa ) = @_; my ( $cmd, @args ) = @aa; my $npmglobal = ( AttrVal( $name, 'npmglobal', 1 ) eq '1' ? 1 : 0 ); # outdated if ( $cmd eq 'outdated' ) { $hash->{".fhem"}{npm}{cmd} = $cmd; } # statusRequest elsif ( lc($cmd) eq 'statusrequest' ) { $hash->{".fhem"}{npm}{cmd} = 'getNodeVersion'; } # update | upgrade elsif ( $cmd eq 'update' || $cmd eq 'upgrade' ) { return "Please run outdated check first" unless ( defined( $hash->{".fhem"}{npm}{outdatedpackages} ) ); my $update; # generate explicit list for packages to update if ( @args == 0 || lc( $args[0] ) eq 'all' || lc( $args[0] ) eq 'fhem-all' ) { my $fhemall = @args > 0 && lc( $args[0] ) eq 'fhem-all' ? 1 : 0; undef @args; foreach ( keys %{ $hash->{".fhem"}{npm}{outdatedpackages} } ) { next if ( $_ eq 'undefined' || ( $fhemall && !defined( $fhem_npm_modules{$_} ) ) ); push @args, $_; } } # use 'install' as default update method my $installcmd = 'install'; foreach my $pkgfull (@args) { next unless ( $pkgfull =~ /^(?:@([\w-]+)\/)?([\w-]+)(?:@([\d\.=<>]+|latest))?$/ && defined( $hash->{".fhem"}{npm}{outdatedpackages}{$2} ) ); my $pkg = $2; # if there is a non-FHEM package requested for update, # enforce to use update method instead of install. # See https://forum.fhem.de/index.php/topic,48558. \ # msg910786.html#msg910786 # Also see https://semver.org/ $installcmd = 'update' unless ( defined( $fhem_npm_modules{$pkg} ) || $cmd eq 'upgrade' ); my $v = $3 ? $3 : ( $installcmd eq 'install' ? $hash->{".fhem"}{npm}{outdatedpackages}{$pkg}{latest} : $hash->{".fhem"}{npm}{outdatedpackages}{$pkg}{wanted} ); $update .= " " if ($update); $update .= $pkg . '@' . $v; } return "Everything is up-to-date already" unless ($update); $hash->{".fhem"}{npm}{cmd} = $installcmd . " " . $update; } # install elsif ( $cmd eq 'install' ) { return "usage: $cmd " if ( @args < 1 ); if ( defined( $args[0] ) and ( lc( $args[0] ) eq "all" or lc( $args[0] ) eq "fhem-all" ) ) { my $install; foreach ( keys %fhem_npm_modules ) { next if ( defined( $hash->{".fhem"}{npm}{listedpackages}{dependencies}{$_} ) ); $install .= " " if ($install); $install .= $_; } return "No FHEM specific NPM modules left to install" unless ($install); $hash->{".fhem"}{npm}{cmd} = $cmd . " " . $install; } else { $hash->{".fhem"}{npm}{cmd} = $cmd . " " . join( " ", @args ); } } # uninstall elsif ( $cmd eq 'uninstall' ) { return "usage: $cmd " if ( @args < 1 ); if ( defined( $args[0] ) and lc( $args[0] ) eq "fhem-all" ) { my $uninstall; foreach ( keys %fhem_npm_modules ) { next unless ( defined( $hash->{".fhem"}{npm}{listedpackages}{dependencies}{$_} ) ); $uninstall .= " " if ($uninstall); $uninstall .= $_; } return "No FHEM specific NPM modules left to uninstall" unless ($uninstall); $hash->{".fhem"}{npm}{cmd} = $cmd . " " . $uninstall; } elsif ( defined( $args[0] ) and lc( $args[0] ) eq "all" ) { return "Please run outdated check first" unless ( defined( $hash->{".fhem"}{npm}{listedpackages} ) ); my $uninstall; foreach ( keys %{ $hash->{".fhem"}{npm}{listedpackages}{dependencies} } ) { next if ( $_ eq "npm" ); $uninstall .= " " if ($uninstall); $uninstall .= $_; } return "There is nothing to uninstall" unless ($uninstall); $hash->{".fhem"}{npm}{cmd} = $cmd . " " . $uninstall; } else { return "NPM cannot be uninstalled from here" if ( grep ( m/^(?:@([\w-]+)\/)?(npm)(?:@([\d\.=<>]+|latest))?$/i, @args ) ); $hash->{".fhem"}{npm}{cmd} = $cmd . " " . join( " ", @args ); } } # return Usage: else { my $list = ''; if ( !defined( $hash->{".fhem"}{npm}{nodejsversions} ) ) { $list = "install:nodejs-v12,nodejs-v10,nodejs-v8,nodejs-v6 statusRequest:noArg"; } else { $list = "outdated:noArg"; my $install; foreach ( keys %fhem_npm_modules ) { next if ( defined( $hash->{".fhem"}{npm}{listedpackages} ) and defined( $hash->{".fhem"}{npm}{listedpackages}{dependencies} ) and defined( $hash->{".fhem"}{npm}{listedpackages}{dependencies}{$_} ) ); $install .= "," if ($install); $install = "install:fhem-all," unless ($install); $install .= $_; } $install = "install" unless ($install); $list .= " $install"; if ( defined( $hash->{".fhem"}{npm}{listedpackages} ) and defined( $hash->{".fhem"}{npm}{listedpackages}{dependencies} ) and scalar keys %{ $hash->{".fhem"}{npm}{listedpackages}{dependencies} } > 0 ) { my $uninstall; foreach ( sort keys %{ $hash->{".fhem"}{npm}{listedpackages}{dependencies} } ) { next if ( $_ eq "npm" or $_ eq "undefined" ); $uninstall .= "," if ($uninstall); $uninstall = "uninstall:all,fhem-all," unless ($uninstall); $uninstall .= $_; } $list .= " $uninstall" if ($uninstall); } if ( defined( $hash->{".fhem"}{npm}{outdatedpackages} ) and scalar keys %{ $hash->{".fhem"}{npm}{outdatedpackages} } > 0 ) { my $update; my $upgrade; foreach ( sort keys %{ $hash->{".fhem"}{npm}{outdatedpackages} } ) { next unless ( defined( $hash->{".fhem"}{npm}{outdatedpackages}{$_}{current} ) && defined( $hash->{".fhem"}{npm}{outdatedpackages}{$_}{wanted} ) && defined( $hash->{".fhem"}{npm}{outdatedpackages}{$_}{latest} ) ); if ( defined( $fhem_npm_modules{$_} ) || $hash->{".fhem"}{npm}{outdatedpackages}{$_}{wanted} ne $hash->{".fhem"}{npm}{outdatedpackages}{$_}{current} ) { $update .= "," if ($update); $update .= $_; $upgrade .= "," if ($upgrade); $upgrade .= $_; } else { $upgrade .= "," if ($upgrade); $upgrade .= $_; } } if ($update) { $update = "update:all,fhem-all,$update"; $list .= " $update"; } if ($upgrade) { $upgrade = "upgrade:all,fhem-all,$upgrade"; $list .= " $upgrade"; } } } return "Unknown argument $cmd, choose one of $list"; } AsynchronousExecuteNpmCommand($hash); return undef; } sub Get($$@) { my ( $hash, $name, @aa ) = @_; my ( $cmd, @args ) = @aa; if ( lc($cmd) eq 'showoutdatedlist' ) { return "usage: $cmd" if ( @args != 0 ); my $ret = CreateOutdatedList( $hash, $cmd ); return $ret; } elsif ( lc($cmd) eq 'showinstalledlist' ) { return "usage: $cmd" if ( @args != 0 ); my $ret = CreateInstalledList( $hash, $cmd ); return $ret; } # elsif ( $cmd eq 'showInstallResultList' ) { # return "usage: $cmd" if ( @args != 0 ); # # my $ret = CreateInstallResultList( $hash, $cmd ); # return $ret; # # } # elsif ( $cmd eq 'showUninstallResultList' ) { # return "usage: $cmd" if ( @args != 0 ); # # my $ret = CreateUninstallResultList( $hash, $cmd ); # return $ret; # # } # elsif ( $cmd eq 'showUpdateResultList' ) { # return "usage: $cmd" if ( @args != 0 ); # # my $ret = CreateUpdateResultList( $hash, $cmd ); # return $ret; # # } # elsif ( $cmd eq 'showWarningList' ) { # return "usage: $cmd" if ( @args != 0 ); # # my $ret = CreateWarningList($hash); # return $ret; # # } elsif ( lc($cmd) eq 'showerrorlist' ) { return "usage: $cmd" if ( @args != 0 ); my $ret = CreateErrorList($hash); return $ret; } else { my $list = ''; $list .= " showOutdatedList:noArg" if ( defined( $hash->{".fhem"}{npm}{outdatedpackages} ) and scalar keys %{ $hash->{".fhem"}{npm}{outdatedpackages} } > 0 ); $list .= " showInstalledList:noArg" if ( defined( $hash->{".fhem"}{npm}{listedpackages} ) and defined( $hash->{".fhem"}{npm}{listedpackages}{dependencies} ) and scalar keys %{ $hash->{".fhem"}{npm}{listedpackages}{dependencies} } > 0 ); # $list .= " showInstallResultList:noArg" # if ( defined( $hash->{".fhem"}{npm}{installedpackages} ) # and scalar keys %{ $hash->{".fhem"}{npm}{installedpackages} } > 0 ); # $list .= " showUninstallResultList:noArg" # if ( defined( $hash->{".fhem"}{npm}{uninstalledpackages} ) # and scalar # keys %{ $hash->{".fhem"}{npm}{uninstalledpackages} } > 0 ); # $list .= " showUpdateResultList:noArg" # if ( defined( $hash->{".fhem"}{npm}{updatedpackages} ) # and scalar keys %{ $hash->{".fhem"}{npm}{updatedpackages} } > 0 ); # $list .= " showWarningList:noArg" # if ( defined( $hash->{".fhem"}{npm}{'warnings'} ) # and scalar keys %{ $hash->{".fhem"}{npm}{'warnings'} } > 0 ); $list .= " showErrorList:noArg" if ( defined( $hash->{".fhem"}{npm}{errors} ) and scalar keys %{ $hash->{".fhem"}{npm}{errors} } > 0 ); return "Unknown argument $cmd, choose one of $list"; } } sub Event ($$) { my $hash = shift; my $event = shift; my $name = $hash->{NAME}; return unless ( defined( $hash->{".fhem"}{npm}{cmd} ) && $hash->{".fhem"}{npm}{cmd} =~ m/^(install|uninstall|update)(?: (.+))/i ); my $cmd = $1; my $packages = $2; my $list; foreach my $package ( split / /, $packages ) { next unless ( $package =~ /^(?:@([\w-]+)\/)?([\w-]+)(?:@([\d\.=<>]+|latest))?$/ ); $list .= " " if ($list); $list .= $2; } DoModuleTrigger( $hash, uc($event) . uc($cmd) . " $name $list" ); } sub DoModuleTrigger($$@) { my ( $hash, $eventString, $noreplace, $TYPE ) = @_; $hash = $defs{$hash} unless ( ref($hash) ); $noreplace = 1 unless ( defined($noreplace) ); $TYPE = $hash->{TYPE} unless ( defined($TYPE) ); return '' unless ( defined($TYPE) && defined( $modules{$TYPE} ) && defined($eventString) && $eventString =~ m/^([A-Za-z\d._]+)(?:\s+([A-Za-z\d._]+)(?:\s+(.+))?)?$/ ); my $event = $1; my $dev = $2; return "DoModuleTrigger() can only handle module related events" if ( ( $hash->{NAME} && $hash->{NAME} eq "global" ) || $dev eq "global" ); # This is a global event on module level return DoTrigger( "global", "$TYPE:$eventString", $noreplace ) unless ( $event =~ /^INITIALIZED|INITIALIZING|MODIFIED|DELETED|BEGIN(?:UPDATE|INSTALL|UNINSTALL)|END(?:UPDATE|INSTALL|UNINSTALL)$/ ); # This is a global event on module level and in device context return "$event: missing device name" if ( !defined($dev) || $dev eq '' ); return DoTrigger( "global", "$TYPE:$eventString", $noreplace ); } ################################### sub ProcessUpdateTimer($) { my $hash = shift; my $name = $hash->{NAME}; RemoveInternalTimer($hash); InternalTimer( gettimeofday() + 14400, "FHEM::npmjs::ProcessUpdateTimer", $hash, 0 ); Log3 $name, 4, "npmjs ($name) - stateRequestTimer: Call Request Timer"; unless ( IsDisabled($name) ) { if ( exists( $hash->{".fhem"}{subprocess} ) ) { Log3 $name, 2, "npmjs ($name) - update in progress, process aborted."; return 0; } readingsSingleUpdate( $hash, "state", "ready", 1 ) if ( ReadingsVal( $name, 'state', 'none' ) eq 'none' or ReadingsVal( $name, 'state', 'none' ) eq 'initialized' ); if ( ToDay() ne ( split( ' ', ReadingsTimestamp( $name, 'outdated', '1970-01-01' ) ) )[0] or ReadingsVal( $name, 'state', '' ) eq 'disabled' ) { $hash->{".fhem"}{npm}{cmd} = 'outdated'; AsynchronousExecuteNpmCommand($hash); } } } sub CleanSubprocess($) { my $hash = shift; my $name = $hash->{NAME}; delete( $hash->{".fhem"}{subprocess} ); Log3 $name, 4, "npmjs ($name) - clean Subprocess"; } use constant POLLINTERVAL => 1; sub AsynchronousExecuteNpmCommand($) { require "SubProcess.pm"; my ($hash) = shift; my $name = $hash->{NAME}; my $subprocess = SubProcess->new( { onRun => \&OnRun } ); $subprocess->{npm} = $hash->{".fhem"}{npm}; $subprocess->{npm}{host} = $hash->{HOST}; $subprocess->{npm}{debug} = ( AttrVal( $name, 'verbose', 0 ) > 3 ? 1 : 0 ); $subprocess->{npm}{npmglobal} = ( AttrVal( $name, 'npmglobal', 1 ) eq '1' ? 1 : 0 ); my $pid = $subprocess->run(); readingsSingleUpdate( $hash, 'state', 'command \'npm ' . $hash->{".fhem"}{npm}{cmd} . '\' in progress', 1 ); if ( !defined($pid) ) { Log3 $name, 1, "npmjs ($name) - Cannot execute command asynchronously"; CleanSubprocess($hash); readingsSingleUpdate( $hash, 'state', 'Cannot execute command asynchronously', 1 ); return undef; } Event( $hash, "BEGIN" ); Log3 $name, 4, "npmjs ($name) - execute command asynchronously (PID= $pid)"; $hash->{".fhem"}{subprocess} = $subprocess; InternalTimer( gettimeofday() + POLLINTERVAL, "FHEM::npmjs::PollChild", $hash, 0 ); Log3 $hash, 4, "npmjs ($name) - control passed back to main loop."; } sub PollChild($) { my $hash = shift; my $name = $hash->{NAME}; my $subprocess = $hash->{".fhem"}{subprocess}; my $json = $subprocess->readFromChild(); if ( !defined($json) ) { Log3 $name, 5, "npmjs ($name) - still waiting (" . $subprocess->{lasterror} . ")."; InternalTimer( gettimeofday() + POLLINTERVAL, "FHEM::npmjs::PollChild", $hash, 0 ); return; } else { Log3 $name, 4, "npmjs ($name) - got result from asynchronous parsing."; $subprocess->wait(); Log3 $name, 4, "npmjs ($name) - asynchronous finished."; CleanSubprocess($hash); PreProcessing( $hash, $json ); } } ###################################### # Begin Childprocess ###################################### sub OnRun() { my $subprocess = shift; my $response = ExecuteNpmCommand( $subprocess->{npm} ); my $json = eval { encode_json($response) }; if ($@) { Log3 'npmjs OnRun', 3, "npmjs - JSON error: $@"; $json = "{\"jsonerror\":\"$@\"}"; } $subprocess->writeToParent($json); } sub ExecuteNpmCommand($) { my $cmd = shift; my $npm = {}; $npm->{debug} = $cmd->{debug}; my $cmdPrefix = ''; my $cmdSuffix = ''; my $locale = 'LC_ALL=C'; if ( $cmd->{host} =~ /^(?:(.*)@)?([^:]+)(?::(\d+))?$/ && lc($2) ne "localhost" ) { my $port = ''; if ($3) { $port = "-p $3 "; } # One-time action to add remote hosts key. # If key changes, user will need to intervene # and cleanup known_hosts file manually for security reasons $cmdPrefix = 'KEY=$(' . $locale . ' ssh-keyscan -t ed25519 ' . $2 . ' 2>/dev/null); ' . 'grep -q -E "^${KEY% *}" ${HOME}/.ssh/known_hosts || echo "${KEY}" >> ${HOME}/.ssh/known_hosts; '; $cmdPrefix .= 'KEY=$(' . $locale . ' ssh-keyscan -t rsa ' . $2 . ' 2>/dev/null); ' . 'grep -q -E "^${KEY% *}" ${HOME}/.ssh/known_hosts || echo "${KEY}" >> ${HOME}/.ssh/known_hosts; '; # wrap SSH command $cmdPrefix .= $locale . ' ssh -oBatchMode=yes ' . $port . ( $1 ? "$1@" : '' ) . $2 . ' \''; $cmdSuffix = '\' 2>&1'; } my $global = '-g '; my $sudo = 'sudo -n '; if ( $cmd->{npmglobal} eq '0' ) { $global = ''; $sudo = ''; } $npm->{nodejsversions} = $cmdPrefix . 'echo n | ' . $locale . ' node -e "console.log(JSON.stringify(process.versions));" 2>&1' . $cmdSuffix; $npm->{npminstall} = $cmdPrefix . 'echo n | sh -c "' . $sudo . $locale . ' NODE_ENV=${NODE_ENV:-production} npm install ' . $global . '--json --silent --unsafe-perm %PACKAGES%" 2>&1' . $cmdSuffix; $npm->{npmuninstall} = $cmdPrefix . 'echo n | sh -c "' . $sudo . $locale . ' NODE_ENV=${NODE_ENV:-production} npm uninstall ' . $global . '--json --silent %PACKAGES%" 2>&1' . $cmdSuffix; $npm->{npmupdate} = $cmdPrefix . 'echo n | sh -c "' . $sudo . $locale . ' NODE_ENV=${NODE_ENV:-production} npm update ' . $global . '--json --silent --unsafe-perm %PACKAGES%" 2>&1' . $cmdSuffix; $npm->{npmoutdated} = $cmdPrefix . 'echo n | ' . 'echo "{' . "\n" . '\"versions\": "; ' . $locale . ' node -e "console.log(JSON.stringify(process.versions));"; ' . 'L1=$(' . $locale . ' npm list ' . $global . '--json --silent --depth=0 2>/dev/null); ' . '[ "$L1" != "" ] && [ "$L1" != "\n" ] && echo ", \"listed\": $L1"; ' . 'L2=$(' . $locale . ' npm outdated ' . $global . '--json --silent 2>&1); ' . '[ "$L2" != "" ] && [ "$L2" != "\n" ] && echo ", \"outdated\": $L2"; ' . 'echo "}"' . $cmdSuffix; my $response; if ( $cmd->{cmd} =~ /^install (.+)/ ) { if ( not defined( $cmd->{nodejsversions} ) or not defined( $cmd->{nodejsversions}{node} ) ) { if ( $1 =~ /^nodejs-v(\d+)/ ) { $npm->{npminstall} = $cmdPrefix . 'echo n | if [ -z "$(node --version 2>/dev/null)" ]; then' . ' sh -c "( ' . $locale . ' curl -fsSL https://deb.nodesource.com/setup_' . $1 . '.x 2>/dev/null || ' . $locale . ' wget -qO- https://deb.nodesource.com/setup_' . $1 . '.x 2>/dev/null ) | ' . $locale . ' DEBIAN_FRONTEND=noninteractive sudo -n bash - >/dev/null 2>&1" 2>&1 &&' . ' sh -c "' . $locale . ' DEBIAN_FRONTEND=noninteractive sudo -n apt-get install -qqy nodejs >/dev/null 2>&1" 2>&1; ' . 'fi; ' . $locale . ' node -e "console.log(JSON.stringify(process.versions));" 2>&1' . $cmdSuffix; } } else { my @packages = ''; foreach my $package ( split / /, $1 ) { next unless ( $package =~ /^(?:@([\w-]+)\/)?([\w-]+)(?:@([\d\.=<>]+|latest))?$/ ); push @packages, "homebridge" if ( $package =~ m/^homebridge-/i && ( defined( $cmd->{listedpackages} ) and defined( $cmd->{listedpackages}{dependencies} ) and !defined( $cmd->{listedpackages}{dependencies}{homebridge} ) ) ); push @packages, $package; } my $pkglist = join( ' ', @packages ); return unless ( $pkglist ne '' ); $npm->{npminstall} =~ s/%PACKAGES%/$pkglist/gi; } print qq($npm->{npminstall}\n) if ( $npm->{debug} == 1 ); $response = NpmInstall($npm); } elsif ( $cmd->{cmd} =~ /^uninstall (.+)/ ) { my @packages = ''; foreach my $package ( split / /, $1 ) { next unless ( $package =~ /^(?:@([\w-]+)\/)?([\w-]+)(?:@([\d\.=<>]+|latest))?$/ ); push @packages, $package; } my $pkglist = join( ' ', @packages ); return unless ( $pkglist ne '' ); $npm->{npmuninstall} =~ s/%PACKAGES%/$pkglist/gi; print qq($npm->{npmuninstall}\n) if ( $npm->{debug} == 1 ); $response = NpmUninstall($npm); } elsif ( $cmd->{cmd} =~ /^update(?: (.+))?/ ) { my $pkglist = ''; if ( defined($1) ) { my @packages; foreach my $package ( split / /, $1 ) { next unless ( $package =~ /^(?:@([\w-]+)\/)?([\w-]+)(?:@([\d\.=<>]+|latest))?$/ ); push @packages, $package; } $pkglist = join( ' ', @packages ); } $npm->{npmupdate} =~ s/%PACKAGES%/$pkglist/gi; print qq($npm->{npmupdate}\n) if ( $npm->{debug} == 1 ); $response = NpmUpdate($npm); } elsif ( $cmd->{cmd} eq 'outdated' ) { print qq($npm->{npmoutdated}\n) if ( $npm->{debug} == 1 ); $response = NpmOutdated($npm); } elsif ( $cmd->{cmd} eq 'getNodeVersion' ) { print qq($npm->{nodejsversions}\n) if ( $npm->{debug} == 1 ); $response = GetNodeVersion($npm); } return $response; } sub GetNodeVersion($) { my $cmd = shift; my $p = `$cmd->{nodejsversions}`; my $ret = RetrieveNpmOutput( $cmd, $p ); return { versions => $ret } if ( scalar keys %{$ret} > 0 && !defined( $ret->{error} ) ); return $ret; } sub NpmUninstall($) { my $cmd = shift; my $p = `$cmd->{npmuninstall}`; my $ret = RetrieveNpmOutput( $cmd, $p ); return $ret; } sub NpmUpdate($) { my $cmd = shift; eval { umask 0022; 1; }; my $p = `$cmd->{npmupdate}`; my $ret = RetrieveNpmOutput( $cmd, $p ); return $ret; } sub NpmInstall($) { my $cmd = shift; eval { umask 0022; 1; }; my $p = `$cmd->{npminstall}`; my $ret = RetrieveNpmOutput( $cmd, $p ); # this will come back only after # nodejs installation return { versions => $ret } if ( scalar keys %{$ret} > 0 && defined( $ret->{node} ) ); return $ret; } sub NpmOutdated($) { my $cmd = shift; my $p = `$cmd->{npmoutdated}`; my $ret = RetrieveNpmOutput( $cmd, $p ); return $ret; } sub RetrieveNpmOutput($$) { my $cmd = shift; my $p = shift; my $h = {}; return $h unless ( defined($p) && $p ne '' ); # first try to interprete text as JSON directly my $decode_json = eval { decode_json($p) }; if ( not $@ ) { $h = $decode_json; } # if this was not successful, # we'll disassamble the text else { my $o; my $json; my $skip = 0; foreach my $line ( split /\n/, $p ) { chomp($line); print qq($line\n) if ( $cmd->{debug} == 1 ); # JSON output if ($skip) { $json .= $line; } # reached JSON elsif ( $line =~ /^\{$/ ) { $json = $line; $skip = 1; } # other output before JSON else { $o .= $line; } } $decode_json = eval { decode_json($json) }; # Found valid JSON output if ( not $@ ) { $h = $decode_json; } # Final parsing error else { if ($o) { my $runningUser = getpwuid($<); if ( $o =~ m/Permission.denied.\(publickey\)\.?\r?\n?$/i ) { $h->{error}{code} = "E403"; $h->{error}{summary} = "Forbidden - None of the SSH keys from ~/.ssh/ " . "were authorized to access remote host"; $h->{error}{detail} = "
$o
"; } elsif ( $o =~ m/(sudo: .+)/i ) { $h->{error}{code} = "E403"; $h->{error}{summary} = "Forbidden - " . "passwordless sudo permissions required"; $h->{error}{detail} = $1 . "

" . "You may add the following lines to /etc/sudoers.d/$runningUser:\n" . "
"
                      . "  $runningUser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/npm update *\n"
                      . "  $runningUser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/npm install *\n"
                      . "  $runningUser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/npm uninstall *"
                      . "
"; } elsif ( $o =~ m/(?:(\w+?): )?(?:(\w+? \d+): )?(\w+?): [^:]*?not.found$/i or $o =~ m/(?:(\w+?): )?(?:(\w+? \d+): )?(\w+?): [^:]*?No.such.file.or.directory$/i ) { $h->{error}{code} = "E404"; $h->{error}{summary} = "Not Found - $3 is not installed"; $h->{error}{detail} = "
$o
"; } else { $h->{error}{code} = "E501"; $h->{error}{summary} = "Parsing error - " . $@; $h->{error}{detail} = "
$p
"; } } elsif ( $json =~ m/(?:(\w+?): )?(?:(\w+? \d+): )?(\w+?): [^:]*?not.found$/im or $json =~ m/(?:(\w+?): )?(?:(\w+? \d+): )?(\w+?): [^:]*?No.such.file.or.directory$/im ) { $h->{error}{code} = "E404"; $h->{error}{summary} = "Not Found - $3 is not installed"; $h->{error}{detail} = "
$o
"; } else { $h->{error}{code} = "E500"; $h->{error}{summary} = "Parsing error - " . $@; $h->{error}{detail} = "
$p
"; } } } return $h; } #################################################### # End Childprocess #################################################### sub PreProcessing($$) { my ( $hash, $json ) = @_; my $name = $hash->{NAME}; my $decode_json = eval { decode_json($json) }; if ($@) { Log3 $name, 2, "npmjs ($name) - JSON error: $@"; return; } Log3 $hash, 4, "npmjs ($name) - JSON: $json"; if ( defined( $decode_json->{versions} ) && defined( $decode_json->{versions}{node} ) ) { $hash->{".fhem"}{npm}{nodejsversions} = $decode_json->{versions}; } # safe result in hidden reading # to restore module state after reboot if ( $hash->{".fhem"}{npm}{cmd} eq 'outdated' ) { delete $hash->{".fhem"}{npm}{outdatedpackages}; $hash->{".fhem"}{npm}{outdatedpackages} = $decode_json->{outdated} if ( defined( $decode_json->{outdated} ) ); delete $hash->{".fhem"}{npm}{listedpackages}; $hash->{".fhem"}{npm}{listedpackages} = $decode_json->{listed} if ( defined( $decode_json->{listed} ) ); readingsSingleUpdate( $hash, '.packageList', $json, 0 ); } elsif ( $hash->{".fhem"}{npm}{cmd} =~ /^install/ ) { delete $hash->{".fhem"}{npm}{installedpackages}; $hash->{".fhem"}{npm}{installedpackages} = $decode_json; readingsSingleUpdate( $hash, '.installedList', $json, 0 ); } elsif ( $hash->{".fhem"}{npm}{cmd} =~ /^uninstall/ ) { delete $hash->{".fhem"}{npm}{uninstalledpackages}; $hash->{".fhem"}{npm}{uninstalledpackages} = $decode_json; readingsSingleUpdate( $hash, '.uninstalledList', $json, 0 ); } elsif ( $hash->{".fhem"}{npm}{cmd} =~ /^update/ ) { delete $hash->{".fhem"}{npm}{updatedpackages}; $hash->{".fhem"}{npm}{updatedpackages} = $decode_json; readingsSingleUpdate( $hash, '.updatedList', $json, 0 ); } if ( defined( $decode_json->{warning} ) or defined( $decode_json->{error} ) ) { $hash->{".fhem"}{npm}{'warnings'} = $decode_json->{warning} if ( defined( $decode_json->{warning} ) ); $hash->{".fhem"}{npm}{errors} = $decode_json->{error} if ( defined( $decode_json->{error} ) ); } else { delete $hash->{".fhem"}{npm}{'warnings'}; delete $hash->{".fhem"}{npm}{errors}; } WriteReadings( $hash, $decode_json ); } sub WriteReadings($$) { my ( $hash, $decode_json ) = @_; my $name = $hash->{NAME}; Log3 $hash, 4, "npmjs ($name) - Write Readings"; Log3 $hash, 5, "npmjs ($name) - " . Dumper $decode_json; readingsBeginUpdate($hash); if ( $hash->{".fhem"}{npm}{cmd} eq 'outdated' ) { readingsBulkUpdate( $hash, 'outdated', ( defined( $decode_json->{listed} ) ? 'check completed' : 'check failed' ) ); delete $hash->{".fhem"}{npm}{nodejsversions} unless ( $decode_json->{versions} ); $hash->{helper}{lastSync} = ToDay(); } readingsBulkUpdateIfChanged( $hash, 'updatesAvailable', scalar keys %{ $decode_json->{outdated} } ) if ( $hash->{".fhem"}{npm}{cmd} eq 'outdated' ); readingsBulkUpdateIfChanged( $hash, 'updateListAsJSON', eval { encode_json( $hash->{".fhem"}{npm}{outdatedpackages} ) } ) if ( AttrVal( $name, 'updateListReading', 'none' ) ne 'none' ); my $result = 'successful'; $result = 'error' if ( defined( $hash->{".fhem"}{npm}{errors} ) ); $result = 'warning' if ( defined( $hash->{".fhem"}{npm}{'warnings'} ) ); readingsBulkUpdate( $hash, 'installed', $result ) if ( $hash->{".fhem"}{npm}{cmd} =~ /^install/ ); readingsBulkUpdate( $hash, 'uninstalled', $result ) if ( $hash->{".fhem"}{npm}{cmd} =~ /^uninstall/ ); readingsBulkUpdate( $hash, 'updated', $result ) if ( $hash->{".fhem"}{npm}{cmd} =~ /^update/ ); readingsBulkUpdateIfChanged( $hash, "nodejsVersion", $decode_json->{versions}{node} ) if ( defined( $decode_json->{versions} ) && defined( $decode_json->{versions}{node} ) ); if ( defined( $decode_json->{error} ) ) { readingsBulkUpdate( $hash, 'state', 'error \'' . $hash->{".fhem"}{npm}{cmd} . '\'' ); } elsif ( defined( $decode_json->{warning} ) ) { readingsBulkUpdate( $hash, 'state', 'warning \'' . $hash->{".fhem"}{npm}{cmd} . '\'' ); } else { readingsBulkUpdate( $hash, 'state', ( ( scalar keys %{ $decode_json->{outdated} } > 0 or scalar keys %{ $hash->{".fhem"}{npm}{outdatedpackages} } > 0 ) ? 'npm updates available' : 'npm is up to date' ) ); } Event( $hash, "FINISH" ); readingsEndUpdate( $hash, 1 ); ProcessUpdateTimer($hash) if ( $hash->{".fhem"}{npm}{cmd} eq 'getNodeVersion' && !defined( $decode_json->{error} ) ); } sub CreateWarningList($) { my $hash = shift; my $warnings = $hash->{".fhem"}{npm}{'warnings'}; # disable automatic links to FHEM devices delete $FW_webArgs{addLinks}; my $ret = ''; $ret .= '
'; $ret .= ''; $ret .= ''; $ret .= ""; $ret .= ""; $ret .= ''; if ( ref($warnings) eq "ARRAY" ) { my $linecount = 1; foreach my $warning ( @{$warnings} ) { if ( $linecount % 2 == 0 ) { $ret .= ''; } else { $ret .= ''; } $ret .= ""; $ret .= ''; $linecount++; } } $ret .= '
Warning List
$warning->{message}
'; return $ret; } sub CreateErrorList($) { my $hash = shift; my $error = $hash->{".fhem"}{npm}{errors}; # disable automatic links to FHEM devices delete $FW_webArgs{addLinks}; my $ret = ''; $ret .= '
'; $ret .= ''; if ( ref($error) eq "HASH" ) { $ret .= ''; $ret .= ""; $ret .= ""; $ret .= ''; $ret .= ''; $ret .= ""; $ret .= ''; $ret .= ''; $ret .= ""; $ret .= ''; } else { $ret .= ''; $ret .= ""; $ret .= ""; $ret .= ''; } $ret .= '
Error code $error->{code}
Summary:
$error->{summary}
Detail:
$error->{detail}
Error List
'; return $ret; } sub CreateInstalledList($$) { my ( $hash, $getCmd ) = @_; my @ret; my $packages; my $html = defined( $hash->{CL} ) && $hash->{CL}{TYPE} eq "FHEMWEB" ? 1 : 0; $packages = $hash->{".fhem"}{npm}{listedpackages}{dependencies}; # disable automatic links to FHEM devices delete $FW_webArgs{addLinks}; my $header = ''; my $footer = ''; if ($html) { $header = ''; $footer = '
'; } my $rowOpen = ''; my $rowOpenEven = ''; my $rowOpenOdd = ''; my $colOpen = ''; my $colOpenMinWidth = ''; my $txtOpen = ''; my $txtClose = ''; my $colClose = "\t\t\t"; my $rowClose = ''; if ($html) { $rowOpen = ''; $rowOpenEven = ''; $rowOpenOdd = ''; $colOpen = ''; $colOpenMinWidth = ''; $txtOpen = ""; $txtClose = ""; $colClose = ''; $rowClose = ''; } push @ret, $rowOpen . $colOpenMinWidth . $txtOpen . 'Package Name' . $txtClose . $colClose . $colOpenMinWidth . $txtOpen . 'Installed Version' . $txtClose . $colClose . $rowClose; if ( ref($packages) eq "HASH" ) { my $linecount = 1; foreach my $package ( sort { "\L$a" cmp "\L$b" } keys( %{$packages} ) ) { next if ( $package eq "undefined" ); my $l = $linecount % 2 == 0 ? $rowOpenEven : $rowOpenOdd; $l .= $colOpenMinWidth . $package . $colClose; $l .= $colOpenMinWidth . ( defined( $packages->{$package}{version} ) ? $packages->{$package}{version} : '?' ) . $colClose; $l .= $rowClose; push @ret, $l; $linecount++; } } return $header . join( "\n", @ret ) . $footer; } sub CreateOutdatedList($$) { my ( $hash, $getCmd ) = @_; my @ret; my $packages; my $html = defined( $hash->{CL} ) && $hash->{CL}{TYPE} eq "FHEMWEB" ? 1 : 0; $packages = $hash->{".fhem"}{npm}{outdatedpackages}; my $npmglobal = ( AttrVal( $hash->{NAME}, 'npmglobal', 1 ) eq '1' ? 1 : 0 ); # disable automatic links to FHEM devices delete $FW_webArgs{addLinks}; my $header = ''; my $footer = ''; if ($html) { $header = ''; $footer = '
'; } my $rowOpen = ''; my $rowOpenEven = ''; my $rowOpenOdd = ''; my $colOpen = ''; my $colOpenMinWidth = ''; my $txtOpen = ''; my $txtClose = ''; my $colClose = "\t\t\t"; my $rowClose = ''; if ($html) { $rowOpen = ''; $rowOpenEven = ''; $rowOpenOdd = ''; $colOpen = ''; $colOpenMinWidth = ''; $txtOpen = ""; $txtClose = ""; $colClose = ''; $rowClose = ''; } push @ret, $rowOpen . $colOpenMinWidth . $txtOpen . 'Package Name' . $txtClose . $colClose . $colOpenMinWidth . $txtOpen . 'Installed Version' . $txtClose . $colClose . $colOpenMinWidth . $txtOpen . 'Update Version' . $txtClose . $colClose . $colOpenMinWidth . $txtOpen . 'Upgrade Version' . $txtClose . $colClose . $rowClose; if ( ref($packages) eq "HASH" ) { my $linecount = 1; foreach my $package ( sort { "\L$a" cmp "\L$b" } keys( %{$packages} ) ) { next if ( $package eq "undefined" ); my $fhemPkg = defined( $fhem_npm_modules{$package} ) ? 1 : 0; my $l = $linecount % 2 == 0 ? $rowOpenEven : $rowOpenOdd; $l .= $colOpenMinWidth . $package . $colClose; $l .= $colOpenMinWidth . ( defined( $packages->{$package}{current} ) ? $packages->{$package}{current} : '?' ) . $colClose; $l .= $colOpenMinWidth . ( defined( $packages->{$package}{wanted} ) ? ( $fhemPkg ? $packages->{$package}{latest} : ( defined( $packages->{$package}{current} ) ? ( $packages->{$package}{wanted} ne $packages->{$package}{current} ? $packages->{$package}{wanted} : '' ) : $packages->{$package}{wanted} ) ) : '?' ) . $colClose; $l .= $colOpenMinWidth . ( defined( $packages->{$package}{latest} ) ? $packages->{$package}{latest} : '?' ) . $colClose; $l .= $rowClose; push @ret, $l; $linecount++; } } return $header . join( "\n", @ret ) . $footer; } #### my little helper sub ToDay() { my ( $sec, $min, $hour, $mday, $month, $year, $wday, $yday, $isdst ) = localtime( gettimeofday() ); $month++; $year += 1900; my $today = sprintf( '%04d-%02d-%02d', $year, $month, $mday ); return $today; } 1; =pod =encoding utf8 =item device =item summary Module to control Node.js package installation and update =item summary_DE Modul zur Bedienung der Node.js Paket Installation und Updates =begin html

Node.js installation and update

=end html =begin html_DE

Node.js Installation und Update

=end html_DE =for :application/json;q=META.json 42_npmjs.pm { "abstract": "Module to control Node.js package installation and update", "x_lang": { "de": { "abstract": "Modul zur Bedienung der Node.js Installation und Updates" } }, "version": "v1.1.6", "release_status": "stable", "author": [ "Julian Pawlowski " ], "x_fhem_maintainer": [ "loredo" ], "x_fhem_maintainer_github": [ "jpawlowski" ], "keywords": [ "nodejs", "setup", "update" ], "prereqs": { "runtime": { "requires": { "FHEM": 5.00918799, "perl": 5.014, "GPUtils": 0, "JSON::PP": 0, "Data::Dumper": 0, "SubProcess": 0 }, "recommends": { "JSON": 0 }, "suggests": { "Cpanel::JSON::XS": 0, "JSON::XS": 0 } } }, "x_prereqs_nodejs": { "runtime": { "requires": { "node": 8.0, "npm": 0 }, "recommends": { }, "suggests": { } } }, "x_prereqs_binary_exec": { "runtime": { "requires": { "/usr/bin/node|/usr/local/bin/node": 0, "/usr/bin/npm|/usr/local/bin/npm": 0 }, "recommends": { }, "suggests": { "/usr/bin/ssh|/usr/local/bin/ssh": 0 } } }, "x_prereqs_sudo": { "runtime": { "requires": { }, "recommends": { "ALL=(ALL) NOPASSWD:SETENV: /usr/bin/npm update *": 0, "ALL=(ALL) NOPASSWD:SETENV: /usr/local/bin/npm update *": 0, "ALL=(ALL) NOPASSWD:SETENV: /usr/bin/npm install *": 0, "ALL=(ALL) NOPASSWD:SETENV: /usr/local/bin/npm install *": 0 }, "suggests": { "ALL=(ALL) NOPASSWD:SETENV: /usr/bin/npm uninstall *": 0, "ALL=(ALL) NOPASSWD:SETENV: /usr/local/bin/npm uninstall *": 0 } } } } =end :application/json;q=META.json =cut