diff --git a/bin/AGENTS.md b/bin/AGENTS.md index 3914359e..c1d786cd 100644 --- a/bin/AGENTS.md +++ b/bin/AGENTS.md @@ -9,7 +9,8 @@ Built punk shell executables (kits with the punk boot layer), assorted build/exp The `.cmd` scripts here (e.g `runtime.cmd`) are punk MULTISHELL polyglots GENERATED by `punk::mix::commandset::scriptwrap::multishell` from scriptset sources under `src/scriptapps/bin/` (the home for bin-deployed scriptsets, e.g `runtime.*` alongside -`getzig.*`): payload scripts (`.ps1`, `.bash`, ...) plus a `_wrap.toml` +`getzig.*`) or `src/scriptapps/` itself (e.g `tclargs.*`, `dtplite.*`): payload scripts +(`.ps1`, `.bash`, `.tcl`, ...) plus a `_wrap.toml` config, spliced into the `punk.multishell.cmd` template. The polyglot structure is deliberately fragile (mutual shell-hiding tricks, LF-only endings, cmd.exe's 512-byte label-scanner constraints) - a hand-edit can silently break one of the participating @@ -31,6 +32,17 @@ When asked to "fix bin/.cmd": re-wrap of the runtime scriptset reproduces `bin/runtime.cmd` byte for byte (`src/tests/modules/punk/mix/testsuites/scriptwrap/multishell.test` scriptwrap_runtime_cmd_roundtrip_no_drift), so drift in either direction fails tests. + `bin/dtplite.cmd` is pinned the same way + (`src/tests/shell/testsuites/binscripts/dtplite.test` dtplite_cmd_roundtrip_no_drift). + +### dtplite (`dtplite.cmd`) + +`bin/dtplite.cmd` wraps tcllib's dtplite doctools processor (payload +`src/scriptapps/dtplite.tcl`, tclsh nextshell on all platforms). It exists so the punk +repl's unknown-handler can resolve the bare `dtplite` command via PATH - `dev +doc.validate` (punk::mix::commandset::doc) depends on it. The payload falls back to the +project-vendored tcllib (`src/vendorlib_tcl9//tcllib*`) when the invoking tclsh +has no dtplite package installed. ## Local Contracts diff --git a/bin/dtplite.cmd b/bin/dtplite.cmd new file mode 100644 index 00000000..d617e546 --- /dev/null +++ b/bin/dtplite.cmd @@ -0,0 +1,1661 @@ +: "punk MULTISHELL - shebangless polyglot for Tcl Perl sh zsh/bash cmd pwsh powershell" + "[rename set S;proc Hide shell_not_supported {proc $shell_not_supported args {}};Hide :]" + "\$(function : {<#pwsh#>})" + "perlhide" + qw^ +set -- "$@" "a=[Hide <#;Hide set;S 1 list]"; set -- : "$@";$1 = @' +: heredoc1 - hide from powershell using @ and squote above. close sqote for unix shells + ' \ +: .bat/.cmd launch section, leading colon hides from cmd, trailing slash hides next line from tcl + \ +: "[Hide @GOTO; Hide =begin; Hide @REM] #not necessary but can help avoid errs in testing" + +: << 'HEREDOC1B_HIDE_FROM_BASH_AND_SH' +: STRONG SUGGESTION: DO NOT MODIFY FIRST LINE OF THIS SCRIPT - except for first double quoted section. +: shebang line is not required on unix or windows and will reduce functionality and/or portability. +: Even comment lines can be part of the functionality of this script (both on unix and windows) - modify with care. +@GOTO :skip_perl_pod_start ^; +=begin excludeperl +: skip_perl_pod_start +: Continuation char at end of this line and rem with curly-braces used to exlude Tcl from the whole cmd block \ +: { +@REM ############################################################################################################################ +@REM THIS IS A POLYGLOT SCRIPT - supporting payloads in Tcl, zsh, bash, (sh diversion) and/or powershelll (powershell.exe or pwsh.exe) +@REM It should remain portable between unix-like OSes & windows if the proper structure is maintained. +@REM ############################################################################################################################ +@rem ------------------------------------------------------------------------------------------------------------------------------- +@rem return from endlocal macro - courtesy of jeb +@rem This allows return of values containing special characters from subroutines +@rem https://stackoverflow.com/questions/3262287/make-an-environment-variable-survive-endlocal/8257951#8257951 +@rem ------------------------------------------------------------------------------------------------------------------------------- +@setlocal DisableDelayedExpansion +@echo off +%= 2 blank lines after next are required =% +set LF=^ + + +set ^"\n=^^^%LF%%LF%^%LF%%LF%^^" +%= I use EDE for EnableDelayeExpansion and DDE for DisableDelayedExpansion =% +set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% + setlocal EnableDelayedExpansion%\n% + %= Take all variable names into the varName array =%%\n% + set varName_count=0%\n% + for %%C in (!args!) do set "varName[!varName_count!]=%%~C" ^& set /a varName_count+=1%\n% + %= Build one variable with a list of set statements for each variable delimited by newlines =%%\n% + %= The lists looks like --> set result1=myContent\n"set result1=myContent1"\nset result2=content2\nset result2=content2\n =%%\n% + %= Each result exists two times, the first for the case returning to DDE, the second for EDE =%%\n% + %= The correct line will be detected by the (missing) enclosing quotes =%%\n% + set "retContent=1!LF!"%\n% + for /L %%n in (0 1 !varName_count!) do (%\n% + for /F "delims=" %%C in ("!varName[%%n]!") DO (%\n% + set "content=!%%C!"%\n% + set "retContent=!retContent!"set !varName[%%n]!=!content!"!LF!"%\n% + if defined content (%\n% + %= This complex block is only for replacing '!' with '^!' =%%\n% + %= First replacing '"'->'""q' '^'->'^^' =%%\n% + set ^"content_EDE=!content:"=""q!"%\n% + set "content_EDE=!content_EDE:^=^^!"%\n% + %= Now it's poosible to use CALL SET and replace '!'->'""e!' =%%\n% + call set "content_EDE=%%content_EDE:^!=""e^!%%"%\n% + %= Now it's possible to replace '""e' to '^', this is effectivly '!' -> '^!' =%%\n% + set "content_EDE=!content_EDE:""e=^!"%\n% + %= Now restore the quotes =%%\n% + set ^"content_EDE=!content_EDE:""q="!"%\n% + ) ELSE set "content_EDE="%\n% + set "retContent=!retContent!set "!varName[%%n]!=!content_EDE!"!LF!"%\n% + )%\n% + )%\n% + %= Now return all variables from retContent over the barrier =%%\n% + for /F "delims=" %%V in ("!retContent!") DO (%\n% + %= Only the first line can contain a single 1 =%%\n% + if "%%V"=="1" (%\n% + %= We need to call endlocal twice, as there is one more setlocal in the macro itself =%%\n% + endlocal%\n% + endlocal%\n% + ) ELSE (%\n% + %= This is true in EDE =%%\n% + if "!"=="" (%\n% + if %%V==%%~V (%\n% + %%V !%\n% + )%\n% + ) ELSE IF not %%V==%%~V (%\n% + %%~V%\n% + )%\n% + )%\n% + )%\n% + ) else set args=" + +@rem ------------------------------------------------------------------------------------------------------------------------------- +@SETLOCAL EnableExtensions EnableDelayedExpansion +@REM Change the value of nextshell to one of the supported types, and add code within payload sections for tcl,sh,bash,powershell as appropriate. +@REM This wrapper can be edited manually (carefully!) - or bash,tcl,perl,powershell scripts can be wrapped using the Tcl-based punkshell system +@REM e.g from within a running punkshell: dev scriptwrap.multishell -outputfolder +@REM Call with sh, bash, perl, or tclsh. (powershell untested on unix) +@REM Due to lack of shebang (#! line) Unix-like systems will hopefully default to a flavour of sh that can divert to bash if the script is called without an interpreter - but it may depend on the shell in use when called. +@REM If you find yourself really wanting/needing to add a shebang line - do so on the basis that the script will exist on unix-like systems only. +@REM in batch scripts - array syntax with square brackets is a simulation of arrays or associative arrays. +@REM note that many shells linked as sh do not support substition syntax and may fail - e.g dash etc - generally bash should be used in this context +@SET "validshelltypes= pwsh____________ powershell______ sh______________ wslbash_________ bash____________ tcl_____________ perl____________ none____________" +@REM for batch - only win32 is relevant - but other scripts on other platforms also parse the nextshell block to determine next shell to launch +@REM nextshellpath and nextshelltype indices (underscore-padded to 16wide) are "other" plus those returned by Tcl platform pkg e.g win32,linux,freebsd,macosx +@REM The horrible underscore-padded fixed-widths are to keep the batch labels aligned whilst allowing values to be set +@REM If more than 64 chars needed for a target, it can still be done but overall script padding may need checking/adjusting +@REM Supporting more explicit oses than those listed may also require script padding adjustment +: <> +@SET "nextshellpath[win32___________]=tclsh___________________________________________________________________________________________________________________________" +@SET "nextshelltype[win32___________]=tcl_____________" +@SET "nextshellpath[dragonflybsd____]=/usr/bin/env tclsh______________________________________________________________________________________________________________" +@SET "nextshelltype[dragonflybsd____]=tcl_____________" +@SET "nextshellpath[freebsd_________]=/usr/bin/env tclsh______________________________________________________________________________________________________________" +@SET "nextshelltype[freebsd_________]=tcl_____________" +@SET "nextshellpath[netbsd__________]=/usr/bin/env tclsh______________________________________________________________________________________________________________" +@SET "nextshelltype[netbsd__________]=tcl_____________" +@SET "nextshellpath[linux___________]=/usr/bin/env tclsh______________________________________________________________________________________________________________" +@SET "nextshelltype[linux___________]=tcl_____________" +@SET "nextshellpath[macosx__________]=/usr/bin/env tclsh______________________________________________________________________________________________________________" +@SET "nextshelltype[macosx__________]=tcl_____________" +@SET "nextshellpath[other___________]=/usr/bin/env tclsh______________________________________________________________________________________________________________" +@SET "nextshelltype[other___________]=tcl_____________" +: <> +@rem asadmin is for automatic elevation to administrator. Separate window will be created (seems unavoidable with current elevation mechanism) and user will still get security prompt (probably reasonable). +: <> +@SET "asadmin=0" +: <> +@SET "selected_shelltype=%nextshelltype[win32___________]%" +@REM @ECHO selected_shelltype %selected_shelltype% +@CALL :stringTrimTrailingUnderscores %selected_shelltype% selected_shelltype_trimmed +@REM @ECHO selected_shelltype_trimmed %selected_shelltype_trimmed% +@SET "selected_shellpath=%nextshellpath[win32___________]%" +@CALL :stringTrimTrailingUnderscores "%selected_shellpath%" selected_shellpath_trimmed +@CALL SET "keyRemoved=%%validshelltypes:!selected_shelltype!=%%" +@REM @ECHO keyremoved %keyRemoved% +@REM Note that 'powershell' e.g v5 is just a fallback for when pwsh is not available +@REM ## ### ### ### ### ### ### ### ### ### ### ### ### ### +@REM -- cmd/batch file section (ignored on unix but should be left in place) +@REM -- This section intended mainly to launch the next shell (and to escalate privileges if necessary) +@REM -- Avoid customising this if you are not familiar with batch scripting. cmd/batch script can be useful, but is probably the least expressive language and most error prone. +@REM -- For example - as this file needs to use unix-style lf line-endings - the label scanner is susceptible to the 512Byte boundary issue: https://www.dostips.com/forum/viewtopic.php?t=8988#p58888 +@REM -- This label issue can be triggered/abused in files with crlf line endings too - but it is less likely to happen accidentaly. +@REm -- See also: https://stackoverflow.com/questions/4094699/how-does-the-windows-command-interpreter-cmd-exe-parse-scripts/4095133#4095133 +@REM ############################################################################################################################ +@REM -- Due to this issue -seemingly trivial edits of the batch file section can break the script! (for Windows anyway) +@REM -- Even something as simple as adding or removing an @REM +@REM -- From within punkshell - use: +@REM -- deck scriptwrap.checkfile filepath +@REM -- to check your templates or final wrapped scripts for byte boundary issues +@REM -- It will report any labels that are on boundaries +@REM -- This is why the nextshell value above is a 2 digit key instead of a string - so that editing the value doesn't change the byte offsets. +@REM -- Editing your sh,bash,tcl,pwsh payloads is much less likely to cause an issue. There is the possibility of the final batch :exit_multishell label spanning a boundary - so testing using deck scriptwrap.checkfile is still recommended. +@REM -- Alternatively, as you should do anyway - test the final script on windows +@REM -- Aside from adding comments/whitespace to tweak the location of labels - you can try duplicating the label (e.g just add the label on a line above) but this is not guaranteed to work in all situations. +@REM -- '@REM' is a safer comment mechanism than a leading colon - which is used sparingly here. +@REM -- A colon anywhere in the script that happens to land on a 512 Byte boundary (from file start or from a callsite) could be misinterpreted as a label +@REM -- It is unknown what versions of cmd interpreters behave this way - and deck scriptwrap.checkfile doesn't check all such boundaries. +@REM -- For this reason, batch labels should be chosen to be relatively unlikely to collide with other strings in the file, and simple names such as :exit or :end should probably be avoided +@REM ############################################################################################################################ +@REM -- custom windows payloads should be in powershell,tclsh (or sh/bash if available) code sections +@REM ## ### ### ### ### ### ### ### ### ### ### ### ### ### +@SET "winpath=%~dp0" %= e.g c:\punkshell\bin\ %= +@SET "fname=%~nx0" +@SET "scriptrootname=%~dp0%~n0" %= e.g c:\punkshell\bin\runtime (full path without extension) unavailable after shift, so store it =% +@SET "fullscriptname=%~dp0%~n0%~x0" +@REM @ECHO fname %fname% +@REM @ECHO winpath %winpath% +@REM @ECHO commandlineascalled %0 +@REM @ECHO commandlineresolved %~f0 +@CALL :getNormalizedScriptTail nftail +@REM @ECHO normalizedscripttail %nftail% +@CALL :getFileTail %0 clinetail +@REM @ECHO clinetail %clinetail% +@CALL :stringToUpper %~nx0 capscripttail +@REM @ECHO capscriptname: %capscripttail% + +@goto skip_parameter_wrangling +@set argCount=30 +@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe +@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon +@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. +@set tmpfile_base=%TEMP%\punkbatch_params +@call :getUniqueFile %tmpfile_base% ".txt" paramfile +@echo %paramfile% + +%= NOTE when we loop like this using the percent-n args and shift, we lose unquoted separators such as comma and semicolon %= +@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 +@rem outer loop required to redirect all rem lines at once to file +@for %%x in (1) do @( + @for /L %%f in (1,1,%argCount%) do @( + @set "argnum=%%~nf" + @set "a1=%%1" + @rem @set "argname=%%!argnum!" + @rem @echo argname: !argname! + @call :rem_output !argnum! !a1! + @shift + ) +) > %paramfile% +@echo off + +@set "newcommandline= " + +@(set target=cmd_pwsh) +@if "%target%"=="cmd_pwsh" ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + @REM @echo ######### %%L + @rem call :buildcmdline newcommandline param "{" "}" + @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= + call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= + @rem @echo . + ) +) ELSE ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + call :buildcmdline newcommandline param + ) +) +@REM padding +SETLOCAL EnableDelayedExpansion +@echo off +@IF EXIST %paramfile% ( + @DEL /F /Q %paramfile% +) +@IF EXIST %paramfile% ( + echo failed to delete %paramfile% + cat %paramfile% +) +:skip_parameter_wrangling + +@IF "%nftail%"=="%capscripttail%" ( + @ECHO forcing asadmin=1 due to file name on filesystem being uppercase + @SET "asadmin=1" +) else ( + @CALL :stringToUpper %clinetail% capcmdlinetail + @REM @ECHO capcmdlinetail !capcmdlinetail! + IF "%clinetail%"=="!capcmdlinetail!" ( + @ECHO forcing asadmin=1 due to cmdline scriptname in uppercase + @set "asadmin=1" + ) +) +@SET "vbsGetPrivileges=%temp%\punk_bat_elevate_%fname%.vbs" +@SET arglist=%* +@SET "qstrippedargs=args%arglist%" +@SET "qstrippedargs=%qstrippedargs:"=%" +@IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" ( + GOTO :gotPrivileges +) +@IF !asadmin!==1 ( + net file 1>NUL 2>NUL + @IF '!errorlevel!'=='0' ( GOTO :gotPrivileges ) else ( GOTO :getPrivileges ) +) +@REM padding +@GOTO skip_privileges +:getPrivileges +@IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" (echo PUNK-ELEVATED & shift /1 & goto :gotPrivileges ) +@ECHO Set UAC = CreateObject^("Shell.Application"^) > "%vbsGetPrivileges%" +@ECHO pre = "/c %fullscriptname% PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@REM @echo pre = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@echo args = pre >> "%vbsGetPrivileges%" +@ECHO For Each strArg in WScript.Arguments >> "%vbsGetPrivileges%" +@ECHO args = args ^& Chr(34) ^& strArg ^& Chr(34) ^& " " >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@GOTO skiptest + +%= Option Explicit =% +%= We need a child process to locate the current script. =% +@ECHO Const FLAG_PROCESS = "winver.exe" >> "%vbsGetPrivileges%" + +%= ' WMI constants %= +@ECHO Const wbemFlagForwardOnly = 32 >> "%vbsGetPrivileges%" + +%=' Generate a unique value to be used as a flag =% +@ECHO Dim guid >> "%vbsGetPrivileges% +@ECHO guid = Left(CreateObject("Scriptlet.TypeLib").GUID,38) >> "%vbsGetPrivileges%" + +%= ' Start a process using the indicated flag inside its command line =% +@ECHO WScript.CreateObject("WScript.Shell").Run """" ^& FLAG_PROCESS ^& """ " ^& guid, 0, False >> "%vbsGetPrivileges%" + +%= ' To retrieve process information a WMI reference is needed =% +@ECHO Dim wmi >> "%vbsGetPrivileges%" +@ECHO Set wmi = GetObject("winmgmts:{impersonationLevel=impersonate}^!\\.\root\cimv2") >> "%vbsGetPrivileges%" + +%= ' Query the list of processes with the flag in its command line, retrieve the =% +%= ' process ID of its parent process ( our script! ) and terminate the process =% +@ECHO Dim colProcess, process, myProcessID >> "%vbsGetPrivileges%" +@ECHO Set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT ParentProcessID From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE Name='" ^& FLAG_PROCESS ^& "' " ^& _>> "%vbsGetPrivileges%" +@ECHO "AND CommandLine LIKE '%%" ^& guid ^& "%%'" _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO myProcessID = process.ParentProcessID >> "%vbsGetPrivileges%" +@ECHO process.Terminate >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" + +%= ' Knowing the process id of our script we can query the process list =% +%= ' and retrieve its command line =% +@ECHO Dim commandLine >> "%vbsGetPrivileges%" +@ECHO set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT CommandLine From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE ProcessID=" ^& myProcessID _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO commandLine = process.CommandLine >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "raw commandline: " ^& commandLine >>"%vbsGetPrivileges%" + +%= ' Done =% +@ECHO intpos = 0 >> "%vbsGetPrivileges%" +@ECHO intCount = 0 >> "%vbsGetPrivileges%" +@ECHO intstartsearch = 1 >> "%vbsGetPrivileges%" +@ECHO intmax = 100 >> "%vbsGetPrivileges%" +@ECHO do While intCount ^< 4 and intmax ^> 0 >> "%vbsGetPrivileges%" +@ECHO intpos = InStr(intstartsearch, commandline, """") >> "%vbsGetPrivileges%" +@ECHO if intpos ^<^> 0 then >> "%vbsGetPrivileges%" +@ECHO intCount = intCount + 1 >> "%vbsGetPrivileges%" +@ECHO if intcount = 4 then >> "%vbsGetPrivileges%" +@ECHO ' wscript.echo "position: " ^& intpos >> "%vbsGetPrivileges%" +@ECHO commandline = Mid(commandline,intpos+1) >> "%vbsGetPrivileges%" +@ECHO exit do >> "%vbsGetPrivileges%" +@ECHO else >> "%vbsGetPrivileges%" +@ECHO intstartsearch = intpos + 1 >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO intmax = intmax -1 >> "%vbsGetPrivileges%" +@ECHO Loop >> "%vbsGetPrivileges%" +@ECHO if intcount ^< 4 then >> "%vbsGetPrivileges%" +@ECHO err.raise vbObjectError + 1001, "vbsGetPrivileges", "failed to parse commandline" >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO commandline = pre ^& commandline >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "commandline: " ^& commandLine >>"%vbsGetPrivileges%" +@ECHO WScript.Echo "args: " ^& args >>"%vbsGetPrivileges%" +:skiptest + +@ECHO UAC.ShellExecute "cmd.exe", args, "", "runas", 1 >> "%vbsGetPrivileges%" +@REM @ECHO UAC.ShellExecute "%fullscriptname%", commandline, "", "runas", 1 >> "%vbsGetPrivileges%" +@ECHO Launching script "%fullscriptname%" in new window due to administrator elevation with args: "%*" +@"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" %* +@REM @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" !newcommandline! +@EXIT /B + +@REM buffer +@REM buffer +:gotPrivileges +@REM setlocal & pushd . +@PUSHD . +@cd /d %winpath% +@IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" ( + @DEL "%vbsGetPrivileges%" 1>nul 2>nul + @SET arglist=%arglist:~14% + @SHIFT +) + +:skip_privileges +@SET need_ps1=0 +@REM we want the ps1 to exist even if the nextshell isn't powershell +@if not exist "%scriptrootname%.ps1" ( + @SET need_ps1=1 +) ELSE ( + fc "%fullscriptname%" "%scriptrootname%.ps1" >nul || goto different + @REM @ECHO "files same" + @SET need_ps1=0 +) +@GOTO :pscontinue +:different +@REM @ECHO "files differ" +@SET need_ps1=1 +:pscontinue +@IF !need_ps1!==1 ( + COPY "%fullscriptname%" "%scriptrootname%.ps1" >NUL +) +@REM avoid using CALL to launch pwsh,tclsh etc - it will intercept some args such as /? +@IF "!selected_shelltype_trimmed!"=="none" ( + SET selected_shelltype_trimmed=pwsh +) + +@REM @SET "squoted_args=" +@REM @for %%a in (%*) do @( +@REM set "v=%%a" +@REM set "v=!v:'=''!" +@REM SET "squoted_args=!squoted_args!'!v!' " +@REM ) +@REM @SET "squoted_args=%squoted_args:~0,-1%" +@REM @ECHO %squoted_args% + + +@IF "!selected_shelltype_trimmed!"=="pwsh" ( + REM pwsh vs powershell hasn't been tested because we didn't need to copy cmd to ps1 this time + REM test availability of preferred option of powershell7+ pwsh + REM when run without cmd.exe - pwsh will receive the semicolon (for cmd.exe unquoted semicolon and comma are separators that aren't seen in positional arguments) + pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted 2>NUL; write-host "statusmessage: pwsh-found" >NUL + SET pwshtest_exitcode=!errorlevel! + REM ECHO pwshtest_exitcode !pwshtest_exitcode! + REM fallback to powershell if pwsh failed + IF !pwshtest_exitcode!==0 ( + @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; "%scriptrootname%.ps1" %arglist% + @rem pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" %arglist% %= ok =% + @rem cmd /c pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + SET task_exitcode=!errorlevel! + ) ELSE ( + REM TODO prompt user with option to call script to install pwsh using winget + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + SET task_exitcode=!errorlevel! + ) +) ELSE ( + IF "!selected_shelltype_trimmed!"=="powershell" ( + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + @rem @echo powershell - !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% %= ok - this works =% + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + SET task_exitcode=!errorlevel! + ) ELSE ( + IF "!selected_shelltype_trimmed!"=="wslbash" ( + CALL :getWslPath %winpath% wslpath + REM ECHO wslfullpath "!wslpath!%fname%" + %selected_shellpath_trimmed% "!wslpath!%fname%" %arglist% + SET task_exitcode=!errorlevel! + ) ELSE ( + REM perl or tcl or sh or bash + IF NOT "x%keyRemoved%"=="x%validshelltypes%" ( + REM sh on windows uses /c/ instead of /mnt/c - at least if using msys. Todo, review what is the norm on windows with and without msys2,cygwin,wsl + REM and what logic if any may be needed. For now sh with /c/xxx seems to work the same as sh with c:/xxx + REM The compound statement with trailing call is required to stop batch termination confirmation, whilst still capturing exitcode + @REM @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" + !selected_shellpath_trimmed! "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; + ) ELSE ( + ECHO %fname% has invalid nextshelltype value %selected_shelltype% valid options are %validshelltypes% + SET task_exitcode=66 + @REM boundary padding + ) + ) + ) +) +@REM batch file library functions +@GOTO :endlib + +@REM padding xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +@REM padding xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +%= ---------------------------------------------------------------------- =% +@rem courtesy of dbenham +:: Example usage +@rem call :getUniqueFile "d:\test\myFile" ".txt" myFile +@rem echo myFile="%myFile%" +@rem 2025 - wmic deprecated :/ +@rem 2025 - output of 'wmic os get localDateTime' was something like: +@rem LocalDateTime +@rem 20251015234316.777000+660 +@rem !time! has a resolution of centiseconds. As we test in a loop for file existence, that should be ok. +:getUniqueFile baseName extension rtnVar +setlocal enabledelayedexpansion +:getUniqueFileLoop +set "r=!date!_!time!" +set "r=%r::=.%" +set "r=%r: =%" +set "rtn=%~1_!r!%~2" +echo "### %rtn%" +if exist "%rtn%" ( + goto :getUniqueFileLoop +) else ( + 2>nul >nul (9>"%rtn%" timeout /nobreak 1) || goto :getUniqueFileLoop +) +endlocal & set "%~3=%rtn%" +exit /b + +%= ---------------------------------------------------------------------- =% + +@REM padding +:buildcmdline cmdlinevar paramvar wrapA wrapB + %= quoting for cmd.exe /c pwsh -nop !args! =% + @SETLOCAL EnableDelayedExpansion + + @REM @echo ===================== + set "pval=!%~2:*#=!" + set "pval=!pval:~0,-2!" + @REM set "pval=!pval:~0,-1!" + set "wrapa=%~3" + set "wrapb=%~4" + + @call :strlen pval slen + @rem @echo strlen: !slen! + if "!slen!"=="0" ( + goto :eof + ) + @set /A n = !slen! - 1 + @(set str=) + @set "dq="" + @set "bang=^!" + @(set carat=^^) + @for /l %%i in (0,1,!n!) do @( + (set c=!pval:~%%i,1!) + if "!c!"=="|" ( + set "ch=^^!pval:~%%i,1!" + ) ELSE IF "!c!"=="(" ( + set "ch=^(" + ) ELSE if "!c!"==")" ( + set "ch=^)" + ) ELSE if "!c!"=="&" ( + set "ch=^^&" + ) ELSE if "!c!"=="!dq!" ( + set "ch=^"" + ) ELSE if "!c!"=="!bang!" ( + @rem - double caret - first for initial parsing, second to ensure remains escaped during delayed expansion phase + @rem - REVIEW + set "ch=^^!bang!" + ) ELSE if "!c!"=="^carat" ( + set "ch=^^^^" + ) ELSE if "!c!"=="'" ( + set "ch=''" + ) ELSE ( + set "ch=!c!" + ) + @rem @echo - !ch! + set "str=!str!!ch!" + ) + echo +++++ %~1 = !%1! !str! + + set "%~1=!%1! !wrapa!!str!!wrapb!" + + @rem old method of return - failes to preserve exclamation marks + @rem for /f "delims=" %%A in (""!str!"") do endlocal & set "%~1=!%1! '%%~A'" + @rem macro method of endlocal return - preserving !val! + @echo off + %endlocal% %~1 + + @exit /b + +:rem_output + @rem --------------------------------------------- + @rem rem_output is called for each n in the number of args we process - as we don't have a non-destructive way to count args whilst accepting special chars + @rem we therefore need a way to stop processing on the last received arg so we don't write argCount entries to param.txt if less are received + @rem see 'disappearing quotes' technique + @rem https://stackoverflow.com/questions/4643376/how-to-split-double-quoted-line-into-multiple-lines-in-windows-batch-file/4645113#4645113 + @rem and + @rem https://groups.google.com/g/alt.msdos.batch.nt/c/J71F17Bve9Y (sponge belly) + @echo off + setlocal enableextensions disabledelayedexpansion + set "param1=%~2" + rem do must not be indented + for %%^" in ("") ^ +do if not defined param1 set %%~"param1=%2%%~" + if not defined param1 goto :eof + endlocal + @rem --------------------------------------------- + + @PROMPT @ + @echo on + rem %1 #%2# +@exit /b + +@rem padding +@REM courtesy of: https://stackoverflow.com/users/463115/jeb +:strlen stringVar returnVar +@( + setlocal EnableDelayedExpansion + @SET "rtrn=%~2" + (set^ tmp=!%~1!) + @rem @echo jjjjj !tmp! + @if defined tmp ( + set "len=1" + @for %%P in (4096 2048 1024 512 256 128 64 32 16 8 4 2 1) do @( + @if "!tmp:~%%P,1!" NEQ "" ( + set /a "len+=%%P" + set "tmp=!tmp:~%%P!" + ) + ) + ) ELSE ( + set len=0 + ) +) +@( + endlocal + @IF "%~2" neq "" ( + @SET "%rtrn%=%len%" + ) ELSE ( + @ECHO :strlen result: %len% + ) + exit /b +) + + +:getWslPath +@SETLOCAL + @SET "_path=%~p1" + @SET "name=%~nx1" + @SET "drive=%~d1" + @SET "rtrn=%~2" + @REM Although drive letters on windows are normally upper case wslbash seems to expect lower case drive letters + @CALL :stringToLower %drive ldrive + @SET "result=/mnt/%ldrive:~0,1%%_path:\=/%%name%" +@ENDLOCAL & ( + @if "%~2" neq "" ( + SET "%rtrn%=%result%" + ) ELSE ( + ECHO %result% + ) +) +@EXIT /B + +@REM padding +@REM padding +:getFileTail +@REM return tail of file without any normalization e.g c:/punkshell/bin/Punk.cmd returns Punk.cmd even if file is punk.cmd +@REM we can't use things such as %~nx1 as it can change capitalisation +@REM This function is designed explicitly to preserve capitalisation +@REM accepts full paths with either / or \ as delimiters - or +@SETLOCAL + @SET "rtrn=%~2" + @SET "arg=%~1" + @REM @SET "result=%_arg:*/=%" + @REM @SET "result=%~1" + @SET LF=^ + + + : The above 2 empty lines are important. Don't remove + @CALL :stringContains "!arg!" "\" hasBackSlash + @IF "!hasBackslash!"=="true" ( + @for %%A in ("!LF!") do @( + @FOR /F %%B in ("!arg:\=%%~A!") do @set "result=%%B" + ) + ) ELSE ( + @CALL :stringContains "!arg!" "/" hasForwardSlash + @IF "!hasForwardSlash!"=="true" ( + @FOR %%A in ("!LF!") do @( + @FOR /F %%B in ("!arg:/=%%~A!") do @set "result=%%B" + ) + ) ELSE ( + @set "result=%arg%" + ) + ) +@ENDLOCAL & ( + @if "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO %result% + ) +) +@EXIT /B +@REM boundary padding +@REM boundary padding +@REM boundary padding +:getNormalizedScriptTail +@SETLOCAL + @SET "result=%~nx0" + @SET "rtrn=%~1" +@ENDLOCAL & ( + @IF "%~1" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO %result% + ) +) +@EXIT /B + +@REM boundary padding +@REM boundary padding +@REM boundary padding +:getNormalizedFileTailFromPath +@REM warn via echo, and do not set return variable if path not found +@REM note that %~nx1 does not preserve case of provided path - hence the name 'normalized' +@SETLOCAL + @CALL :stringContains %~1 "\" hasBackSlash + @CALL :stringContains %~1 "/" hasForwardSlash + @IF "%hasBackslash%-%hasForwardslash%"=="false-false" ( + @SET "P=%cd%%~1" + @CALL :getNormalizedFileTailFromPath "!P!" ftail2 + @SET "result=!ftail2!" + ) else ( + @IF EXIST "%~1" ( + @SET "result=%~nx1" + ) else ( + @ECHO error getNormalizedFileTailFromPath file not found: %~1 + @EXIT /B 1 + ) + ) + @SET "rtrn=%~2" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + SET "%rtrn%=%result%" + ) ELSE ( + @ECHO getNormalizedFileTailFromPath %1 result: %result% + ) +) +@EXIT /B + +@REM boundary padding +@REM boundary padding +@REM boundary padding + +:stringContains +@REM usage: @CALL:stringContains string needle returnvarname +@SETLOCAL + @SET "rtrn=%~3" + @SET "string=%~1" + @SET "needle=%~2" + @IF "!string:%needle%=!"=="!string!" @( + @SET "result=false" + ) ELSE ( + @SET "result=true" + ) +@ENDLOCAL & ( + @IF "%~3" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO stringContains %string% %needle% result: %result% + ) +) +@EXIT /B +@REM boundary padding +@REM boundary padding +:stringToUpper strvar returnvar +@SETLOCAL + @SET "rtrn=%~2" + @SET "string=%~1" + @SET "capstring=%~1" + @FOR %%A in (A B C D E F G H I J K L M N O P Q R S T U V W X Y Z) DO @( + @SET "capstring=!capstring:%%A=%%A!" + ) + @SET "result=!capstring!" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO stringToUpper %string% result: %result% + ) +) +@EXIT /B +:stringToLower +@SETLOCAL + @SET "rtrn=%~2" + @SET "string=%~1" + @SET "retstring=%~1" + @FOR %%A in (a b c d e f g h i j k l m n o p q r s t u v w x y z) DO @( + @SET "retstring=!retstring:%%A=%%A!" + ) + @SET "result=!retstring!" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO stringToLower %string% result: %result% + ) +) +@EXIT /B +@REM boundary padding +@REM boundary padding +@REM boundary padding +@REM boundary padding +@REM boundary padding +@REM boundary padding +:stringTrimTrailingUnderscores +@SETLOCAL + @SET "rtrn=%~2" + @SET "string=%~1" + @SET "trimstring=%~1" + @REM trim up to 127 underscores from the end of a string using string substitution + @SET "trimstring=%trimstring%###" + @SET "trimstring=%trimstring:________________________________________________________________###=###%" + @SET "trimstring=%trimstring:________________________________###=###%" + @SET "trimstring=%trimstring:________________###=###%" + @SET "trimstring=%trimstring:________###=###%" + @SET "trimstring=%trimstring:____###=###%" + @SET "trimstring=%trimstring:__###=###%" + @SET "trimstring=%trimstring:_###=###%" + @SET "trimstring=%trimstring:###=%" + @SET "result=!trimstring!" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO stringTrimTrailingUnderscores %string% result: %result% + ) +) +@EXIT /B +:isNumeric +@SETLOCAL + @SET "notnumeric="&FOR /F "delims=0123456789" %%i in ("%1") do set "notnumeric=%%i" + @IF defined notnumeric ( + @SET "result=false" + ) else ( + @SET "result=true" + ) + @SET "rtrn=%~2" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO %result% + ) +) +@EXIT /B + +:endlib +: \ +@REM padding +@REM padding +@REM @SET taskexit_code=!errorlevel! & goto :exit_multishell +@GOTO :exit_multishell +# } +# -*- tcl -*- +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +# -- tcl script section +# -- This is a punk multishell file +# -- It is tuned to run (and possibly divert to different payload shell) when called from cmd.exe as a batch file, tclsh,sh,zsh,bash,perl or pwsh/powershell script +# -- i.e it is a polyglot file. +# -- The payload target (by os) is defined in the nextshell block at the top which is constructed when generating the polyglot +# -- using the tcl 'dev scriptwrap.multishell' command in a tcl punk shell +# -- The payload can be tcl,perl,powershell/pwsh or zsh/bash. +# -- The specific layout including some lines that appear just as comments is quite sensitive to change. +# -- It can be called on unix or windows platforms with or without the interpreter being specified on the commandline. +# -- e.g ./scriptname.cmd in sh or zsh or bash +# -- e.g tclsh scriptname.cmd +# -- +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +rename set ""; rename S set; set k {-- "$@" "a}; if {[info exists ::env($k)]} {unset ::env($k)} ;# tidyup and restore +Hide :exit_multishell;Hide {<#};Hide '@ +#--------------------------------------------------------------------- +puts "info script : [info script]" +#puts "argcount : $::argc" +#puts "argvalues: $::argv" +#puts "argv0 : $::argv0" +# -- --- --- --- --- --- --- --- --- --- --- --- +#divert to configured nextshell +set script_as_called [info script] +package require platform +set plat_full [platform::generic] +set plat [lindex [split $plat_full -] 0] +#may be old tcl - not assuming readFile available +set fd [open [info script] r] +set scriptdata [read $fd] +close $fd +set scriptdata [string map [list \r\n \n] $scriptdata] +set in_data 0 +set nextshellpath "" +set nextshelltype "" +puts stderr "PLAT: $plat" +switch -glob -- $plat { + "msys" - "mingw*" { + set os "win32" + } + default { + set os $plat + } +} +foreach ln [split $scriptdata \n] { + if {[string trim $ln] eq ""} {continue} + if {!$in_data} { + if {[string match ": <>*" $ln]} { + set in_data 1 + } + } else { + if {[string match "*@SET*nextshellpath?${os}_*" $ln]} { + set lineparts [split $ln =] + set tail [lindex $lineparts 1] + set nextshellpath [string trimright $tail {_"}] + if {$nextshellpath ne "" && $nextshelltype ne ""} { + break + } + } elseif {[string match "*@SET*nextshelltype?${os}_*" $ln]} { + set lineparts [split $ln =] + set tail [lindex $lineparts 1] + set nextshelltype [string trimright $tail {_"}] + if {$nextshellpath ne "" && $nextshelltype ne ""} { + break + } + } elseif {[string match ": <>*" $ln]} { + break + } + } +} +if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { + set script_rootname [file rootname $script_as_called] + if {$nextshelltype in "pwsh powershell"} { + # experimental + set script_ps1 $script_rootname.ps1 + set arglist $::argv + + if {[file extension $script_as_called] ne ".ps1"} { + #we need to ensure .ps1 is up to date + set needs_updating 0 + if {![file exists $script_ps1]} { + set needs_updating 1 + } else { + #both exist + if {[file size $script_as_called] != [file size $script_ps1]} { + set needs_updating 1 + } else { + #both exist with same size - do full check that they're identical + catch {package require sha256} + if {[package provide sha256] ne ""} { + set h1 [sha2::sha256 -hex -file $script_as_called] + set h2 [sha2::sha256 -hex -file $script_ps1] + if {[string length $h1] != 64 || [string length $h2] != 64} { + set needs_updating 1 + } elseif {$h1 ne $h2} { + set needs_updating 1 + } + } else { + #manually compare - scripts aren't too big, so slurp and string compare is fine + set fd [open $script_as_called] + chan configure $fd -translation binary + set data1 [read $fd] + close $fd + set fd [open $script_ps1] + chan configure $fd -translation binary + set data2 [read $fd] + close $fd + if {![string equal $data1 $data2]} { + set needs_updating 1 + } + } + } + } + + if {$needs_updating} { + file copy -force $script_as_called $script_ps1 + } + } else { + #when called on the .ps1 - we assume it's up to date - review + } + set scrname $script_ps1 + + #set arglist [list] + #foreach a $::argv { + # set a "'$a'" + # lappend arglist $a + #} + } else { + set scrname $script_as_called + set arglist $::argv + } + #todo - handle /usr/bin/env + #todo - exitcode + #review - test spaced quoted words in nextshellpath? + # + #if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { + # set nextshell_words [list $nextshellpath] + #} else { + # set nextshell_words $nextshellpath + #} + + #perform any msys argument munging on a cmd/cmd.exe based nextshellpath before we convert the first word to an auto_exec path + switch -glob -- $plat { + "msys" - "mingw*" { + set cmdword [lindex $nextshellpath 0] + #we only act on cmd or cmd.exe - not a full path such as c:/WINDOWS/system32/cmd.exe + #the nextshellpath should generally be configured as cmd /c ... or cmd.exe ... but specifying it as a path could allow bypassing this un-munging. + #The un-munging only applies to msys/mingw, so such bypassing should be unnecessary - review + #maint: keep this munging in sync with zsh/bash and perl blocks which must also do msys mangling + if {[regexp {^cmd$|^cmd[.]exe$} $cmdword]} { + #need to deal with msys argument munging + puts stderr "cmd call via msys detected. performing translation of /c to //C" + #for now we only deal with /C or /c - todo - other cmd.exe flags? + #In this context we would usually only be using cmd.exe /c to launch older 'desktop' powershell to avoid spaced-argument problems - so we aren't expecting other flags + set new_nextshellpath [list $cmdword] + #for now - just do what zsh munging does - bash regex/string/array processing is tedious and footgunny for the unfamiliar (me), + #so determine the minimum viable case for code there, then port behaviour to perl/tcl msys munging sections. + foreach w [lrange $nextshellpath 1 end] { + if {[regexp {^/[Cc]$} $w]} { + lappend new_nextshellpath {//C} + } else { + lappend new_nextshellpath $w + } + } + set nextshellpath $new_nextshellpath + } + } + } + + set ns_firstword [lindex $nextshellpath 0] + #review - is this test for extra layer of double quoting on first word really necessary? + #if we are treaing $nextshellpath as a tcl list - the first layer of double quotes will already have disappeared + ##if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { + ## set ns_firstword [string range $ns_firstword 1 end-1] + ##} + + if {$::tcl_platform(platform) ne "windows" && [string match {/*/env} $ns_firstword]} { + set exec_part $nextshellpath + } else { + set epath [auto_execok $ns_firstword] + if {$epath eq ""} { + error "unable to find executable path for first word '$ns_firstword' of nextshellpath '$nextshellpath'" + } else { + set exec_part [list {*}$epath {*}[lrange $nextshellpath 1 end]] + } + } + + + puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" + puts stdout "exec: $exec_part $scrname $arglist" + catch {exec {*}$exec_part $scrname {*}$arglist <@stdin >@stdout 2>@stderr} emsg eopts + + if {[dict exists $eopts -errorcode]} { + set ecode [dict get $eopts -errorcode] + if {[lindex $ecode 0] eq "CHILDSTATUS"} { + exit [lindex $ecode 2] + } else { + puts stderr "error calling next shell $nextshelltype :" + puts stderr $emsg + exit 1 + } + } else { + exit 0 + } +} +#--------------------------------------------------------------------- + +namespace eval ::punk::multishell { + set last_script_root [file dirname [file normalize ${::argv0}/__]] + set last_script [file dirname [file normalize [info script]/__]] + if {[info exists ::argv0] && + $last_script eq $last_script_root + } { + set ::punk::multishell::is_main($last_script) 1 ;#run as executable/script - likely desirable to launch application and return an exitcode + } else { + set ::punk::multishell::is_main($last_script) 0 ;#sourced - likely to be being used as a library - no launch, no exit. Can use return. + } + if {"::punk::multishell::is_main" ni [info commands ::punk::multishell::is_main]} { + proc ::punk::multishell::is_main {{script_name {}}} { + if {$script_name eq ""} { + set script_name [file dirname [file normalize [info script]/--]] + } + if {![info exists ::punk::multishell::is_main($script_name)]} { + #e.g a .dll or something else unanticipated + puts stderr "Warning punk::multishell didn't recognize info script result: $script_name - will treat as if sourced and return instead of exiting" + puts stderr "Info: script_root: [file dirname [file normalize ${::argv0}/__]]" + return 0 + } + return [set ::punk::multishell::is_main($script_name)] + } + } +} +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin Tcl Payload + +# +# -*- tcl -*- + +# @@ Meta Begin +# Application dtplite 1.0.5 +# Meta platform tcl +# Meta summary Lightweight DocTools Processor +# Meta description This application is a simple processor +# Meta description for documents written in the doctools +# Meta description markup language. It covers the most +# Meta description common use cases, but is not as +# Meta description configurable as its big brother dtp. +# Meta category Processing doctools documents +# Meta subject doctools doctoc docidx +# Meta require {dtplite 1.0.5} +# Meta author Andreas Kupries +# Meta license BSD +# @@ Meta End + +if {[catch {package require dtplite 1.0.5}]} { + # Fall back to the project-vendored tcllib (pure Tcl) when the invoking + # tclsh has no tcllib installed. Works both unwrapped (src/scriptapps/dtplite.tcl) + # and wrapped (/bin/dtplite.cmd) - each is one level below a folder + # containing (or sibling to) src/vendorlib_tcl9//tcllib + set selfdir [file dirname [file dirname [file normalize [info script]/__]]] + foreach libdir [glob -nocomplain -types d -directory $selfdir\ + ../vendorlib_tcl9/*/tcllib*\ + ../src/vendorlib_tcl9/*/tcllib*] { + set libdir [file normalize $libdir] + if {$libdir ni $::auto_path} { + lappend ::auto_path $libdir + } + } + package require dtplite 1.0.5 +} + +# dtp lite - Lightweight DocTools Processor +# ======== = ============================== + +exit [dtplite::do $argv] + +# ### ### ### ######### ######### ######### +exit + +# + + +# -- --- --- --- --- --- --- --- --- --- --- --- +# -- Best practice is to always return or exit above, or just by leaving the below defaults in place. +# -- If the multishell script is modified to have Tcl below the Tcl Payload section, +# -- then Tcl bracket balancing needs to be carefully managed in the shell and powershell sections below. +# -- Only the # in front of the two relevant if statements below needs to be removed to enable Tcl below +# -- but the sh/bash 'then' and 'fi' would also need to be uncommented. +# -- This facility left in place for experiments on whether configuration payloads etc can be appended +# -- to tail of file - possibly binary with ctrl-z char - but utility is dependent on which other interpreters/shells +# -- can be made to ignore/cope with such data. +if {[::punk::multishell::is_main]} { + exit 0 +} else { + return +} +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---end Tcl Payload +# end hide from unix shells \ +HEREDOC1B_HIDE_FROM_BASH_AND_SH +# Be wary of any non-trivial sed/awk etc - can be brittle to maintain across linux,freebsd,macosx due to differing implementations \ +echo "var0: $0 @: $@" +# use oldschool backticks and sed (posix - lowest common denominator) \ +# ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` \ +# some ps impls will return arguments - so last field not always appropriate \ +# some ps impls don't have -o (e.g cygwin) so ps_shellname may remain empty and emit an error \ +ps_shellname=`ps -o pid,comm -p $$ | awk '$1 != "PID" {print $2}'` +# \ +echo "shell from ps: $ps_shellname" +# \ +echo "args: $@" +# ------------------------------------------------------------------------------ +# -- This if block wraps posix sh diversion section - only needed if Tcl didn't exit or return above. +if false==false # else { + then + : # + +# https://gist.github.com/fcard/e26c5a1f7c8b0674c17c7554fb0cd35c0 (MIT lic) +# https://stackoverflow.com/questions/63864755/remove-last-argument-in-shell-script-posix +# posix compliant pop +pop() { + __pop_n=$(($1 - ${2:-1})) + if [ -n "$ZSH_VERSION" -o -n "$BASH_VERSION" ]; then + POP_EXPR='set -- "${@:1:'$__pop_n'}"' + elif [ $__pop_n -ge 500 ]; then + POP_EXPR="set -- $(seq -s " " 1 $__pop_n | sed 's/[0-9]\+/"${\0}"/g')" + else + __pop_index=0 + __pop_arguments="" + while [ $__pop_index -lt $__pop_n ]; do + __pop_index=$((__pop_index+1)) + __pop_arguments="$__pop_arguments \"\${$__pop_index}\"" + done + POP_EXPR="set -- $__pop_arguments" + fi +} +# ------------------------------------------------------------------------------ + +# non-bash-like posix diversion +# we don't use $BASH_VERSION/$ZSH_VERSION as these can still be set when for example +# sh is a symlink to bash (posix-mode bash - reduced bashism capabilities?) +# if our ps_shellname didn't contain a result, don't divert and risk looping +if [ -n "$ps_shellname" ] && [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ] ; then + shift + pop $# + eval "$POP_EXPR" + echo "divert to bash $0 $@" + + /usr/bin/env bash "$0" "$@" + exit $? +fi +# close false==false block +fi +# close tcl wrap } +# ------------------------------------------------------ +# -- This if block wraps whole zsh/bash and perl sections - only needed if Tcl didn't exit or return above. +if false==false # else { + then + : # + +# zsh/bash \ +shift && set -- "${@:1:$((${#@}-1))}" +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +# -- zsh/bash script section +# -- +# -- review - for zsh do we want to use: setopt KSH_ARRAYS ? +# -- arrays in bash 0-based vs 1-based in zsh +# -- stick to the @:i:len syntax which is same for both +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### + +plat=$(uname -s) #platform/system +if [[ "$plat" == "Linux"* ]]; then + os="linux" +elif [[ "$plat" == "Darwin"* ]]; then + os="macosx" +elif [[ "$plat" == "FreeBSD"* ]]; then + os="freebsd" +elif [[ "$plat" == "DragonFly"* ]]; then + os="dragonflybsd" +elif [[ "$plat" == "NetBSD"* ]]; then + os="netbsd" +elif [[ "$plat" == "OpenBSD"* ]]; then + os="openbsd" +elif [[ "$plat" == "MINGW32"* ]]; then + os="win32" +elif [[ "$plat" == "MINGW64"* ]]; then + os="win32" +elif [[ "$plat" == "CYGWIN_NT"* ]]; then + os="win32" +elif [[ "$plat" == "MSYS_NT"* ]]; then + #review.. + + #Need to consider the difference between when msys2 was launched (which strips some paths and sets up the environment) + # vs if the msys2 sh was called - (actually bash) in which case paths will be different + + #wsl and cygwin or msys2 can commonly be problematic combinations - primarily due to path issues + #e.g "c:/windows/system32/" is quite likely in the path ahead of msys,git etc. + #e.g It means a /usr/bin/env bash call may launch the (linux elf) bash for wsl rather than the msys bash + # + + #msys provides win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' + #bash reports $OSTYPE msys + + #there are statements around the web that cmd /c .. will work under msys2 + # - but from experience, it can be required to use cmd //c ... + # or MSYS2_ARG_CONV_ECL='*' cmd /c .. + # This seems to be because process arguments that look like unix paths are converted to windows paths :/ + #review! + + os="win32" + #review - need ps/sed/awk to determine shell? + interp=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` + #use 'command -v' (shell builtin preferred over external which) + shellpath=`command -v $interp` + shellfolder="${shellpath%/*}" #avoid dependency on basename or dirname + export PATH="$shellfolder${PATH:+:${PATH}}" +elif [[ "$OSTYPE" == "win32" ]]; then + os="win32" +else + #os="$OSTYPE" + os="other" +fi +echo ostype: $OSTYPE +## This is the sort of sed that will not work across implementations +## shellconfiglines=$( sed -n "/: <>/{:a;n;/: <>/q;p;ba}" "$0" | grep $os) +#awk tested on linux & freebsd +shellconfiglines=$( awk '/^:.*<>.*$/,/^:.*<>.*$/' "$0" | grep $os) +# echo $shellconfiglines; +# readarray requires bash 4.0 +if [[ "$ps_shellname" == "bash" ]]; then + readarray -t arr_oslines <<<"$shellconfiglines" +elif [[ "$ps_shellname" == "zsh" ]]; then + arr_oslines=("${(f)shellconfiglines}") +else + #fallback - doesn't seem to work in zsh - untested in early bash + IFS=$'\n' arr_oslines=($shellconfiglines) + IFS=$' \t\n' + # review +fi +nextshellpath="" +nextshelltype="" +for ln in "${arr_oslines[@]}"; do + # echo "---- $ln" + if [[ "$ln" == *"nextshellpath"* ]]; then + splitln="${ln#*=}" #remove everything through the first '=' + pathraw="${splitln%%\"*}" #take everything before the quote - use %% to get longest match + #remove trailing underscores (% means must match at end) + nextshellpath="${pathraw/%_*/}" + # echo "nextshellpath: $nextshellpath" + elif [[ "$ln" == *"nextshelltype"* ]]; then + splitln="${ln#*=}" + typeraw="${splitln%%\"*}" + nextshelltype="${typeraw/%_*/}" + # echo "nextshelltype: $nextshelltype" + fi +done + +exitcode=0 +#-- sh/bash launches nextscript here instead of shebang line at top +if [[ "$nextshelltype" != "bash" && "$nextshelltype" != "none" ]]; then + echo zsh/bash launching subshell of type: $nextshelltype shellpath: $nextshellpath on "$0" with args "$@" + + script="$0" + if [[ "$nextshelltype" == "pwsh" || "$nextshelltype" == "powershell" ]]; then + #powershell requires the file extension to be .ps1 (always - on windows) + #on other platforms it's not required if a shebang line is used - but this script must be shebangless for portability and to maintain polyglot capabilities. + cmdpattern="[.]cmd$" + if [[ "$script" =~ $cmdpattern ]]; then + ps1script="${script%????}.ps1" + if ! cmp -s "$script" "$ps1script" ; then + #ps1script either different or missing + #on windows - batch script copies .cmd -> .ps1 if not identical + cp -f "$script" "$ps1script" + fi + script=$ps1script + fi + fi + if [[ "$plat" == "MSYS_NT"* ]]; then + + #we need to deal with MSYS argument munging + cmdpattern="^cmd.exe |^cmd " + #do not double quote cmdpattern - or it will be treated as literal string + if [[ "$nextshellpath" =~ $cmdpattern ]]; then + #for now - tell the user what's going on + echo "cmd call via msys detected. performing translation of /c to //c and escaping backslashes in script path" >&2 + #flags to cmd.exe such as /c are interpreted by msys as looking like a unix path + #review - for nextshellpath targets specified in the block for win32 - we don't expect unix paths (?) + #what about other flags? - can we just double up all forward slashes? + #maint: keep this munging in sync with the tcl block and perl block which must also do msys munging + nextshellpath="${nextshellpath// \/[cC] / \/\/c }" + # echo "new nextshellpath: ${nextshellpath}" + #review - + #don't double quote this + script=${script//\\/\\\\} + fi + echo "calling ${nextshellpath} $script $@" + + #load into array + cmd_array=($nextshellpath) + cmd_array+=("$script") #add script, which may contain spaces as a single entry ? + cmd_array+=( "$@" ) #add each element of args to array as a separate entry (equiv ? "${arr[@]}") + # printf "%s\n" "${cmd_array[@]}" + "${cmd_array[@]}" + + # this works to make nextshellpath run - but joins $@ members incorrectly + #eval ${nextshellpath} "$script" "$@" + else + #e.g /usr/bin/env tclsh "$0" "$@" + ${nextshellpath} "$script" "$@" + fi + + + exitcode=$? + #echo "zsh/bash reporting exitcode: ${exitcode}" + exit $exitcode + #-- override exitcode example + #exit 66 +else + #already in bash - don't launch another process or we would loop + #echo "zsh/bash payload" + : +fi +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin zsh Payload +#printf "start of bash or zsh code" + +# +echo "No bash code for this script. Try another program such as perl or tcl" >&2 +# + + +#printf "zsh/bash done \n" +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---end zsh Payload +#------------------------------------------------------ +fi +exit ${exitcode} +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +# -- Perl script section +# -- leave the script below as is, if all that is required is launching the Tcl payload" +# -- +# -- Note that perl script isn't called by default when simply running this script by name +# -- adjust the nextshell value at the top of the script to point to perl +# -- +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +=cut +#!/user/bin/perl +my $exit_code = 0; +use Cwd qw(abs_path); +my $scriptname = abs_path($0); +#print "perl $scriptname\n"; +my $os = "$^O"; +if ($os eq "MSWin32") { + $os = "win32"; +} elsif ($os eq "darwin") { + $os = "macosx"; +} +print "os $os\n"; +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin perl Payload +#use ExtUtils::Installed; +#my $installed = ExtUtils::Installed->new(); +#my @modules = $installed->modules(); +#print "Modules:\n"; +#foreach my $m (@modules) { +# print "$m\n"; +#} +# -- --- --- + + +my $i =1; +foreach my $a(@ARGV) { + print "Arg # $i: $a\n"; +} + +# +print STDERR "No perl code for this script. Try another program such as tcl or bash"; +# + +# -- --- --- --- --- --- --- --- +#$exit_code=system("tclsh", $scriptname, @ARGV); +#print "perl reporting tcl exitcode: $exit_code"; +# -- --- --- --- --- --- --- --- + + +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---end perl Payload +exit $exit_code; +__END__ + +# end hide sh/bash/perl block from Tcl +# This comment with closing brace should stay in place whether if commented or not } +#------------------------------------------------------ +# begin hide powershell-block from Tcl - only needed if Tcl didn't exit or return above +if 0 { +: end heredoc1 - end hide from powershell \ +'@ +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +# -- powershell/pwsh section +# -- Do not edit if current file is the .ps1 +# -- Edit the corresponding .cmd and it will autocopy +# -- unbalanced braces { } here *even in comments* will cause problems if there was no Tcl exit or return above +# -- custom script should generally go below the begin_powershell_payload line +# ## ### ### ### ### ### ### ### ### ### ### ### ### ### +#$MyInvocation.ScriptName should probably be considered deprecated +# https://stackoverflow.com/questions/78511229/how-can-i-choose-between-myinvocation-scriptname-and-myinvocation-pscommandpat +$runningscriptname = $PSCommandPath +if (-not $MyInvocation.PSCommandPath) { + $callingscriptname = '' +} else { + $callingscriptname = $MyInvocation.PSCommandPath +} +#The problem with psmodulepath +#https://github.com/PowerShell/PowerShell/issues/18108 +# psmodulepath is shared by powershell and pwsh despite not all ps modules being compatible. +# It is futzed with by powershell/pwsh based on detecting the child process type. +# a psmodulepath that has been futzed with by pwsh will not work for a child powershell 5 process that isn't launched directly +#This is inherently unfriendly to situations where an intervening process may be something else such as cmd.exe,tcl,perl etc +# nevertheless, powershell/pwsh maintainers seem to have taken the MS-centric view of the world that such situations don't exist :/ +# +#symptoms of these shenannigans not working include things like Get-FileHash failing in powershell desktop +# +#We don't know if the original console was pwsh/powershell or cmd.exe, and we need to potentially divert to powershell 5 (desktop) +#via tcl or perl etc - or cmd.exe +if ($PSVersionTable.PSVersion.Major -le 5) { + # For Windows PowerShell, we want to remove any PowerShell 7 paths from PSModulePath + #snipped from https://github.com/PowerShell/DSC/pull/777/commits/af9b99a4d38e0cf1e54c4bbd89cbb6a8a8598c4e + #Presumably users are supposed to know not to have custom paths for powershell desktop containing a 'powershell' subfolder?? + $env:PSModulePath = ($env:PSModulePath -split ';' | Where-Object { $_ -notlike '*\powershell\*' }) -join ';' +} + +function GetDynamicParamDictionary { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline=$true, Mandatory=$true)] + [string] $CommandName + ) + + begin { + # Get a list of params that should be ignored (they're common to all advanced functions) + $CommonParameterNames = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([type] [System.Management.Automation.Internal.CommonParameters]) | + Get-Member -MemberType Properties | + Select-Object -ExpandProperty Name + } + + process { + # Create the dictionary that this scriptblock will return: + $DynParamDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary + + # Convert to object array and get rid of Common params: + (Get-Command $CommandName | select -exp Parameters).GetEnumerator() | + Where-Object { $CommonParameterNames -notcontains $_.Key } | + ForEach-Object { + $DynamicParameter = New-Object System.Management.Automation.RuntimeDefinedParameter ( + $_.Key, + $_.Value.ParameterType, + $_.Value.Attributes + ) + $DynParamDictionary.Add($_.Key, $DynamicParameter) + } + + # Return the dynamic parameters + return $DynParamDictionary + } +} +# Example usage: +# GetDynamicParamDictionary +# - This can make it easier to share a single set of param definitions between functions +# - sample usage +#function ParameterDefinitions { +# param( +# [Parameter(Mandatory)][string] $myargument, +# [Parameter(ValueFromRemainingArguments)] $opts +# ) +#} +#function psmain { +# [CmdletBinding()] +# param() +# dynamicparam { GetDynamicParamDictionary ParameterDefinitions } +# process { +# #called once with $PSBoundParameters dictionary +# #can be used to validate arguments, or set a simpler variable name for access +# switch ($PSBoundParameters.keys) { +# 'myargument' { +# Set-Variable -Name $_ -Value $PSBoundParameters."$_" +# } +# 'opts' { +# write-warning "Unused parameters: $($PSBoundParameters.$_)" +# } +# Default { +# write-warning "Unhandled parameter -> [$($_)]" +# } +# } +# foreach ($boundparam in $PSBoundParameters.GetEnumerator()) { +# #... +# } +# } +# end { +# #Main function logic +# Write-Host "myargument value is: $myargument" +# #myotherfunction @PSBoundParameters +# } +#} +#psmain @args +#"Timestamp : {0,10:yyyy-MM-dd HH:mm:ss}" -f $(Get-Date) | write-host +#"Running Script Name : {0}" -f $runningscriptname | write-host +"Powershell Version: {0}" -f $PSVersionTable.PSVersion.Major | write-host +#"powershell args : {0}" -f ($args -join ", ") | write-host +# -- --- --- --- +$thisfileContent = Get-Content $runningscriptname -Raw +$startTag = ": <>" +$endTag = ": <>" +$pattern = "(?s)`n$startTag[^`n]*`n(.*?)`n$endTag" +$match = [regex]::Match($thisfileContent,$pattern) +$asadmin = 0 +if ($match.Success) { + $admininfo = $match.Groups[1].Value + $asadmin = $admininfo.Contains("asadmin=1") + if ($asadmin) { + if ($args[0] -eq "PUNK-ELEVATED") { + # May be present if launch and elevation was done via cmd.exe script + # shift away first arg + $newargs = $args | Select-Object -Skip 1 + } else { + $newargs = $args + } + # -Wait e.g for starting a service or other operations which remainder of script may depend on + $arguments = @("-NoProfile","-NoLogo", "-NoExit", "-ExecutionPolicy", "Bypass") + $arguments += @("-File", $($MyInvocation.MyCommand.Path)) + foreach ($a in $newargs) { + if ($a -match '\s') { + $arguments += "`"$a`"" + } else { + $arguments += $a + } + } + if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + # If not elevated, relaunch with elevated privileges + Write-Host "Powershell elevating using start-process with -Verb RunAs" + if ($PSVersionTable.PSEdition -eq 'Core') { + Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -Wait -Verb RunAs + } else { + Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Wait -Verb RunAs + } + Exit # Exit the current non-elevated process + } else { + if ($args[0] -eq "PUNK-ELEVATED") { + #Already elevated (by cmd.exe) + #.. but it is impossible to modify or reassign the automatic $args variable + # so let's start yet another whole new process just to remove one leading argument so the custom script can operate on parameters cleanly - thanks powershell :/ + if ($PSVersionTable.PSEdition -eq 'Core') { + Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -NoNewWindow -Wait + } else { + Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -NoNewWindow -Wait + } + Exit + } + } + } +} +# +$startTag = ": <>" +$endTag = ": <>" +$pattern = "(?s)`n$startTag[^`n]*`n(.*?)`n$endTag" +$match = [regex]::Match($thisfileContent,$pattern) +if ($match.Success) { + $plat = [System.Environment]::OSVersion.Platform + if ($plat -eq "Unix") { + $runtime_ident = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier + switch ($runtime_ident.split("-")[0]) { + "freebsd" { + # untested + $os = "freebsd" + } + "linux" { + $os = "linux" + } + "osx" { + # osx-x64 or osx-arm64 ? + $os = "macosx" + } + default { + #openbsd, netbsd ? + $os = "other" + } + } + } else { + $os = "win32" + } + + $matchedlines = $match.Groups[1].Value + $nextshell_type = "" + $nextshell_path = "" + ForEach ($line in $($matchedlines -split "\r?\n")) { + $m = [regex]::Match($line,".*nextshelltype\[${os}[_]+\]=([^_]*)[_]*") + if ($m.Success) { + $nextshell_type = $m.Groups[1].Value + } + $m = [regex]::Match($line,".*nextshellpath\[${os}[_]+\]=([^_]*)[_]*") + if ($m.Success) { + $nextshell_path = $m.Groups[1].Value + } + if ($nextshell_type -ne "" -and $nextshell_path -ne "") { + break + } + } + if (-not (("pwsh", "powershell", "") -contains $nextshell_type)) { + #nextshell diversion exists for this platform + write-host "os: $os pwsh/powershell launching subshell of type: $nextshell_type shellpath: $nextshell_path on script $runningscriptname" + + # $arguments = @($($MyInvocation.MyCommand.Path)) + # $arguments += $args + # NOTE - this gives incorrect argument quoting e.g wrong number of arguments received by launched process for arguments: a "b c" + # $process = (Start-Process -FilePath $nextshell_path -ArgumentList $arguments -NoNewWindow -Wait) + # Exit $process.ExitCode + + & $nextshell_path $runningscriptname $args + exit $LASTEXITCODE + } +} + +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin powershell Payload + +# +Write-Error "No powershell code for this script. Try another program such as tcl or bash`n" +"powershell args : {0}" -f ($args -join ", ") | write-host +# + + +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---end powershell Payload +Exit $LASTEXITCODE +# heredoc2 for powershell to ignore block below +$1 = @' +' +: comment end hide powershell-block from Tcl \ +# This comment with closing brace should stay in place whether 'if' commented or not } +: multishell doubled-up cmd exit label - return exitcode +:exit_multishell +:exit_multishell +: \ +@REM @ECHO exitcode: !task_exitcode! +: \ +@IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" (echo. & @cmd /k echo elevated prompt: type exit to quit) +: \ +@EXIT /B !task_exitcode! +# cmd has exited +: comment end heredoc2 \ +'@ +<# +# id:tailblock0 +# -- powershell multiline comment +#> +<# +no script engine should try to run me +# id:tailblock1 +# + +# +# -- unreachable by tcl directly if ctrl-z character is in the section above. (but file can be read and split on \x1A) +# -- Potential for zip and/or base64 contents, but we can't stop pwsh parser from slurping in the data +# -- so for example a plain text tar archive could cause problems depending on the content. +# -- final line in file must be the powershell multiline comment terminator or other data it can handle. +# -- e.g plain # comment lines will work too +# -- (for example a powershell digital signature is a # commented block of data at the end of the file) +#> + + + + diff --git a/punkproject.toml b/punkproject.toml index e666b2da..23b8ac24 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,4 +1,4 @@ [project] name = "punkshell" -version = "0.12.22" +version = "0.12.23" license = "BSD-2-Clause" diff --git a/src/scriptapps/AGENTS.md b/src/scriptapps/AGENTS.md index 45440379..a4a8c0fa 100644 --- a/src/scriptapps/AGENTS.md +++ b/src/scriptapps/AGENTS.md @@ -13,7 +13,7 @@ Standalone Tcl scripts that serve as entry points for Punk applications and util - Scripts here are invoked directly by the user or by build tools, not loaded as packages. - Wrapper configs (`.toml` files) define how scripts are packaged as platform executables. -- `_wrap.toml` scriptsets are the SOURCE of the generated polyglot `.cmd` scripts in `/bin`, and bin-deployed scriptsets live in the `bin/` subfolder here (e.g `bin/runtime.ps1` + `bin/runtime.bash` + `bin/runtime_wrap.toml` -> `/bin/runtime.cmd` via `punk::mix::commandset::scriptwrap::multishell runtime -askme 0`, run from `src/scriptapps/bin`, alongside the `getzig.*` scriptset). Fixes to a `bin/*.cmd` polyglot are made in the scriptset sources and re-wrapped - never in the output file. Regenerate and commit the bin output together with payload changes: `src/tests/modules/punk/mix/testsuites/scriptwrap/multishell.test` pins the runtime scriptset round-trip byte-identical against `bin/runtime.cmd`. +- `_wrap.toml` scriptsets are the SOURCE of the generated polyglot `.cmd` scripts in `/bin`, and bin-deployed scriptsets live in the `bin/` subfolder here (e.g `bin/runtime.ps1` + `bin/runtime.bash` + `bin/runtime_wrap.toml` -> `/bin/runtime.cmd` via `punk::mix::commandset::scriptwrap::multishell runtime -askme 0`, run from `src/scriptapps/bin`, alongside the `getzig.*` scriptset). Fixes to a `bin/*.cmd` polyglot are made in the scriptset sources and re-wrapped - never in the output file. Regenerate and commit the bin output together with payload changes: `src/tests/modules/punk/mix/testsuites/scriptwrap/multishell.test` pins the runtime scriptset round-trip byte-identical against `bin/runtime.cmd`, and `src/tests/shell/testsuites/binscripts/dtplite.test` pins the dtplite scriptset (`dtplite.tcl` + `dtplite_wrap.toml`, wrapped from this folder) against `bin/dtplite.cmd` the same way. Payload scripts embedded in polyglots must be LF-only - the wrap embeds payload bytes verbatim and the multishell output contract is LF-only. - The `spud/` directory holds the spud build tool's app scripts. - The `tools/` directory holds miscellaneous build and deployment utilities. diff --git a/src/scriptapps/dtplite.tcl b/src/scriptapps/dtplite.tcl index d110eff5..2a69a181 100644 --- a/src/scriptapps/dtplite.tcl +++ b/src/scriptapps/dtplite.tcl @@ -1,27 +1,42 @@ -# -*- tcl -*- - -# @@ Meta Begin -# Application dtplite 1.0.5 -# Meta platform tcl -# Meta summary Lightweight DocTools Processor -# Meta description This application is a simple processor -# Meta description for documents written in the doctools -# Meta description markup language. It covers the most -# Meta description common use cases, but is not as -# Meta description configurable as its big brother dtp. -# Meta category Processing doctools documents -# Meta subject doctools doctoc docidx -# Meta require {dtplite 1.0.5} -# Meta author Andreas Kupries -# Meta license BSD -# @@ Meta End - -package require dtplite 1.0.5 - -# dtp lite - Lightweight DocTools Processor -# ======== = ============================== - -exit [dtplite::do $argv] - -# ### ### ### ######### ######### ######### -exit +# -*- tcl -*- + +# @@ Meta Begin +# Application dtplite 1.0.5 +# Meta platform tcl +# Meta summary Lightweight DocTools Processor +# Meta description This application is a simple processor +# Meta description for documents written in the doctools +# Meta description markup language. It covers the most +# Meta description common use cases, but is not as +# Meta description configurable as its big brother dtp. +# Meta category Processing doctools documents +# Meta subject doctools doctoc docidx +# Meta require {dtplite 1.0.5} +# Meta author Andreas Kupries +# Meta license BSD +# @@ Meta End + +if {[catch {package require dtplite 1.0.5}]} { + # Fall back to the project-vendored tcllib (pure Tcl) when the invoking + # tclsh has no tcllib installed. Works both unwrapped (src/scriptapps/dtplite.tcl) + # and wrapped (/bin/dtplite.cmd) - each is one level below a folder + # containing (or sibling to) src/vendorlib_tcl9//tcllib + set selfdir [file dirname [file dirname [file normalize [info script]/__]]] + foreach libdir [glob -nocomplain -types d -directory $selfdir\ + ../vendorlib_tcl9/*/tcllib*\ + ../src/vendorlib_tcl9/*/tcllib*] { + set libdir [file normalize $libdir] + if {$libdir ni $::auto_path} { + lappend ::auto_path $libdir + } + } + package require dtplite 1.0.5 +} + +# dtp lite - Lightweight DocTools Processor +# ======== = ============================== + +exit [dtplite::do $argv] + +# ### ### ### ######### ######### ######### +exit diff --git a/src/scriptapps/dtplite_wrap.toml b/src/scriptapps/dtplite_wrap.toml new file mode 100644 index 00000000..6bd15bed --- /dev/null +++ b/src/scriptapps/dtplite_wrap.toml @@ -0,0 +1,15 @@ +[application] + template="punk.multishell.cmd" + as_admin=false + + scripts=[ + "dtplite.tcl", + ] + + default_outputfile="dtplite.cmd" + default_nextshellpath="/usr/bin/env tclsh" + default_nextshelltype="tcl" + + win32.nextshellpath="tclsh" + win32.nextshelltype="tcl" + win32.outputfile="dtplite.cmd" diff --git a/src/tests/shell/testsuites/binscripts/dtplite.test b/src/tests/shell/testsuites/binscripts/dtplite.test new file mode 100644 index 00000000..b719c22e --- /dev/null +++ b/src/tests/shell/testsuites/binscripts/dtplite.test @@ -0,0 +1,231 @@ +package require tcltest + +#Behaviour + artifact tests for bin/dtplite.cmd - the scriptwrap-multishell wrapped +#tcllib dtplite application (payload: src/scriptapps/dtplite.tcl + dtplite_wrap.toml). +# +#Context: 'dev doc.validate' (punk::mix::commandset::doc::validate) invokes a bare +#`dtplite validate ` which the punk repl unknown-handler resolves via auto_execok, +#so a dtplite executable must be findable on PATH. bin/dtplite.cmd provides that +#cross-platform (cmd.exe on windows dispatching to tclsh; sh/tclsh on unix). +# +#Covered: +# - artifact contract: bin/dtplite.cmd is LF-only and passes scriptwrap checkfile +# (no 512-byte label location errors) +# - source sync: re-wrapping the dtplite scriptset from src/scriptapps reproduces the +# committed bin/dtplite.cmd byte for byte (fix payloads and re-wrap - never the output) +# - execution usecases (windows via cmd.exe; unix via sh - both need a tclsh with the +# dtplite package or the project-vendored tcllib fallback): +# * no args -> usage error, nonzero exit +# * validate -> exit 0 +# * validate -> nonzero exit + doctools error on stderr +# * validate -> exit 0 (the 'dev doc.validate' usecase) +# * html generation to an output file +# +#NOTE for agents: bin/dtplite.cmd is GENERATED - edit src/scriptapps/dtplite.tcl / +#dtplite_wrap.toml and re-wrap with punk::mix::commandset::scriptwrap::multishell +#(see src/scriptapps/AGENTS.md). + +namespace eval ::testspace { + namespace import ::tcltest::* + variable common { + set result "" + } + + variable testdir [file dirname [file normalize [info script]]] + #/src/tests/shell/testsuites/binscripts -> src/tests is 3 levels up + variable testsroot [file normalize [file join $testdir .. .. ..]] + variable projectroot [file normalize [file join $testsroot .. ..]] + + variable target [file join $projectroot bin dtplite.cmd] + testConstraint havedtplitecmd [file exists $target] + testConstraint iswindows [expr {$::tcl_platform(platform) eq "windows"}] + testConstraint isunix [expr {$::tcl_platform(platform) eq "unix"}] + testConstraint havescriptwrap [expr {![catch { + package require Thread ;#punk::fileline textinfo (used by checkfile) calls thread::id + package require punk::mix::commandset::scriptwrap + }]}] + + proc readbytes {path} { + set fd [open $path r] + fconfigure $fd -translation binary + set data [read $fd] + close $fd + return $data + } + + #write with explicit lf translation + proc writefile_lf {path content} { + set fd [open $path w] + fconfigure $fd -translation lf + puts $fd $content + close $fd + } + + #run bin/dtplite.cmd with args; returns dict {exit output } + #on windows via cmd.exe (the way the repl unknown-handler launches it), on unix via sh + proc run_dtplite {args} { + variable target + set output "" + set code 0 + if {$::tcl_platform(platform) eq "windows"} { + set comspec [expr {[info exists ::env(ComSpec)] ? $::env(ComSpec) : "cmd.exe"}] + set cmdline [list $comspec /c [file nativename $target] {*}$args] + } else { + set cmdline [list sh $target {*}$args] + } + if {[catch {exec -- {*}$cmdline << "" 2>@1} output opts]} { + set ec [dict get $opts -errorcode] + if {[lindex $ec 0] eq "CHILDSTATUS"} { + set code [lindex $ec 2] + } else { + set code -1 + } + } + return [dict create exit $code output $output] + } + + #--- fixtures: minimal good/bad doctools manpages + a small doc tree ------------- + variable good_man {[manpage_begin sample_good n 1.0] +[copyright {2026}] +[moddesc {Sample}] +[titledesc {A minimal valid doctools manpage}] +[description] +[para] This is a minimal valid doctools document used for smoke testing dtplite. +[section Usage] +[para] Nothing to see here. +[manpage_end]} + variable bad_man {[manpage_begin sample_bad n 1.0] +[copyright {2026}] +[moddesc {Sample}] +[titledesc {An invalid doctools manpage - manpage_end missing}] +[description] +[list_begin itemized] +[item] an item, but the list is never closed and manpage_end is missing} + + variable fixdir [makeDirectory dtplite_fixture] + writefile_lf $fixdir/sample_good.man $good_man + writefile_lf $fixdir/sample_bad.man $bad_man + variable treedir [makeDirectory dtplite_fixture_tree] + file mkdir $treedir/sub + writefile_lf $treedir/one.man [string map {sample_good tree_one} $good_man] + writefile_lf $treedir/sub/two.man [string map {sample_good tree_two} $good_man] + + #--- artifact contract ------------------------------------------------------------ + + test dtplite_cmd_lf_only {the committed bin/dtplite.cmd uses LF line endings exclusively (cmd label scanning depends on it)}\ + -constraints havedtplitecmd\ + -setup $common -body { + variable target + set data [readbytes $target] + lappend result [expr {[string first \r $data] == -1}] + }\ + -cleanup { + }\ + -result [list 1] + + test dtplite_cmd_checkfile_no_label_errors {the committed bin/dtplite.cmd passes scriptwrap checkfile - no 512-byte label location errors}\ + -constraints {havedtplitecmd havescriptwrap}\ + -setup $common -body { + variable target + set summary [punk::mix::commandset::scriptwrap::checkfile $target] + lappend result [string match "*ERROR: label location errors*" $summary] + #sanity: the analysis actually ran (labels enumerated) + lappend result [string match "*call-labels-found:*" $summary] + }\ + -cleanup { + }\ + -result [list 0 1] + + test dtplite_cmd_roundtrip_no_drift {re-wrapping the dtplite scriptset from src/scriptapps reproduces the committed bin/dtplite.cmd byte for byte}\ + -constraints {havedtplitecmd havescriptwrap}\ + -setup $common -body { + variable projectroot + variable target + set scriptapps [file join $projectroot src scriptapps] + if {![file exists $scriptapps/dtplite_wrap.toml]} { + lappend result no_dtplite_sources + } else { + set outdir [makeDirectory dtplite_roundtrip] + set startdir [pwd] + cd $scriptapps + set res [punk::mix::commandset::scriptwrap::multishell dtplite -outputfolder $outdir -askme 0 -force 1] + cd $startdir + set fresh [dict get $res filename] + lappend result [expr {[readbytes $fresh] eq [readbytes $target]}] + #a failure here means either bin/dtplite.cmd was edited directly (fix + #src/scriptapps/dtplite.tcl or dtplite_wrap.toml instead and re-wrap), + #or the payload/template changed without regenerating bin/dtplite.cmd + } + }\ + -cleanup { + }\ + -result [list 1] + + #--- execution usecases ----------------------------------------------------------- + + test dtplite_exec_no_args_usage_error {running dtplite.cmd without arguments exits nonzero with the dtplite usage message}\ + -constraints havedtplitecmd\ + -setup $common -body { + set r [run_dtplite] + lappend result [expr {[dict get $r exit] != 0}] + lappend result [string match "*wrong#args*" [dict get $r output]] + }\ + -cleanup { + }\ + -result [list 1 1] + + test dtplite_exec_validate_good {validate of a well-formed .man file exits 0}\ + -constraints havedtplitecmd\ + -setup $common -body { + variable fixdir + set r [run_dtplite validate [file nativename $fixdir/sample_good.man]] + lappend result [dict get $r exit] + }\ + -cleanup { + }\ + -result [list 0] + + test dtplite_exec_validate_bad {validate of a malformed .man file exits nonzero and reports a doctools format error}\ + -constraints havedtplitecmd\ + -setup $common -body { + variable fixdir + set r [run_dtplite validate [file nativename $fixdir/sample_bad.man]] + lappend result [expr {[dict get $r exit] != 0}] + lappend result [string match "*FmtError*" [dict get $r output]] + }\ + -cleanup { + }\ + -result [list 1 1] + + test dtplite_exec_validate_tree {validate of a directory tree of .man files exits 0 - the 'dev doc.validate' usecase}\ + -constraints havedtplitecmd\ + -setup $common -body { + variable treedir + set r [run_dtplite validate [file nativename $treedir]] + lappend result [dict get $r exit] + }\ + -cleanup { + }\ + -result [list 0] + + test dtplite_exec_html_generation {html generation from a .man file produces an html output file mentioning the manpage name}\ + -constraints havedtplitecmd\ + -setup $common -body { + variable fixdir + set outfile $fixdir/sample_good.html + set r [run_dtplite -o [file nativename $outfile] html [file nativename $fixdir/sample_good.man]] + lappend result [dict get $r exit] + lappend result [file exists $outfile] + if {[file exists $outfile]} { + set html [readbytes $outfile] + lappend result [string match "*sample_good*" $html] + } else { + lappend result no_output_file + } + }\ + -cleanup { + file delete -force $fixdir/sample_good.html + }\ + -result [list 0 1 1] +} +tcltest::cleanupTests ;#needed to produce test summary line.