You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

580 lines
25 KiB

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
}
}