package provide app-punkshell 1.0 package require Thread package require punk::lib ;#required for compat - lpop for some early Tcl 8.6 versions package require punk::args package require shellfilter package require punk::ansi package require punk::packagepreference punk::packagepreference::install namespace eval punkshell { variable chanstack_stderr_redir variable chanstack_stdout_redir proc clock_sec {} { return [expr {[clock millis]/1000.0}] } set do_log 0 if {$do_log} { set debug_syslog_server 127.0.0.1:514 #set debug_syslog_server 172.16.6.42:51500 set error_syslog_server 127.0.0.1:514 set data_syslog_server 127.0.0.1:514 } else { set debug_syslog_server "" set error_syslog_server "" set data_syslog_server "" } #------------------------------------------------------------------------- ##don't write to stdout/stderr before you've redirected them to a log using shellfilter functions ## puts to stdout/stderr will comingle with command's output if performed before the channel stacks are configured. #chan configure stdin -buffering line #chan configure stdout -buffering none #chan configure stderr -buffering none #redir on the shellfilter stack with no log or syslog specified acts to suppress output of stdout & stderr. #todo - fix shellfilter code to make this noop more efficient (avoid creating corresponding logging thread and filter?) #JMN #set redirconfig {-settings {-syslog 127.0.0.1:514 -file ""}} set redirconfig {} #lassign [shellfilter::redir_output_to_log "SUPPRESS" {*}$redirconfig] chanstack_stdout_redir chanstack_stderr_redir #shellfilter::log::write $punkshell_status_log "shellfilter::redir_output_to_log SUPPRESS DONE [clock_sec]" set stdout_log "" set stderr_log "" set stdout_log "[pwd]/punkshell_out.log" set stderr_log "[pwd]/punkshell_err.log" set errdeviceinfo [shellfilter::stack::new punkshellerr -settings [list -tag "punkshellerr" -buffering none -raw 1 -syslog $data_syslog_server -file $stderr_log]] set outdeviceinfo [shellfilter::stack::new punkshellout -settings [list -tag "punkshellout" -buffering none -raw 1 -syslog $data_syslog_server -file $stdout_log]] #set commandlog [dict get $outdeviceinfo localchan] #puts $commandlog "HELLO $commandlog" #flush $commandlog proc do_script {scriptname args} { #ideally we don't want to launch an external process to run the script #variable punkshell_status_log #shellfilter::log::write $punkshell_status_log "do_script got scriptname:'$scriptname' replwhen:$replwhen args:'$args'" set exepath [file dirname [file join [info nameofexecutable] __dummy__]] set exedir [file dirname $exepath] set scriptpath [file normalize $scriptname] if {![file exists $scriptpath]} { puts stderr "Failed to find script: '$scriptpath'" error "bad scriptpath '$scriptpath'" } set script [string map [list %a% $args %s% $scriptpath] { set normscript %s% #save values set prevscript [info script] set prevglobal [dict create] foreach g [list ::argv ::argc ::argv0] { if {[info exists $g]} { dict set prevglobal $g [set $g] } } #setup and run set ::argv [list %a%] set ::argc [llength $::argv] set ::argv0 $normscript info script $normscript source $normscript #restore values info script $prevscript dict with prevglobal {} }] #---------------------------------------------------- append repl_lines {package require punk::repl} \n append repl_lines {repl::init -type 0} \n append repl_lines [list repl::submit stdin "eval \{ $script \};\n"] \n append repl_lines {repl::start stdin} \n set script $repl_lines #---------------------------------------------------- dict set params -tclscript 1 dict set params -teehandle punkshell #dict set params -teehandle punksh dict set params -inbuffering none dict set params -outbuffering none dict set params -readprocesstranslation crlf dict set params -outtranslation lf #set id_err [shellfilter::stack::add stderr ansiwrap -action sink-locked -settings {-colour {red bold}}] set exitinfo [shellfilter::run $script {*}$params] #shellfilter::stack::remove stderr $id_err if {[dict exists $exitinfo errorInfo]} { #strip out the irrelevant info from the errorInfo - we don't want info beyond 'invoked from within' as this is just plumbing related to the script sourcing set stacktrace [string map [list \r\n \n] [dict get $exitinfo errorInfo]] set output "" set tracelines [split $stacktrace \n] foreach ln $tracelines { if {[string match "*invoked from within*" $ln]} { break } append output $ln \n } set output [string trimright $output \n] dict set exitinfo errorInfo $output } return $exitinfo } proc do_tclkit {kitname replwhen args} { set script [string map [list %a% $args %k% $kitname] { #::tcl::tm::add %m% set kit %k% set kitpath [file normalize $kit] set kitmount $kitpath.0 #save values set prevscript [info script] set prevglobal [dict create] foreach g [list ::argv ::argc ::argv0] { if {[info exists $g]} { dict set prevglobal $g [set $g] } } #setup and run set ::argv [list %a%] set ::argc [llength $::argv] set ::argv0 $kitmount #puts stderr "setting 'info script' $kitmount/main.tcl" info script $kitmount/main.tcl #info script dir must match argv0 for kit main.tcl to return 'starkit' from 'starkit::startup' if {![catch { package require vfs package require vfs::mk4 } errMsg]} { vfs::mk4::Mount $kitpath $kitmount lappend ::auto_path $kitmount/lib if {[file exists "$kitmount/modules"]} { tcl::tm::add "$kitmount/modules" } #puts stderr "sourcing $kitmount/main.tcl" #puts stderr "$kitmount/main.tcl exists: [file exists $kitmount/main.tcl]" #puts stderr "argv : $::argv" #puts stderr "argv0: $::argv0" #puts stderr "autopath: $::auto_path" #puts stdout "starkit::startup [starkit::startup]" #usually main.tcl will just be something like: package require app-XXX #it will usually do nothing if starkit::startup returned 'sourced' source $kitmount/main.tcl } else { puts stderr "Unable to load vfs::mk4 for tclkit mounting" } #restore values info script $prevscript dict with prevglobal {} }] set repl_lines "" append repl_lines {package require punk::repl} \n append repl_lines {repl::init -type 0} \n append repl_lines {repl::start stdin} \n #test #set replwhen "repl_last" if {$replwhen eq "repl_last"} { append script $repl_lines } else { #just the script } dict set params -tclscript 1 ;#don't give callback a chance to omit/break this dict set params -teehandle punkshell dict set params -inbuffering none dict set params -outbuffering none dict set params -readprocesstranslation crlf dict set params -outtranslation lf set id_err [shellfilter::stack::add stderr ansiwrap -action sink-locked -settings {-colour {red bold}}] set exitinfo [shellfilter::run $script {*}$params] shellfilter::stack::remove stderr $id_err if {[dict exists $exitinfo errorInfo]} { #strip out the irrelevant info from the errorInfo - we don't want info beyond 'invoked from within' as this is just plumbing related to the script sourcing set stacktrace [string map [list \r\n \n] [dict get $exitinfo errorInfo]] set output "" set tracelines [split $stacktrace \n] foreach ln $tracelines { if {[string match "*invoked from within*" $ln]} { break } append output $ln \n } set output [string trimright $output \n] dict set exitinfo errorInfo $output } return $exitinfo } proc do_shell {inputchan} { #ideally we don't want to launch an external process to run the script #variable punkshell_status_log #shellfilter::log::write $punkshell_status_log "do_script got scriptname:'$scriptname' replwhen:$replwhen args:'$args'" set exepath [file dirname [file join [info nameofexecutable] __dummy__]] set exedir [file dirname $exepath] #---------------------------------------------------- append repl_lines {package require punk::repl} \n append repl_lines {repl::init -type 0} \n append repl_lines "repl::start $inputchan" \n set script $repl_lines #---------------------------------------------------- dict set params -tclscript 1 dict set params -teehandle punkshell #dict set params -teehandle punksh dict set params -inbuffering none dict set params -outbuffering none dict set params -readprocesstranslation crlf dict set params -outtranslation lf #set id_err [shellfilter::stack::add stderr ansiwrap -action sink-locked -settings {-colour {red bold}}] set exitinfo [shellfilter::run $script {*}$params] #shellfilter::stack::remove stderr $id_err if {[dict exists $exitinfo errorInfo]} { #strip out the irrelevant info from the errorInfo - we don't want info beyond 'invoked from within' as this is just plumbing related to the script sourcing set stacktrace [string map [list \r\n \n] [dict get $exitinfo errorInfo]] set output "" set tracelines [split $stacktrace \n] foreach ln $tracelines { if {[string match "*invoked from within*" $ln]} { break } append output $ln \n } set output [string trimright $output \n] dict set exitinfo errorInfo $output } return $exitinfo } punk::args::define { @id -id ::punkshell @cmd -name punkshell -help\ "punkshell - a shell for running scripts and tclkits with logging and error handling. If no script or kit is specified, a repl is started on stdin." @leaders -min 0 -max 0 @opts -debug -type none @values -min 0 -max -1 script_or_kit -type string -optional 1 arg -type any -optional 1 -multiple 1 } set exitinfo [dict create] set argd [punk::args::parse $::argv withid ::punkshell] lassign [dict values $argd] leaders opts values received if {[dict exists $received script_or_kit]} { set script_or_kit [dict get $values script_or_kit] set script_or_kit [dict get $values script_or_kit] if {[dict exists $received arg]} { set arglist [dict get $values arg] } else { set arglist [list] } switch -glob -nocase -- $script_or_kit { lib:* { set exitinfo {} #scriptlib #There may be one or more colons after lib set cposn [string first : $script_or_kit] set script_or_kit [string trimleft [string range $script_or_kit $cposn+1 end] :] if {[file pathtype $script_or_kit] eq "relative"} { set has_globchars [regexp {[*?]} $script_or_kit] ;#basic globs only? set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve - review should we resolve scriptlib relative to a symlink too? set kit_libdir "" ;#metakit or zipkit libdir set known_extensions [list .tcl .py .pl .ps1 .sh] ;#review set ext [file extension $script_or_kit] if {[string tolower $ext] ni $known_extensions} { #only .tcl scripts allowed to be called extensionlessly set scriptname $script_or_kit.tcl } else { set scriptname $script_or_kit } set lower_ext [string tolower [file extension $scriptname]] if {$lower_ext in {.tcl .kit}} { set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}] set kit_base "" if {$has_zipfs_command && [file exists [tcl::zipfs::root]]} { set kit_base [tcl::zipfs::root] } elseif {[file type $exepath] eq "directory"} { set kit_base $exepath } if {$has_zipfs_command && [file exists $kit_base/app/scriptlib]} { set kit_libdir $kit_base/app/scriptlib } elseif {[file exists $exepath/scriptlib]} { set kit_libdir $exepath/scriptlib } #partly for performance benefit - we don't allow overriding of vfs internal scripts. #Only additional scripts can be provided by the bin/scriptlib or ../bin/scriptlib folders if {$kit_libdir ne "" && [file exists $kit_libdir/$scriptname]} { switch -- $lower_ext { .tcl { set exitinfo [punkshell::do_script $kit_libdir/$scriptname {*}$arglist] } .kit { set exitinfo [punkshell::do_tclkit $kit_libdir/$scriptname "no_repl" {*}$arglist] } } } else { #fallback to external filesystem set exedir [file dirname $exepath] set bin_scripts [file join $exedir scriptlib] set binsibling_scripts [file join [file dirname $exedir] scriptlib] set script_check_paths [list] if {[file exists $bin_scripts]} { lappend script_check_paths $bin_scripts/$scriptname } if {[file exists $binsibling_scripts]} { lappend script_check_paths $binsibling_scripts/$scriptname } if {[llength $script_check_paths]} { foreach check_path $script_check_paths { if {[file exists $check_path]} { switch -- $lower_ext { .tcl { set exitinfo [punkshell::do_script $check_path {*}$arglist] } .kit { set exitinfo [punkshell::do_tclkit $check_path "no_repl" {*}$arglist] } } break } } } else { puts stderr "script $script_or_kit not found in vfs or in filesystem relative to $exedir" puts stderr "valid locations:" if {$kit_base ne ""} { puts stderr " $kit_base/scriptlib/$scriptname" } puts stderr " $bin_scripts/$scriptname" puts stderr " $binsibling_scripts/$scriptname" } } } else { puts stderr "No current support for extension [file extension $scriptname]" } } else { puts stderr "Path supplied to lib: must be a relative path" } } *.tcl { #except for lib:*.tcl set exitinfo [punkshell::do_script $script_or_kit {*}$arglist] } *.kit { set exitinfo [punkshell::do_tclkit $script_or_kit "no_repl" {*}$arglist] } default { if {[file exists $script_or_kit]} { #puts stderr "Script $script_or_kit exists but has unrecognised extension" set exitinfo [punkshell::do_script $script_or_kit {*}$arglist] } else { puts stderr "Script $script_or_kit does not exist" } } } } else { set exitinfo [punkshell::do_shell stdin] } flush stderr flush stdout #puts stderr "\n - punkshell.tcl 1" #flush stderr #catch { # shellfilter::stack::remove stderr $chanstack_stderr_redir # shellfilter::stack::remove stdout $chanstack_stdout_redir #} #puts stderr "punkshell.tcl 2" shellfilter::stack::delete punkshellout shellfilter::stack::delete punkshellerr #puts stderr "punkshell.tcl 3" set free_info [shellthread::manager::shutdown_free_threads] #puts stderr "punkshell.tcl 4" foreach tid [thread::names] { thread::release $tid } #puts stderr "punkshell.tcl 5" if {[dict size $exitinfo] == 0} { puts stderr "No result" exit 2 } if {[dict exists $exitinfo errorInfo]} { set einf [dict get $exitinfo errorInfo] puts stderr "errorCode: [dict get $exitinfo errorCode]" if {[catch { punk::ansi::ansiwrap yellow bold $einf } msg]} { set msg $einf } puts stderr $msg flush stderr exit 1 } else { set result [dict get $exitinfo result] set firstword [lindex $result 0] switch -- $firstword { exit { exit [lindex $result 1] } quit { puts stdout "result: [lrange $result 1 end]" exit 0 } interrupt { #exit with 128 + signal number - unix-like compatibility. exit [expr {[lindex $result 1] + 128}] } eof { puts "app-punkshell eof on input channel [lindex $result 1]" #eof on the input channel - usually piped/redirected stdin reaching its end (also e.g ctrl-z/ctrl-d at a console) #Historical behaviour is to reopen the console and drop into an interactive repl - desirable when a user is #present, but a permanent hang for automated callers (CI/agents/exec) since nobody will ever type anything. #Policy selected via env(PUNK_PIPE_EOF) (documented in punk::config punk_env_vars_config - see 'help env'): # exit - always terminate # interactive - always attempt the console reopen (terminates exitcode 1 if no console can be opened) # unset/other - heuristic: attempt the console reopen, terminate cleanly if no console is available #Note the heuristic cannot detect a console that exists but has no user watching (some CI/agent harnesses) - #automated callers should set PUNK_PIPE_EOF=exit (or ensure their piped input ends with an explicit exit). #review - propagate a nonzero exitcode if errors occurred in the piped input? #review - should this consult the punk::config running configuration (pipe_eof) rather than raw ::env? set eof_policy heuristic if {[info exists ::env(PUNK_PIPE_EOF)]} { switch -exact -- [string tolower $::env(PUNK_PIPE_EOF)] { exit - quit - terminate { set eof_policy exit } interactive - repl - shell { set eof_policy interactive } } } if {$eof_policy eq "exit"} { exit 0 } if {$::tcl_platform(platform) eq "windows"} { #set console_input_source "CON" set console_input_source {CONIN$} } else { #/dev/tty - reference to the controlling terminal for a process #review/test set console_input_source "/dev/tty" } if {[catch {open $console_input_source r} s]} { puts stderr "app-punkshell: no console available for interactive shell after eof on input channel ($console_input_source: $s)" if {$eof_policy eq "interactive"} { exit 1 } exit 0 } set errdeviceinfo [shellfilter::stack::new punkshellerr -settings [list -tag "punkshellerr" -buffering none -raw 1 -syslog {} -file {}]] set outdeviceinfo [shellfilter::stack::new punkshellout -settings [list -tag "punkshellout" -buffering none -raw 1 -syslog {} -file {}]] if {$::tcl_platform(platform) eq "windows"} { if {[package provide twapi] ne ""} { set h [twapi::get_tcl_channel_handle $s in] twapi::SetStdHandle -10 $h } } puts stderr "punkshell.tcl restarting repl on inputchannel:$s" #puts stderr "input chan '$s': [chan configure $s]" set ::tcl_interactive [punk::repl::is_interactive $s] #puts "app-punkshell: tcl_interactive: $::tcl_interactive" set exitinfo [punkshell::do_shell $s] shellfilter::stack::delete punkshellout shellfilter::stack::delete punkshellerr #puts stderr "punkshell.tcl 3" set free_info [shellthread::manager::shutdown_free_threads] #puts stderr "punkshell.tcl 4" foreach tid [thread::names] { thread::release $tid } if {[dict exists $exitinfo errorInfo]} { set einf [dict get $exitinfo errorInfo] puts stderr "errorCode: [dict get $exitinfo errorCode]" if {[catch { punk::ansi::ansiwrap yellow bold $einf } msg]} { set msg $einf } puts stderr $msg flush stderr exit 1 } set result [dict get $exitinfo result] set firstword [lindex $result 0] switch -- $firstword { exit { exit [lindex $result 1] } quit { puts stdout "result: [lrange $result 1 end]" exit 0 } interrupt { #exit with 128 + signal number - unix-like compatibility. exit [expr {[lindex $result 1] + 128}] } default { #review - we should get a recognised firstword. puts stderr "unexpected result from repl after reopened input channel: $result" flush stdout exit 1 } } } default { #review - we should get a recognised firstword. puts stdout "result: $result" flush stdout exit 1 } } #puts -nonewline stdout [dict get $exitinfo result] #puts stdout $exitinfo #flush stdout #don't call exit on success - allows tk apps to stay alive. #exit 0 } }