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.
 
 
 
 
 
 

4128 lines
205 KiB

: "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 <inputfilepath> -outputfolder <folderpath>
@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
: <<nextshell_start>>
@SET "nextshellpath[win32___________]=cmd.exe /c powershell -nop -nol -ExecutionPolicy bypass -File___________________________________________________________________"
@SET "nextshelltype[win32___________]=powershell______"
@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________________________________________________________________________"
@SET "nextshelltype[dragonflybsd____]=bash____________"
@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________________________________________________________________________"
@SET "nextshelltype[freebsd_________]=bash____________"
@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________________________________________________________________________"
@SET "nextshelltype[netbsd__________]=bash____________"
@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________________________________________________________________________"
@SET "nextshelltype[linux___________]=bash____________"
@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________________________________________________________________________"
@SET "nextshelltype[macosx__________]=bash____________"
@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________________________________________________________________________"
@SET "nextshelltype[other___________]=bash____________"
: <<nextshell_end>>
@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).
: <<asadmin_start>>
@SET "asadmin=0"
: <<asadmin_end>>
@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 ": <<nextshell_start>>*" $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 ": <<nextshell_end>>*" $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-payload>
puts stderr "No tcl code for this script. Try another program such as zsh or bash or perl"
#</tcl-payload>
# -- --- --- --- --- --- --- --- --- --- --- ---
# -- 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 "/: <<nextshell_start>>/{:a;n;/: <<nextshell_end>>/q;p;ba}" "$0" | grep $os)
#awk tested on linux & freebsd
shellconfiglines=$( awk '/^:.*<<nextshell_start>>.*$/,/^:.*<<nextshell_end>>.*$/' "$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"
#<shell-payload>
wdir="$(pwd)"; [ "$(pwd)" = "/" ] && wdir=""
case "$0" in
/*) scriptpath="${0}";;
*) scriptpath="$wdir/${0#./}";;
esac
scriptdir="${scriptpath%/*}"
scriptdir=$(realpath $scriptdir)
scriptpath=$(realpath $scriptpath)
basename=$(basename "$scriptpath") #e.g punk-runtime.bash
scriptroot="${basename%.*}" #e.g "punk-runtime"
#artifact server base url - overridable for mirrors/testing
url_kitbase="${PUNKBIN_URL:-https://www.gitea1.intx.com.au/jn/punkbin/raw/branch/master}"
#$OSTYPE varies in capitalization across for example zsh and bash
#uname probably a more consistent bet
arch=$(uname -m) #machine/architecture
plat=$(uname -s) #platform/system
#even though most of the platform prongs are very similar,
#we keep the code separate so it can be tweaked easily for unexpected differences
#each prong sets local_platform (the CANONICAL punkshell platform-DIR name - see
#punk::platform / 'help platforms': cpu tokens normalized amd64->x86_64,
#aarch64->arm64). Per-platform default fetch runtimes are NOT baked here - they
#are the server's curated defaults.txt (a punkbin release decision).
narch="$arch"
case "$narch" in
amd64) narch="x86_64";;
aarch64|arm64) narch="arm64";;
esac
if [[ "$plat" = "Linux"* ]]; then
if [[ "$arch" = "x86_64"* ]]; then
local_platform="linux-x86_64"
elif [[ "$narch" = "arm64" ]]; then
#canonical arm64 (aarch64). punkbin's existing arm kit predates the
#arm64 name and sits in linux-arm (the server's defaults.txt records
#per-platform recommendations).
local_platform="linux-arm64"
elif [[ "$arch" = "arm"* ]]; then
#32-bit arm
local_platform="linux-arm"
else
local_platform="linux-$narch"
fi
os="linux"
elif [[ "$plat" = "Darwin"* ]]; then
os="macosx"
#universal (multi-arch) binaries - the runtime tier keeps ONE macosx folder
#(per-arch macosx-x86_64/macosx-arm64 names serve the lib tree tier)
local_platform="macosx"
elif [[ "$plat" = "FreeBSD"* ]]; then
#canonical names (2026-07-22): freebsd-x86_64 (punkbin's actual folder; this
#payload previously said freebsd-amd64) / freebsd-arm64
local_platform="freebsd-$narch"
os="freebsd"
elif [[ "$plat" == "DragonFly"* ]]; then
local_platform="dragonflybsd-$narch"
os="dragonflybsd"
elif [[ "$plat" == "NetBSD"* ]]; then
local_platform="netbsd-$narch"
os="netbsd"
elif [[ "$plat" == "OpenBSD"* ]]; then
local_platform="openbsd-$narch"
os="openbsd"
elif [[ "$plat" == "MINGW32"* ]]; then
#32-bit msys shell - a genuine 32-bit machine reports i*86; a 32-bit shell
#on a 64-bit OS is rare enough that the machine arch decides (REVIEW)
os="win32"
case "$arch" in
i*86) local_platform="win32-ix86";;
*) local_platform="win32-x86_64";;
esac
elif [[ "$plat" == "MINGW64"* ]]; then
#REVIEW
os="win32"
local_platform="win32-x86_64"
elif [[ "$plat" == "CYGWIN_NT"* ]]; then
os="win32"
case "$arch" in
i*86) local_platform="win32-ix86";;
*) local_platform="win32-x86_64";;
esac
elif [[ "$plat" == "MSYS_NT"* ]]; then
echo MSYS
os="win32"
#use 'command -v' (shell builtin preferred over external which)
interp=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'`
shellpath=`command -v $interp`
shellfolder="${shellpath%/*}" #avoid dependency on basename or dirname
#"c:/windows/system32/" is quite likely in the path ahead of msys,git etc.
#This breaks calls to various unix utils such as sed etc (wsl related?)
export PATH="$shellfolder${PATH:+:${PATH}}"
local_platform="win32-x86_64"
else
local_platform="other"
os="other"
fi
#-- platform resolution (G-105 cross-build staging) ---------------------------
#The prongs above detect the LOCAL platform. fetch/list/use accept
#'-platform <p>' to aim at another punkbin platform-dir (cross-build staging /
#provisioning); resolution: -platform arg > PUNK_RUNTIME_PLATFORM env > local.
#Values are punkbin platform-dir names (e.g win32-x86_64, linux-x86_64, macosx)
#- NOT zig triples (the buildsuite maps triples to platform dirs at build time).
#Validation is shape-only: the server's sha1sums/404 is the truth for what
#exists. 'run' is LOCAL ONLY (foreign binaries are not runnable here) - only a
#LEADING -platform is rejected there; later args belong to the launched runtime.
action="${1:-}"
[[ $# -gt 0 ]] && shift
platform_opt=""
case "$action" in
fetch|list|use|platforms)
newargs=()
while [[ $# -gt 0 ]]; do
if [[ "$1" == "-platform" ]]; then
shift
if [[ $# -eq 0 ]]; then
echo "option -platform requires a value (a punkbin platform-dir name, e.g linux-x86_64)"
exit 1
fi
platform_opt="$1"
else
newargs+=("$1")
fi
shift
done
set -- ${newargs[@]+"${newargs[@]}"}
;;
run)
if [[ "${1:-}" == "-platform" ]]; then
echo "'run' launches the LOCAL platform's active runtime - it takes no -platform option"
echo "(foreign-platform folders are cross-build staging; deploy them to their platform to run)"
exit 1
fi
;;
esac
platform="${platform_opt:-${PUNK_RUNTIME_PLATFORM:-$local_platform}}"
if ! [[ "$platform" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "invalid platform name '$platform' (expected a punkbin platform-dir name such as win32-x86_64, linux-x86_64, macosx)"
exit 1
fi
is_local=0
[[ "$platform" == "$local_platform" ]] && is_local=1
archdir="${scriptdir}/runtime/${platform}"
archtail="$platform" #server folder name e.g linux-x86_64
active_file="${archdir}/active.toml"
#sha1 of a file - tool varies by platform (sha1sum: linux/coreutils, shasum: macosx,
#sha1: BSDs, openssl: common fallback). Empty result means no tool available.
sha1_of() {
if command -v sha1sum >/dev/null 2>&1; then
sha1sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 1 "$1" | awk '{print $1}'
elif command -v sha1 >/dev/null 2>&1; then
sha1 -q "$1"
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha1 -r "$1" | awk '{print $1}'
else
echo ""
fi
}
lcase() { printf '%s' "$1" | tr 'A-F' 'a-f'; }
#active runtime marker: constrained single-key toml, per platform folder
#(local per-machine state - ignored by git and fossil)
get_active() {
if [[ -f "$active_file" ]]; then
sed -n 's/^[[:space:]]*active[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$active_file" | head -n 1
fi
}
set_active() {
printf 'active = "%s"\n' "$1" > "$active_file"
echo "active runtime for $archtail set to: $1 (recorded in $active_file)"
}
#installed runtime candidates - exclude directories, build copies and
#Support-file extensions: things that legitimately sit in a runtime tier beside the
#runtimes but are never themselves selectable runtimes. Filtered from the local
#candidate listing, from 'use', and from sha1 value-matching against the server's
#sha1sums (support rows ride in that file too). Keep this in step with the ps1
#payload's $PunkRuntimeSupportExt/Test-PunkRuntimeSupportName - listings must agree
#byte-for-byte.
# docs/text .txt .md .html .htm .pdf .url
# config/meta .toml .json .xml .yml .yaml .ini .cfg
# dev leftover .tm .tmp .log .bak .old
# checksums .sha1 .sha256 .asc .sig
# archives .zip .tar .gz .bz2 .xz .7z
#Anything else is treated as a runtime candidate - see bin/runtime/README.md for what
#a runtime tier is expected to contain.
punk_support_ext_re='\.(txt|md|html|htm|pdf|url|toml|json|xml|yml|yaml|ini|cfg|tm|tmp|log|bak|old|sha1|sha256|asc|sig|zip|tar|gz|bz2|xz|7z)$'
is_punk_support_name() {
case "$1" in
*.txt|*.md|*.html|*.htm|*.pdf|*.url) return 0;;
*.toml|*.json|*.xml|*.yml|*.yaml|*.ini|*.cfg) return 0;;
*.tm|*.tmp|*.log|*.bak|*.old) return 0;;
*.sha1|*.sha256|*.asc|*.sig) return 0;;
*.zip|*.tar|*.gz|*.bz2|*.xz|*.7z) return 0;;
esac
return 1
}
#non-runtime files (ps1-payload parity: files only, same extension excludes).
#LC_ALL=C: glob expansion order is collation-dependent - C-locale byte order
#matches the ps1 payload's ordinal sorting, so listings agree everywhere.
list_candidates() {
local f LC_ALL=C
if [[ -d "$archdir" ]]; then
for f in "$archdir"/*; do
[[ -f "$f" ]] || continue
f="${f##*/}"
case "$f" in
*_BUILDCOPY*) continue;;
esac
is_punk_support_name "$f" && continue
printf '%s\n' "$f"
done
fi
}
#name minus a .exe suffix only - dotted tcl patchlevels (tclsh9.0.5-punk) make
#generic last-dot extension stripping wrong for extensionless unix names
rootname_of() {
case "$1" in
*.exe|*.EXE) printf '%s' "${1%.???}";;
*) printf '%s' "$1";;
esac
}
#G-103 artifact metadata: a runtime may carry a <rootname>.toml beside it (emitted
#by the buildsuite kit-family-artifacts step / fetched from punkbin). Prints a
#short "[variant=... tcl=... rN ...]" summary for list output, "" when absent.
metadata_summary() {
local exename="$1" tomlfile parts variant tclpatch revision piperepl artname target
tomlfile="$archdir/$(rootname_of "$exename").toml"
[[ -f "$tomlfile" ]] || return 0
variant=$(sed -n 's/^[[:space:]]*variant[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$tomlfile" | head -n 1)
tclpatch=$(sed -n 's/^[[:space:]]*tcl_patchlevel[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$tomlfile" | head -n 1)
revision=$(sed -n 's/^[[:space:]]*revision[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$tomlfile" | head -n 1)
piperepl=$(sed -n 's/^[[:space:]]*piperepl[[:space:]]*=[[:space:]]*\(true\|false\).*/\1/p' "$tomlfile" | head -n 1)
artname=$(sed -n 's/^[[:space:]]*name[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$tomlfile" | head -n 1)
target=$(sed -n 's/^[[:space:]]*target[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$tomlfile" | head -n 1)
parts=""
[[ -n "$variant" ]] && parts="$parts variant=$variant"
[[ -n "$tclpatch" ]] && parts="$parts tcl=$tclpatch"
[[ -n "$revision" ]] && parts="$parts r$revision"
if [[ "$piperepl" == "true" ]]; then parts="$parts piperepl=on"
elif [[ "$piperepl" == "false" ]]; then parts="$parts piperepl=off"; fi
#a materialized working copy records which immutable artifact it came from
[[ -n "$artname" && "$artname" != "$exename" ]] && parts="$parts from=$artname"
#integrity flag: a runtime filed under a platform folder its metadata says it
#was not built for (cross-platform fetch/staging misfiling)
[[ -n "$target" && "$target" != "$archtail" ]] && parts="$parts !TARGET-MISMATCH:$target"
[[ -n "$parts" ]] && printf '[%s]' "${parts# }"
}
#operator help (the 'help' action; the no-args case shows show_usage only).
#Keep in sync with the ps1 payload's Show-PunkRuntimeHelp/Show-PunkRuntimeUsage.
show_usage() {
echo "Usage: $0 {fetch|list|use|run|info|platforms|help}"
echo " fetch ?<name>? ?-platform <p>? download+verify a runtime from punkbin"
echo " list ?-remote? ?-platform <p>? installed (or server) runtimes + metadata"
echo " use <name> ?-platform <p>? select active / materialize -r<N> artifact"
echo " run ?args...? launch the active local-platform runtime"
echo " info <name-or-path> ?-platform <p>? identity report: embedded record vs sidecar toml"
echo " platforms ?-remote? local platform folders / platforms the server serves"
echo " help full help (options, env vars, examples)"
}
show_help() {
echo "punk-runtime - manage the plain Tcl runtimes under bin/runtime/<platform>/"
echo ""
show_usage
echo ""
echo "Actions:"
echo " fetch ?<name>? ?-platform <p>?"
echo " Download a runtime from the punkbin artifact server into"
echo " bin/runtime/<p>/ with sha1 verification against the server's"
echo " sha1sums.txt. -r<N>-named family artifacts also fetch their"
echo " <rootname>.toml metadata record. Omitting <name> fetches the"
echo " platform's recommended default per the server's curated"
echo " defaults.txt (a punkbin release decision; works with -platform"
echo " too) - platforms without a recorded default need an explicit"
echo " <name>. A no-name fetch also prints a one-line freshness note"
echo " when the ACTIVE runtime diverges from the resolved default."
echo " list ?-remote? ?-platform <p>?"
echo " List installed runtimes with artifact-metadata summaries"
echo " (variant, tcl patchlevel, revision, piperepl policy, source"
echo " artifact of a materialized copy; a !TARGET-MISMATCH flag marks"
echo " a runtime filed under the wrong platform folder). -remote"
echo " compares local runtimes against the server's sha1sums, marks"
echo " the locally ACTIVE runtime (*) and annotates the platform's"
echo " recommended runtime ((server default), per the server's"
echo " defaults.txt when available) - so active-vs-default is"
echo " visible at a glance. When both are known, a freshness verdict"
echo " states current/ahead/behind/incomparable explicitly (revision"
echo " comparison, sha1 identity fallback; update guidance on behind)."
echo " Rows the server listing does not name carry an identity column:"
echo " from=<artifact> for a materialized working copy (content verified"
echo " against that artifact), sha1=<name> for a byte-copy of a server"
echo " artifact - only genuinely unknown files stay '(not listed on"
echo " server)'."
echo " use <name> ?-platform <p>?"
echo " Select the runtime that 'run' launches (records active.toml in"
echo " the platform folder). An immutable -r<N> ARTIFACT name is"
echo " MATERIALIZED into its WORKING name (name minus -r<N> - what"
echo " mapvfs and projects reference), metadata toml copied alongside."
echo " run ?args...?"
echo " Launch the ACTIVE runtime of the LOCAL platform, passing args."
echo " Resolution: PUNK_ACTIVE_RUNTIME env > active.toml > sole"
echo " installed candidate. Takes no -platform (foreign binaries are"
echo " not runnable here)."
echo " info <name-or-path> ?-platform <p>?"
echo " Identity report for a runtime file (G-117 schema v1): the record"
echo " EMBEDDED in its attached image and the sidecar toml side by side,"
echo " with disagreements flagged and the file's sha1 checked against"
echo " the sidecar. The embedded read uses a zip central-directory read"
echo " of the exe-appended archive (unzip) - the target is NEVER"
echo " executed, so it is safe on stray/untrusted binaries; without"
echo " unzip the printed fallback is a cooperative probe that EXECUTES"
echo " the target (only for binaries you already trust). Works for a"
echo " bare renamed copy with no sidecar (embedded-only report);"
echo " pre-v1 artifacts without an embedded record report sidecar-only."
echo " platforms ?-remote?"
echo " List local platform folders under bin/runtime/. With -remote,"
echo " list the platforms the artifact server serves - read from the"
echo " server's platforms.txt discovery manifest (part of the punkbin"
echo " layout; third-party mirrors carry the same file), with local"
echo " presence marked. Servers without the manifest get an actionable"
echo " message (name platforms explicitly with -platform)."
echo ""
echo "Platforms: punkbin platform-dir names (e.g win32-x86_64, linux-x86_64,"
echo " macosx) - not zig triples. Default is the local platform; override"
echo " order: -platform argument > PUNK_RUNTIME_PLATFORM env var."
echo " Foreign-platform folders serve cross-build staging and provisioning:"
echo " active.toml travels with the folder when it is deployed to its real"
echo " platform (unix exec bits are restored at deploy time - e.g rsync/tar/"
echo " chmod on the receiving side)."
echo ""
echo "Env: PUNKBIN_URL (artifact server base url), PUNK_RUNTIME_PLATFORM,"
echo " PUNK_ACTIVE_RUNTIME (run-time selection override)"
echo ""
echo "Examples:"
echo " $0 platforms -remote"
echo " $0 fetch tclsh9.0.5-punk-r1.exe"
echo " $0 use tclsh9.0.5-punk-r1.exe (materializes tclsh9.0.5-punk.exe)"
echo " $0 list -remote -platform linux-x86_64"
echo " $0 fetch tclsh9.0.5-punk-r1 -platform linux-x86_64"
echo " $0 use tclsh9.0.5-punk-r1 -platform linux-x86_64"
echo " $0 run myscript.tcl arg1 arg2"
}
#G-103 artifact-tier names carry -r<N> before the (optional) .exe suffix; prints
#the WORKING name (artifact minus -r<N>) when the name is artifact-tier, else ""
working_name_of() {
local w
w=$(printf '%s' "$1" | sed -E 's/^(.*)-r[0-9]+(\.[Ee][Xx][Ee])?$/\1\2/')
if [[ "$w" != "$1" ]]; then printf '%s' "$w"; fi
}
#G-119 revision basis: prints N for -r<N>-named artifacts (before an optional
#.exe suffix), else nothing
rev_of() {
printf '%s' "$1" | sed -nE 's/^.*-r([0-9]+)(\.[Ee][Xx][Ee])?$/\1/p'
}
#G-119 freshness verdict: the relationship between the ACTIVE runtime and the
#SERVER DEFAULT - current/ahead/behind/incomparable - so an operator is never
#silently ahead of or behind the curated recommendation. Revision basis:
#-r<N> parsed from an artifact-named active, else the beside-toml's revision
#field (materialized working copies); the default's -r<N> parses from its
#defaults.txt name. Actives with no revision basis (pre-family) fall back to
#sha1 VALUE-matching against the server listing (a byte-copy of a server
#artifact is identified by content). Prints nothing when active and default
#are the same identity - callers annotate that as '(= server default)'.
#Keep verdict lines in parity with the ps1 payload's Write-PunkFreshnessVerdict.
#args: $1=activename $2=defaultname $3=server sha1sums file $4=mode
#mode 'list': multi-line (update guidance on behind, current-verdict lines)
#mode 'fetch': one-line divergence note only (current-class results stay silent)
freshness_verdict() {
local active="$1" default="$2" sha1file="$3" mode="$4"
[[ -z "$active" || -z "$default" ]] && return 0
[[ -f "$archdir/$active" ]] || return 0
local activetoml active_artifact="" arev="" aseries="" drev dseries platsuffix=""
activetoml="$archdir/$(rootname_of "$active").toml"
if [[ -f "$activetoml" ]]; then
active_artifact=$(sed -n 's/^[[:space:]]*name[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$activetoml" | head -n 1)
fi
if [[ "$active" == "$default" || "$active_artifact" == "$default" ]]; then
return 0
fi
[[ "$archtail" != "$local_platform" ]] && platsuffix=" -platform $archtail"
arev=$(rev_of "$active")
if [[ -n "$arev" ]]; then
aseries=$(working_name_of "$active")
elif [[ -f "$activetoml" ]]; then
arev=$(sed -n 's/^[[:space:]]*revision[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$activetoml" | head -n 1)
[[ -n "$arev" ]] && aseries="$active"
fi
drev=$(rev_of "$default")
dseries=$(working_name_of "$default")
local asha="" dsha=""
if [[ -z "$drev" ]]; then
#pre-family default name: no revision to compare - identity check only
if [[ -f "$sha1file" ]]; then
asha=$(lcase "$(sha1_of "$archdir/$active")")
dsha=$(lcase "$(tr -d '\r' < "$sha1file" | grep -E "^[0-9a-fA-F]{40} \*${default}\$" | head -n 1 | cut -d' ' -f1)")
fi
if [[ -n "$asha" && "$asha" == "$dsha" ]]; then
[[ "$mode" == "list" ]] && echo "freshness: active $active is content-identical to the server default $default"
else
echo "freshness: server default $default carries no -r<N> revision - no revision comparison with active $active"
fi
return 0
fi
if [[ -n "$arev" ]]; then
if [[ "$aseries" == "$dseries" ]]; then
if (( arev > drev )); then
echo "freshness: active $active (r$arev) is AHEAD of the server default $default (r$drev)"
elif (( arev < drev )); then
if [[ "$mode" == "fetch" ]]; then
echo "freshness: active $active (r$arev) is BEHIND the server default $default (r$drev) - activate it with: use $default"
else
echo "freshness: active $active (r$arev) is BEHIND the server default $default (r$drev)"
echo " update: $0 fetch$platsuffix then: $0 use $default$platsuffix"
fi
else
[[ "$mode" == "list" ]] && echo "freshness: active $active matches the server default revision (r$arev)"
fi
else
echo "freshness: active $active (r$arev) and the server default $default (r$drev) are different runtime series - no revision comparison"
fi
return 0
fi
#no active revision basis (pre-family): sha1 VALUE-match against the server
#listing identifies a byte-copy of a server artifact
local vmatch="" mrev mseries
if [[ -f "$sha1file" ]]; then
asha=$(lcase "$(sha1_of "$archdir/$active")")
fi
if [[ -n "$asha" ]]; then
dsha=$(lcase "$(tr -d '\r' < "$sha1file" | grep -E "^[0-9a-fA-F]{40} \*${default}\$" | head -n 1 | cut -d' ' -f1)")
if [[ "$asha" == "$dsha" ]]; then
[[ "$mode" == "list" ]] && echo "freshness: active $active is content-identical to the server default $default"
return 0
fi
#deterministic pick: C-collation first value-match, support files
#(.txt/.toml/...) filtered before the pick (ps1-payload parity)
vmatch=$(tr -d '\r' < "$sha1file" | awk -v h="$asha" '{n=$2; sub(/^\*/,"",n); if (tolower($1)==h) print n}' | grep -Ev "$punk_support_ext_re" | LC_ALL=C sort | head -n 1)
fi
if [[ -n "$vmatch" ]]; then
mrev=$(rev_of "$vmatch")
mseries=$(working_name_of "$vmatch")
if [[ -n "$mrev" && "$mseries" == "$dseries" ]]; then
if (( mrev > drev )); then
echo "freshness: active $active is server artifact $vmatch by content (r$mrev) - AHEAD of the server default $default (r$drev)"
elif (( mrev < drev )); then
if [[ "$mode" == "fetch" ]]; then
echo "freshness: active $active is server artifact $vmatch by content (r$mrev) - BEHIND the server default $default (r$drev) - activate it with: use $default"
else
echo "freshness: active $active is server artifact $vmatch by content (r$mrev) - BEHIND the server default $default (r$drev)"
echo " update: $0 fetch$platsuffix then: $0 use $default$platsuffix"
fi
else
[[ "$mode" == "list" ]] && echo "freshness: active $active is content-identical to server artifact $vmatch (the server default revision r$mrev)"
fi
else
echo "freshness: active $active is server artifact $vmatch by content - a different runtime series than the server default $default (no revision comparison)"
fi
else
echo "freshness: active $active has no revision basis (no -r<N> name, no metadata toml revision, sha1 matches no server artifact) - cannot compare with the server default $default (r$drev)"
fi
return 0
}
case "$action" in
"fetch")
if [[ "$archtail" == "other" ]]; then
echo "Unrecognised local platform - name one explicitly with -platform"
echo "(canonical names: 'help platforms' in the punk shell)"
exit 1
fi
runtime="${1:-}"
fetch_default=""
if [[ -z "$runtime" ]]; then
#no name given: consult the server's curated defaults.txt - the punkbin
#release recommendation (updated there as part of each publication
#change-set; see punkbin AGENTS.md). Per-platform server data, so it
#works for any -platform, not just local.
defaultsurl="${url_kitbase}/defaults.txt"
defaultslocal="${scriptdir}/runtime/defaults.txt"
mkdir -p "${scriptdir}/runtime"
if ! curl -fsSL --output "$defaultslocal" "$defaultsurl"; then
if [[ -f "$defaultslocal" ]]; then
echo "WARNING: could not fetch $defaultsurl - using cached copy at $defaultslocal"
else
echo "Could not fetch $defaultsurl"
echo "No server default available - name a runtime explicitly:"
echo " $0 fetch <runtimename> ?-platform $archtail?"
echo " (see available names: $0 list -remote -platform $archtail)"
exit 1
fi
fi
runtime=$(awk -v p="$archtail" '$1==p {print $2; exit}' "$defaultslocal")
if [[ -z "$runtime" ]]; then
echo "No default recorded for platform '$archtail' in the server's defaults.txt - name a runtime explicitly:"
echo " $0 fetch <runtimename> ?-platform $archtail?"
echo " (see available names: $0 list -remote -platform $archtail)"
exit 1
fi
echo "using server default for $archtail: $runtime"
fetch_default="$runtime"
fi
if [[ -n "$runtime" ]]; then
url="${url_kitbase}/${archtail}/${runtime}"
output="${archdir}/${runtime}"
sha1url="${url_kitbase}/${archtail}/sha1sums.txt"
sha1local="${archdir}/sha1sums.txt"
mkdir -p "$archdir"
echo "Fetching $sha1url"
if ! curl -fsSL --output "$sha1local" "$sha1url"; then
echo "An error occurred while downloading $sha1url"
if [[ -f "$output" ]]; then
echo "A runtime is available at $output - but we failed to retrieve the list of sha1sums from the server"
echo "Unable to check for updated version at this time."
else
echo "Please retry - or manually download a runtime from ${url_kitbase}/${archtail} and verify checksums"
fi
exit 1
fi
#sha1sums.txt line format: <sha1> *<filename> (binary indicator)
#(dots in $runtime are regex-any here - acceptable for these filenames)
stored_sha1=$(grep -E "^[0-9a-fA-F]{40} \*${runtime}\$" "$sha1local" | head -n 1 | cut -d' ' -f1)
if [[ -z "$stored_sha1" ]]; then
echo "Unable to locate hash for $runtime in $sha1local - Aborting"
echo "Please download and verify manually (or check available names with: $0 list)"
exit 1
fi
probe=$(sha1_of "$sha1local")
if [[ -z "$probe" ]]; then
echo "No sha1 tool found (tried sha1sum, shasum, sha1, openssl) - refusing unverified download."
echo "Please install one (e.g coreutils or openssl) and retry."
exit 1
fi
need_download=1
if [[ -f "$output" ]]; then
echo "Runtime already found at $output"
echo "Checking sha1 checksum of local file versus sha1 of server file"
local_sha1=$(sha1_of "$output")
if [[ "$(lcase "$local_sha1")" == "$(lcase "$stored_sha1")" ]]; then
need_download=0
echo "Local copy of runtime at $output matches sha1 checksum of file on server."
echo "No download required"
else
echo "$runtime on server has different sha1 hash - Download required"
fi
else
echo "$runtime not found locally - Download required"
fi
if [[ "$need_download" -eq 1 ]]; then
echo "Downloading from $url ..."
if ! curl -fSL --output "${output}.tmp" "$url"; then
echo "Error: Failed to download to ${output}.tmp"
exit 1
fi
echo "comparing sha1 checksum of downloaded file with data in sha1sums.txt"
new_sha1=$(sha1_of "${output}.tmp")
if [[ "$(lcase "$new_sha1")" == "$(lcase "$stored_sha1")" ]]; then
echo "sha1 checksum ok"
mv -f "${output}.tmp" "$output"
chmod +x "$output"
echo "Runtime is available at $output"
if [[ "$plat" == "Linux"* ]]; then
echo "Please ensure libxFt.so.2 is available"
echo "e.g on Ubuntu: sudo apt-get install libxft2"
fi
else
echo "WARNING! sha1 of downloaded file at ${output}.tmp does not match stored sha1 from sha1sums.txt"
echo "The .tmp file has been left in place for inspection - it has NOT been installed."
exit 1
fi
fi
#G-103: family artifacts (-r<N> names) carry a metadata toml alongside
#on the server - fetch it too; absence is fine (pre-family runtimes)
if [[ -n "$(working_name_of "$runtime")" ]]; then
tomlname="$(rootname_of "$runtime").toml"
if curl -fsSL --output "${archdir}/${tomlname}" "${url_kitbase}/${archtail}/${tomlname}"; then
echo "artifact metadata saved at ${archdir}/${tomlname}"
else
rm -f "${archdir}/${tomlname}"
echo "no artifact metadata toml on server for $runtime (ok for pre-family runtimes)"
fi
fi
#first fetch establishes the active runtime; later fetches never steal it
if [[ -z "$(get_active)" ]]; then
set_active "$runtime"
fi
#G-119: a no-name fetch is a request for "the default" - when the ACTIVE
#runtime diverges from it, say so (one line) instead of silence
if [[ -n "$fetch_default" ]]; then
freshness_verdict "$(get_active)" "$fetch_default" "$sha1local" fetch
fi
fi
;;
"list")
if [[ "${1:-}" == "-remote" ]]; then
#compare local runtimes with those listed on the artifact server
#(functional parity with the powershell payload's 'list -remote')
sha1url="${url_kitbase}/${archtail}/sha1sums.txt"
sha1local="${archdir}/sha1sums.txt"
mkdir -p "$archdir"
if curl -fsSL --output "$sha1local" "$sha1url"; then
echo "Fetched $sha1url"
elif [[ -f "$sha1local" ]]; then
echo "WARNING: could not fetch $sha1url - using cached copy at $sha1local"
else
echo "Unable to fetch $sha1url and no cached copy available"
exit 1
fi
probe=$(sha1_of "$sha1local")
if [[ -z "$probe" ]]; then
echo "No sha1 tool found (tried sha1sum, shasum, sha1, openssl) - cannot compare local runtimes."
exit 1
fi
#server-default + local-active surfacing (G-103): the server's curated
#defaults.txt names the recommended runtime per platform - best-effort
#fetch (cached/absent both fine for a listing), then mark the matching
#row and the locally active one so "is my active the recommended
#default?" is answerable at a glance.
activename=$(get_active)
platform_default=""
defaultslocal="${scriptdir}/runtime/defaults.txt"
curl -fsL --silent --output "$defaultslocal" "${url_kitbase}/defaults.txt" 2>/dev/null || :
if [[ -f "$defaultslocal" ]]; then
platform_default=$(awk -v p="$archtail" '$1==p {print $2; exit}' "$defaultslocal")
fi
echo "----------------------------------------------------------------------------------------"
echo "Runtimes for $archtail"
echo "Local $archdir"
echo "Remote ${url_kitbase}/${archtail}"
if [[ -n "$platform_default" ]]; then
echo "server default for $archtail: $platform_default"
fi
if [[ -n "$activename" ]]; then
#the default names an immutable -r<N> ARTIFACT while the active is
#typically its materialized WORKING name - the beside-toml's 'name'
#field records which artifact the working copy came from, so match
#on either identity
active_artifact=""
activetoml="$archdir/$(rootname_of "$activename").toml"
if [[ -f "$activetoml" ]]; then
active_artifact=$(sed -n 's/^[[:space:]]*name[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$activetoml" | head -n 1)
fi
matchnote=""
if [[ -n "$platform_default" ]] && [[ "$activename" == "$platform_default" || "$active_artifact" == "$platform_default" ]]; then
matchnote=" (= server default)"
fi
echo "active (local): $activename$matchnote"
#G-119 freshness verdict: current/ahead/behind/incomparable when the
#active is not the default identity (silent when it is - the
#annotation above already says so)
freshness_verdict "$activename" "$platform_default" "$sha1local" list
fi
echo "----------------------------------------------------------------------------------------"
printf ' %-35s %-33s %s\n' "Local" "" "Remote"
echo "----------------------------------------------------------------------------------------"
for f in $(list_candidates); do
local_sha1=$(lcase "$(sha1_of "$archdir/$f")")
stored_sha1=$(grep -E "^[0-9a-fA-F]{40} \*${f}\$" "$sha1local" | head -n 1 | cut -d' ' -f1)
mid=""
ident=""
if [[ -n "$stored_sha1" ]]; then
if [[ "$local_sha1" == "$(lcase "$stored_sha1")" ]]; then
rhs="Same version"
else
rhs="UPDATE AVAILABLE"
fi
else
#G-119 refinement: identity middle column for rows the server
#listing does not name. A materialized working copy is identified
#by its beside-toml source artifact (from=) with its CONTENT
#verified against that artifact's server sha1; a tomlless stray by
#sha1 VALUE-match (sha1=). The Remote column then carries a real
#server relationship for such rows instead of the dead-end
#'(not listed on server)', which now means genuinely unknown.
rowtoml="$archdir/$(rootname_of "$f").toml"
artname=""
if [[ -f "$rowtoml" ]]; then
artname=$(sed -n 's/^[[:space:]]*name[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$rowtoml" | head -n 1)
[[ "$artname" == "$f" ]] && artname=""
fi
if [[ -n "$artname" ]]; then
mid="from=$artname"
ident="$artname"
artsha=$(lcase "$(tr -d '\r' < "$sha1local" | grep -E "^[0-9a-fA-F]{40} \*${artname}\$" | head -n 1 | cut -d' ' -f1)")
if [[ -z "$artsha" ]]; then
rhs="(artifact not listed on server)"
elif [[ "$local_sha1" == "$artsha" ]]; then
rhs="Same version"
else
#-r<N> artifacts are immutable - differing content means a
#modified local copy or a mismatched sidecar, not an update
rhs="CONTENT DIFFERS"
fi
else
#sha1 value-match: the default's row first, else the first
#C-collation match with support files excluded - the same pick
#rule as freshness_verdict, so the row identity and the
#freshness line always name the same artifact
vname=""
if [[ -n "$platform_default" ]]; then
dsha=$(lcase "$(tr -d '\r' < "$sha1local" | grep -E "^[0-9a-fA-F]{40} \*${platform_default}\$" | head -n 1 | cut -d' ' -f1)")
[[ -n "$dsha" && "$local_sha1" == "$dsha" ]] && vname="$platform_default"
fi
if [[ -z "$vname" ]]; then
vname=$(tr -d '\r' < "$sha1local" | awk -v h="$local_sha1" '{n=$2; sub(/^\*/,"",n); if (tolower($1)==h) print n}' | grep -Ev "$punk_support_ext_re" | LC_ALL=C sort | head -n 1)
fi
if [[ -n "$vname" ]]; then
mid="sha1=$vname"
ident="$vname"
rhs="Same version"
else
rhs="(not listed on server)"
fi
fi
fi
mark=" "
[[ "$f" == "$activename" ]] && mark="* "
annot=""
#the (server default) annotation follows artifact identity: a working
#copy of (or byte-copy of) the default artifact is the default
if [[ -n "$platform_default" ]] && [[ "$f" == "$platform_default" || "$ident" == "$platform_default" ]]; then
annot=" (server default)"
fi
printf '%s%-35s %-33s %s%s\n' "$mark" "$f" "$mid" "$rhs" "$annot"
done
#remote-only entries, sorted (ps1-payload parity - its dict enumeration
#is explicitly sorted). tr strips CRLF \r: msys grep strips it in text
#mode but bash read does not - without this a locally-present runtime
#also shows as a remote-only row (\r-suffixed name fails the -f test)
tr -d '\r' < "$sha1local" | sed -n 's/^[0-9a-fA-F]\{40\} \*\(.*\)$/\1/p' | LC_ALL=C sort | while IFS= read -r rname; do
#skip support files riding in the server's sha1sums beside the
#runtimes (artifact metadata tomls etc) - same extension set the
#local candidate filter excludes
is_punk_support_name "$rname" && continue
if [[ -n "$rname" && ! -f "$archdir/$rname" ]]; then
annot=""
[[ -n "$platform_default" && "$rname" == "$platform_default" ]] && annot=" (server default)"
printf ' %-35s %-33s %s%s\n' "-" "" "$rname" "$annot"
fi
done
echo "----------------------------------------------------------------------------------------"
echo "* = active (local selection)"
else
if [[ -d "$archdir" ]]; then
candidates=$(list_candidates)
active=$(get_active)
count=$(printf '%s\n' "$candidates" | grep -c . )
echo "$count runtime(s) in $archdir"
for f in $candidates; do
meta=$(metadata_summary "$f")
if [[ "$f" == "$active" ]]; then
printf '* %-35s (active) %s\n' "$f" "$meta"
else
printf ' %-35s %s\n' "$f" "$meta"
fi
done
if [[ -n "$active" && ! -f "$archdir/$active" ]]; then
echo "WARNING: active runtime '$active' (from $active_file) is not present - use '$0 use <name>' to reselect"
fi
echo "Use: '$0 list -remote' to compare local runtimes with those available on the artifact server"
echo "Use: '$0 use <name>' to select the runtime that 'run' launches"
echo "Use: '$0 use <artifact-r<N>-name>' to materialize an immutable -r<N> artifact into its working name and select it"
else
echo "No runtimes available in $archdir"
echo " Use '$0 fetch' to install."
fi
fi
;;
"use")
#with -platform: manage THAT platform folder's selection/materialization
#(cross-build staging - active.toml travels with the folder when deployed;
#'run' on this machine only ever consults the local platform)
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 use <runtimename> ?-platform <p>?"
echo "Installed candidates ($platform):"
for f in $(list_candidates); do
echo " $f"
done
active=$(get_active)
if [[ -n "$active" ]]; then
echo "Currently active: $active"
fi
exit 1
fi
if [[ "$1" == *_BUILDCOPY* ]] || is_punk_support_name "$1"; then
echo "'$1' is not a selectable runtime"
exit 1
fi
if [[ ! -f "$archdir/$1" && -f "$archdir/$1.exe" ]]; then
#accept the name without its .exe suffix (root-name convention: G-103
#name handling strips only .exe, so the suffixless spelling is
#unambiguous; exact names - incl. extensionless unix artifacts - are
#matched first)
set -- "$1.exe" "${@:2}"
fi
if [[ ! -f "$archdir/$1" ]]; then
echo "No runtime named '$1' found in $archdir"
echo "Installed candidates:"
for f in $(list_candidates); do
echo " $f"
done
exit 1
fi
#G-103 artifact-tier names (-r<N>, immutable): 'use' MATERIALIZES the
#artifact into its WORKING name (name minus -r<N> - what mapvfs and
#projects reference), copies its metadata toml alongside, and selects
#the working name. Republishing artifacts never churns consumers.
#(chmod is a no-op when staging unix runtimes from windows filesystems -
#exec bits are restored at deploy time, e.g rsync/tar/chmod on the guest.)
working=$(working_name_of "$1")
if [[ -n "$working" ]]; then
cp -f "$archdir/$1" "$archdir/$working"
chmod +x "$archdir/$working"
srctoml="$archdir/$(rootname_of "$1").toml"
desttoml="$archdir/$(rootname_of "$working").toml"
if [[ -f "$srctoml" ]]; then
cp -f "$srctoml" "$desttoml"
echo "materialized $working from artifact $1 (metadata toml copied alongside)"
else
echo "materialized $working from artifact $1 (no metadata toml found beside the artifact)"
fi
set_active "$working"
else
set_active "$1"
fi
;;
"run")
#resolution order: PUNK_ACTIVE_RUNTIME env override, active.toml, single
#installed candidate - otherwise error with candidates (no last-in-list guessing).
#LOCAL platform only: the -platform arg is rejected in the scan above, and a
#PUNK_RUNTIME_PLATFORM env override is likewise ignored here (foreign
#binaries are not runnable; the env override serves list/fetch/use).
if [[ "$is_local" -ne 1 ]]; then
archdir="${scriptdir}/runtime/${local_platform}"
active_file="${archdir}/active.toml"
fi
activeruntime=""
if [[ -n "$PUNK_ACTIVE_RUNTIME" ]]; then
activeruntime="$PUNK_ACTIVE_RUNTIME"
if [[ ! -f "$archdir/$activeruntime" ]]; then
echo "PUNK_ACTIVE_RUNTIME '$activeruntime' not found in $archdir"
exit 1
fi
else
activeruntime=$(get_active)
if [[ -n "$activeruntime" && ! -f "$archdir/$activeruntime" ]]; then
echo "active runtime '$activeruntime' (from $active_file) is not present in $archdir"
echo "Reselect with: $0 use <name> (or fetch it: $0 fetch $activeruntime)"
exit 1
fi
if [[ -z "$activeruntime" ]]; then
candidates=$(list_candidates)
count=$(printf '%s\n' "$candidates" | grep -c . )
if [[ "$count" -eq 1 ]]; then
activeruntime="$candidates"
elif [[ "$count" -eq 0 ]]; then
echo "No runtimes seem to be installed in $archdir"
echo "Please use '$0 fetch' to install."
exit 1
else
echo "Multiple runtimes installed and no active runtime selected."
echo "Select one with: $0 use <name>"
echo "Installed candidates:"
for f in $candidates; do
echo " $f"
done
exit 1
fi
fi
fi
activeruntime_fullpath="$archdir/$activeruntime"
#echo "using $activeruntime_fullpath"
#(the action was already shifted off during the option scan - "$@" is
#exactly the runtime's argument list)
$activeruntime_fullpath "$@"
;;
"platforms")
#enumerate platform folders: local (bin/runtime/*) and, with -remote, the
#server's platforms.txt discovery manifest (raw-file servers have no
#directory listing - the manifest is part of the punkbin layout contract;
#third-party mirrors carry the same file). -platform is ignored here
#(this action enumerates ALL platforms).
rtroot="${scriptdir}/runtime"
localdirs=""
if [[ -d "$rtroot" ]]; then
localdirs=$(LC_ALL=C ls -1 "$rtroot" 2>/dev/null | while read -r d; do [[ -d "$rtroot/$d" ]] && echo "$d"; done)
fi
if [[ "${1:-}" == "-remote" ]]; then
manifesturl="${url_kitbase}/platforms.txt"
manifestlocal="${rtroot}/platforms.txt"
mkdir -p "$rtroot"
if curl -fsSL --output "$manifestlocal" "$manifesturl"; then
echo "Fetched $manifesturl"
elif [[ -f "$manifestlocal" ]]; then
echo "WARNING: could not fetch $manifesturl - using cached copy at $manifestlocal"
else
echo "No platforms.txt available from $manifesturl"
echo "(pre-convention punkbin or third-party mirror without the discovery manifest)"
echo "Name platforms explicitly with -platform; canonical names: 'help platforms' in the punk shell"
exit 1
fi
echo "-----------------------------------------------------------------------"
echo "Platforms served by ${url_kitbase}"
echo "-----------------------------------------------------------------------"
remoteplatforms=""
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
remoteplatforms="$remoteplatforms $line"
marks=""
[[ "$line" == "$local_platform" ]] && marks="local platform"
if printf '%s\n' $localdirs | grep -qx "$line"; then
[[ -n "$marks" ]] && marks="$marks, "
marks="${marks}local dir present"
fi
if [[ -n "$marks" ]]; then
printf ' %-25s (%s)\n' "$line" "$marks"
else
printf ' %-25s\n' "$line"
fi
done < "$manifestlocal"
for d in $localdirs; do
if ! printf '%s\n' $remoteplatforms | grep -qx "$d"; then
printf ' %-25s (local dir only - not served remotely)\n' "$d"
fi
done
echo "-----------------------------------------------------------------------"
echo "Use: '$0 list -remote -platform <name>' to see a platform's runtimes"
else
echo "-----------------------------------------------------------------------"
echo "Local platform folders under $rtroot"
echo "-----------------------------------------------------------------------"
if [[ -z "$localdirs" ]]; then
echo " (none - 'fetch' creates the local platform's folder)"
fi
for d in $localdirs; do
if [[ "$d" == "$local_platform" ]]; then
printf '* %-25s (local platform)\n' "$d"
else
printf ' %-25s\n' "$d"
fi
done
echo "-----------------------------------------------------------------------"
echo "Use: '$0 platforms -remote' to see platforms served by the artifact server"
echo "Canonical platform names: 'help platforms' in the punk shell"
fi
;;
"info")
#G-117: identity report - the sidecar toml and the record EMBEDDED in the
#artifact's attached image, side by side, disagreements flagged. Embedded
#read = zip central-directory read of the exe-appended archive via unzip
#(the target is NEVER executed - identifying an untrusted stray binary by
#running it is exactly what this action avoids). Hosts without unzip get
#the cooperative-probe fallback COMMAND printed instead (that one EXECUTES
#the target - only for binaries already trusted).
iname="${1:-}"
if [[ -z "$iname" ]]; then
echo "usage: $0 info <runtime-name-or-path> ?-platform <p>?"
exit 1
fi
if [[ -f "$iname" ]]; then
tfile="$iname"
elif [[ -f "$archdir/$iname" ]]; then
tfile="$archdir/$iname"
else
echo "punk-runtime info: no such file: '$iname' (checked as a path and in $archdir)"
exit 1
fi
tdir=$(dirname "$tfile"); tbase=$(basename "$tfile")
sidecar="$tdir/$(rootname_of "$tbase").toml"
embedded=""
haveunzip=0
if command -v unzip >/dev/null 2>&1; then
haveunzip=1
embedded=$(unzip -p "$tfile" punkbin-artifact.toml 2>/dev/null || true)
fi
localsha1=$(lcase "$(sha1_of "$tfile")")
echo "-----------------------------------------------------------------------"
echo "punk-runtime info: $tbase"
echo " file: $tfile"
[[ -n "$localsha1" ]] && echo " sha1: $localsha1 (computed from the file)"
if [[ -f "$sidecar" ]]; then
echo " sidecar: $sidecar"
else
echo " sidecar: (none beside the file - stray/renamed copy?)"
fi
if [[ $haveunzip -eq 0 ]]; then
echo " embedded: (cannot read - no 'unzip' on this host)"
echo " fallback - a cooperative probe that EXECUTES the target (only run"
echo " this against a binary you already trust):"
echo " echo 'catch {puts [read [open //zipfs:/app/punkbin-artifact.toml]]};exit' | $tfile"
elif [[ -z "$embedded" ]]; then
echo " embedded: (no punkbin-artifact.toml in the attached image - pre-v1"
echo " artifact, or not a punkshell family runtime)"
fi
echo "-----------------------------------------------------------------------"
if [[ -n "$embedded" || -f "$sidecar" ]]; then
printf ' %-20s %-36s %s\n' "field" "embedded" "sidecar"
disagreements=""
for k in schema name class variant working_name revision target build_id origin packager project project_url license build_host_platform tcl_patchlevel sha1 size built; do
ev=""; sv=""
if [[ -n "$embedded" ]]; then
ev=$(printf '%s\n' "$embedded" | sed -n 's/^[[:space:]]*'"$k"'[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' | head -n 1)
[[ -z "$ev" ]] && ev=$(printf '%s\n' "$embedded" | sed -n 's/^[[:space:]]*'"$k"'[[:space:]]*=[[:space:]]*\([0-9][0-9]*\|true\|false\).*/\1/p' | head -n 1)
fi
if [[ -f "$sidecar" ]]; then
sv=$(sed -n 's/^[[:space:]]*'"$k"'[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$sidecar" | head -n 1)
[[ -z "$sv" ]] && sv=$(sed -n 's/^[[:space:]]*'"$k"'[[:space:]]*=[[:space:]]*\([0-9][0-9]*\|true\|false\).*/\1/p' "$sidecar" | head -n 1)
fi
[[ -z "$ev" && -z "$sv" ]] && continue
printf ' %-20s %-36s %s\n' "$k" "${ev:--}" "${sv:--}"
#schema absent on one side is pre-v1 tolerance, not a disagreement
if [[ -n "$ev" && -n "$sv" && "$ev" != "$sv" ]]; then
disagreements="$disagreements $k"
fi
done
echo "-----------------------------------------------------------------------"
if [[ -n "$disagreements" ]]; then
echo " !DISAGREE embedded-vs-sidecar on:$disagreements"
echo " (the sidecar + sha1sums are the integrity authority; a mismatched pair"
echo " means this sidecar belongs to a different assembly than this binary)"
elif [[ -n "$embedded" && -f "$sidecar" ]]; then
echo " embedded and sidecar records agree on all shared fields"
fi
if [[ -f "$sidecar" && -n "$localsha1" ]]; then
scsha1=$(lcase "$(sed -n 's/^[[:space:]]*sha1[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' "$sidecar" | head -n 1)")
if [[ -n "$scsha1" ]]; then
if [[ "$scsha1" == "$localsha1" ]]; then
echo " sha1: file matches the sidecar record"
else
echo " !SHA1-MISMATCH: file does not match the sidecar record (modified or"
echo " different assembly; for a materialized working copy compare build_id)"
fi
fi
fi
fi
;;
"help")
show_help
;;
"")
#no action: short usage + pointer at the fuller help
show_usage
echo ""
echo "'$0 help' gives options, env vars and examples."
;;
*)
echo "unknown action '$action'"
show_usage
exit 1
;;
esac
#(512B spacer) byte-alignment padding so template labels beyond the payloads clear cmd's
#512-byte label-scan boundaries - resize this comment if scriptwrap checkfile reports a
#boundary-spanning label after a payload edit (see bin/AGENTS.md polyglot workflow)
#</shell-payload>
#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";
}
#<perl-payload>
print STDERR "No perl code for this script. Try another program such as tcl or bash";
#</perl-payload>
# -- --- --- --- --- --- --- ---
#$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 = ": <<asadmin_start>>"
$endTag = ": <<asadmin_end>>"
$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 = ": <<nextshell_start>>"
$endTag = ": <<nextshell_end>>"
$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
#<powershell-payload>
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-Object -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
}
}
function ParameterDefinitions {
param(
[Parameter(ValueFromRemainingArguments=$true,Position = 1)][string[]] $opts
)
}
#active runtime marker: constrained single-key toml (active = "name") per platform
#folder - local per-machine state, ignored by git and fossil
function Get-PunkActiveRuntime {
param([string] $archfolder)
$activefile = Join-Path -Path $archfolder -ChildPath "active.toml"
if (Test-Path -Path $activefile -PathType Leaf) {
foreach ($line in (Get-Content -Path $activefile)) {
$match = [regex]::Match($line, '^\s*active\s*=\s*"(.*)"\s*$')
if ($match.Success) {
return $match.Groups[1].Value
}
}
}
return ""
}
function Set-PunkActiveRuntime {
param([string] $archfolder, [string] $name)
$activefile = Join-Path -Path $archfolder -ChildPath "active.toml"
#LF-terminated, no BOM - the bash payload reads the same file
[System.IO.File]::WriteAllText($activefile, "active = `"$name`"`n", [System.Text.UTF8Encoding]::new($false))
Write-Host "active runtime set to: $name (recorded in $activefile)"
}
#Support-file extensions: things that legitimately sit in a runtime tier beside the
#runtimes but are never themselves selectable runtimes. Filtered from the local
#candidate listing, from 'use', and from sha1 value-matching against the server's
#sha1sums (support rows ride in that file too). Keep this set and the bash payload's
#is_punk_support_name/punk_support_ext_re in step - listings must agree byte-for-byte.
# docs/text .txt .md .html .htm .pdf .url
# config/meta .toml .json .xml .yml .yaml .ini .cfg
# dev leftover .tm .tmp .log .bak .old
# checksums .sha1 .sha256 .asc .sig
# archives .zip .tar .gz .bz2 .xz .7z
#Anything else is treated as a runtime candidate - see bin/runtime/README.md for what
#a runtime tier is expected to contain.
$script:PunkRuntimeSupportExt = @(
".txt",".md",".html",".htm",".pdf",".url",
".toml",".json",".xml",".yml",".yaml",".ini",".cfg",
".tm",".tmp",".log",".bak",".old",
".sha1",".sha256",".asc",".sig",
".zip",".tar",".gz",".bz2",".xz",".7z"
)
function Test-PunkRuntimeSupportName {
param([string] $name)
return ($script:PunkRuntimeSupportExt -contains [System.IO.Path]::GetExtension($name).ToLowerInvariant())
}
#installed runtime candidates - exclude build copies and non-runtime files.
#ORDINAL name order: culture-sensitive Sort-Object collates differently between
#windows powershell (NLS) and pwsh (ICU) - both launch paths exist (the .ps1
#twin runs under the invoking shell) - and ordinal matches the bash payload's
#LC_ALL=C ordering, so listings agree byte-for-byte everywhere.
function Get-PunkRuntimeCandidates {
param([string] $archfolder)
if (-not (Test-Path -Path $archfolder -PathType Container)) {
return @()
}
$files = @(get-childItem -Path $archfolder -File | Where-object Name -Notlike '*_BUILDCOPY*' | Where-object {-not (Test-PunkRuntimeSupportName $_.Name) })
if ($files.Count -le 1) {
return $files
}
$byname = @{}
foreach ($f in $files) { $byname[$f.Name] = $f }
$names = @($byname.Keys)
[array]::Sort($names, [System.StringComparer]::Ordinal)
$out = @()
foreach ($n in $names) { $out += $byname[$n] }
return $out
}
#operator help (the 'help' action; the no-args case shows the Usage block only).
#Keep in sync with the bash payload's show_help/show_usage.
function Show-PunkRuntimeUsage {
write-host "Usage: punk-runtime.cmd {fetch|list|use|run|info|platforms|help}"
write-host " fetch ?<name>? ?-platform <p>? download+verify a runtime from punkbin"
write-host " list ?-remote? ?-platform <p>? installed (or server) runtimes + metadata"
write-host " use <name> ?-platform <p>? select active / materialize -r<N> artifact"
write-host " run ?args...? launch the active local-platform runtime"
write-host " info <name-or-path> ?-platform <p>? identity report: embedded record vs sidecar toml"
write-host " platforms ?-remote? local platform folders / platforms the server serves"
write-host " help full help (options, env vars, examples)"
}
function Show-PunkRuntimeHelp {
write-host "punk-runtime - manage the plain Tcl runtimes under bin/runtime/<platform>/"
write-host ""
Show-PunkRuntimeUsage
write-host ""
write-host "Actions:"
write-host " fetch ?<name>? ?-platform <p>?"
write-host " Download a runtime from the punkbin artifact server into"
write-host " bin/runtime/<p>/ with sha1 verification against the server's"
write-host " sha1sums.txt. -r<N>-named family artifacts also fetch their"
write-host " <rootname>.toml metadata record. Omitting <name> fetches the"
write-host " platform's recommended default per the server's curated"
write-host " defaults.txt (a punkbin release decision; works with -platform"
write-host " too) - platforms without a recorded default need an explicit"
write-host " <name>. A no-name fetch also prints a one-line freshness note"
write-host " when the ACTIVE runtime diverges from the resolved default."
write-host " list ?-remote? ?-platform <p>?"
write-host " List installed runtimes with artifact-metadata summaries"
write-host " (variant, tcl patchlevel, revision, piperepl policy, source"
write-host " artifact of a materialized copy; a !TARGET-MISMATCH flag marks"
write-host " a runtime filed under the wrong platform folder). -remote"
write-host " compares local runtimes against the server's sha1sums, marks"
write-host " the locally ACTIVE runtime (*) and annotates the platform's"
write-host " recommended runtime ((server default), per the server's"
write-host " defaults.txt when available) - so active-vs-default is"
write-host " visible at a glance. When both are known, a freshness verdict"
write-host " states current/ahead/behind/incomparable explicitly (revision"
write-host " comparison, sha1 identity fallback; update guidance on behind)."
write-host " Rows the server listing does not name carry an identity column:"
write-host " from=<artifact> for a materialized working copy (content verified"
write-host " against that artifact), sha1=<name> for a byte-copy of a server"
write-host " artifact - only genuinely unknown files stay '(not listed on"
write-host " server)'."
write-host " use <name> ?-platform <p>?"
write-host " Select the runtime that 'run' launches (records active.toml in"
write-host " the platform folder). An immutable -r<N> ARTIFACT name is"
write-host " MATERIALIZED into its WORKING name (name minus -r<N> - what"
write-host " mapvfs and projects reference), metadata toml copied alongside."
write-host " run ?args...?"
write-host " Launch the ACTIVE runtime of the LOCAL platform, passing args."
write-host " Resolution: PUNK_ACTIVE_RUNTIME env > active.toml > sole"
write-host " installed candidate. Takes no -platform (foreign binaries are"
write-host " not runnable here)."
write-host " info <name-or-path> ?-platform <p>?"
write-host " Identity report for a runtime file (G-117 schema v1): the record"
write-host " EMBEDDED in its attached image and the sidecar toml side by side,"
write-host " with disagreements flagged and the file's sha1 checked against"
write-host " the sidecar. The embedded read uses a zip central-directory read"
write-host " of the exe-appended archive (.NET System.IO.Compression) - the"
write-host " target is NEVER executed, so it is safe on stray/untrusted"
write-host " binaries. Works for a bare renamed copy with no sidecar"
write-host " (embedded-only report); pre-v1 artifacts without an embedded"
write-host " record report sidecar-only."
write-host " platforms ?-remote?"
write-host " List local platform folders under bin/runtime/. With -remote,"
write-host " list the platforms the artifact server serves - read from the"
write-host " server's platforms.txt discovery manifest (part of the punkbin"
write-host " layout; third-party mirrors carry the same file), with local"
write-host " presence marked. Servers without the manifest get an actionable"
write-host " message (name platforms explicitly with -platform)."
write-host ""
write-host "Platforms: punkbin platform-dir names (e.g win32-x86_64, linux-x86_64,"
write-host " macosx) - not zig triples. Default is the local platform; override"
write-host " order: -platform argument > PUNK_RUNTIME_PLATFORM env var."
write-host " Foreign-platform folders serve cross-build staging and provisioning:"
write-host " active.toml travels with the folder when it is deployed to its real"
write-host " platform (unix exec bits are restored at deploy time - e.g rsync/tar/"
write-host " chmod on the receiving side)."
write-host ""
write-host "Env: PUNKBIN_URL (artifact server base url), PUNK_RUNTIME_PLATFORM,"
write-host " PUNK_ACTIVE_RUNTIME (run-time selection override)"
write-host ""
write-host "Examples:"
write-host " punk-runtime.cmd platforms -remote"
write-host " punk-runtime.cmd fetch tclsh9.0.5-punk-r1.exe"
write-host " punk-runtime.cmd use tclsh9.0.5-punk-r1.exe (materializes tclsh9.0.5-punk.exe)"
write-host " punk-runtime.cmd list -remote -platform linux-x86_64"
write-host " punk-runtime.cmd fetch tclsh9.0.5-punk-r1 -platform linux-x86_64"
write-host " punk-runtime.cmd use tclsh9.0.5-punk-r1 -platform linux-x86_64"
write-host " punk-runtime.cmd run myscript.tcl arg1 arg2"
}
#platform (target) resolution for fetch/list/use: -platform arg > PUNK_RUNTIME_PLATFORM
#env > the local default. Values are punkbin/bin-runtime platform-DIR names (e.g
#win32-x86_64, linux-x86_64, macosx) - NOT zig triples (the buildsuite maps triples to
#platform dirs at build time; punk-runtime speaks only the punkbin tier). Validation is
#deliberately shape-only: the server's sha1sums/404 is the truth for what exists.
#This payload runs on windows - the local default is win32-x86_64, or win32-ix86 on
#a GENUINE 32-bit windows host (PROCESSOR_ARCHITECTURE x86 with no
#PROCESSOR_ARCHITEW6432 - a 32-bit shell on a 64-bit OS keeps the x86_64 default:
#the runtime store serves what the OS can run). A windows-arm default becomes a
#question when punkbin carries such a folder.
$script:PunkLocalPlatform = "win32-x86_64"
if ($env:PROCESSOR_ARCHITECTURE -eq 'x86' -and -not $env:PROCESSOR_ARCHITEW6432) {
$script:PunkLocalPlatform = "win32-ix86"
}
function Resolve-PunkRuntimePlatform {
param([string] $requested)
$plat = $script:PunkLocalPlatform
if ($requested) {
$plat = $requested
} elseif ($env:PUNK_RUNTIME_PLATFORM) {
$plat = $env:PUNK_RUNTIME_PLATFORM
}
#-cnotmatch: platform-dir names are lowercase and the artifact-server URL path
#is case-sensitive (default -notmatch is case-insensitive and would pass typos
#like Win32-X86_64 through to a server 404); bash payload parity (=~ is
#case-sensitive there)
if ($plat -cnotmatch '^[a-z0-9][a-z0-9_-]*$') {
write-host "invalid platform name '$plat' (expected a punkbin platform-dir name such as win32-x86_64, linux-x86_64, macosx)"
exit 1
}
return $plat
}
#name minus a .exe suffix only - dotted tcl patchlevels (tclsh9.0.5-punk) make
#generic last-dot extension stripping wrong for extensionless unix names
function Get-PunkRuntimeRootName {
param([string] $name)
if ($name -match '(?i)\.exe$') {
return $name.Substring(0, $name.Length - 4)
}
return $name
}
#Dependency-free SHA1 (robustness fix 2026-07-23): Get-FileHash is a SCRIPT-defined
#function in Windows PowerShell 5.1's Microsoft.PowerShell.Utility, resolved via
#PSModulePath autoloading - on machines with a damaged PSModulePath (pwsh7 module-path
#pollution, overridden env vars, redirected user module dirs) it vanishes while the
#module's COMPILED cmdlets (Write-Host, Invoke-WebRequest) still work, observed in the
#field ("Get-FileHash ... not recognized" under PS 5). Hashing here goes through .NET
#directly so fetch verification and list -remote comparison work on any PowerShell
#edition/state, with a one-time informational note when the damage is present.
#Returns lowercase hex (sha1sums.txt convention; -eq/-ne comparisons are
#case-insensitive so case never matters for verification).
$script:punkFileHashNoteShown = $false
function Get-PunkFileSha1 {
param([string] $Path)
if (-not $script:punkFileHashNoteShown) {
$script:punkFileHashNoteShown = $true
if ($PSVersionTable.PSVersion.Major -le 5 -and -not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {
Write-Host "note: Get-FileHash is unavailable in this Windows PowerShell (PSModulePath on this machine may be damaged) - punk-runtime uses built-in .NET hashing and is unaffected"
}
}
#Resolve-Path: .NET's current directory is not PowerShell's location, so hand
#[System.IO.File] an absolute provider path
$fullpath = (Resolve-Path -LiteralPath $Path).ProviderPath
$sha1 = [System.Security.Cryptography.SHA1]::Create()
try {
$fs = [System.IO.File]::OpenRead($fullpath)
try {
$hashbytes = $sha1.ComputeHash($fs)
} finally {
$fs.Dispose()
}
} finally {
$sha1.Dispose()
}
return (($hashbytes | ForEach-Object { $_.ToString("x2") }) -join "")
}
#G-103 artifact metadata: a runtime may carry a <rootname>.toml beside it (emitted
#by the buildsuite kit-family-artifacts step / fetched from punkbin). Returns a
#short "[variant=... tcl=... rN ...]" summary for list output, "" when absent.
function Get-PunkRuntimeMetadataSummary {
param([string] $archfolder, [string] $exename, [string] $expectedplatform = "")
$tomlfile = Join-Path -Path $archfolder -ChildPath ((Get-PunkRuntimeRootName $exename) + ".toml")
if (-not (Test-Path -Path $tomlfile -PathType Leaf)) {
return ""
}
$fields = @{}
foreach ($line in (Get-Content -Path $tomlfile)) {
$m = [regex]::Match($line, '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"(.*)"\s*$')
if ($m.Success) {
$fields[$m.Groups[1].Value] = $m.Groups[2].Value
continue
}
$m = [regex]::Match($line, '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([0-9]+|true|false)\s*$')
if ($m.Success) {
$fields[$m.Groups[1].Value] = $m.Groups[2].Value
}
}
$parts = @()
if ($fields.ContainsKey('variant')) { $parts += "variant=$($fields['variant'])" }
if ($fields.ContainsKey('tcl_patchlevel')) { $parts += "tcl=$($fields['tcl_patchlevel'])" }
if ($fields.ContainsKey('revision')) { $parts += "r$($fields['revision'])" }
if ($fields.ContainsKey('piperepl')) {
if ($fields['piperepl'] -eq 'true') { $parts += "piperepl=on" } else { $parts += "piperepl=off" }
}
#a materialized working copy records which immutable artifact it came from
if ($fields.ContainsKey('name') -and $fields['name'] -ne $exename) { $parts += "from=$($fields['name'])" }
#integrity flag: a runtime filed under a platform folder its metadata says it
#was not built for (cross-platform fetch/staging misfiling)
if ($expectedplatform -ne "" -and $fields.ContainsKey('target') -and $fields['target'] -ne $expectedplatform) {
$parts += "!TARGET-MISMATCH:$($fields['target'])"
}
if ($parts.Count -eq 0) {
return ""
}
return "[" + ($parts -join " ") + "]"
}
#G-117 record parsing (schema-tolerant): quoted-string / bare integer / true|false
#value lines only; '#' comments and unknown fields are ignored by construction, and
#an absent schema line simply yields no 'schema' key (pre-v1 records keep working).
function Get-PunkRuntimeRecordFields {
param([string[]] $recordlines)
$fields = @{}
foreach ($line in $recordlines) {
$m = [regex]::Match($line, '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"(.*)"\s*$')
if ($m.Success) {
if (-not $fields.ContainsKey($m.Groups[1].Value)) { $fields[$m.Groups[1].Value] = $m.Groups[2].Value }
continue
}
$m = [regex]::Match($line, '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([0-9]+|true|false)\s*$')
if ($m.Success) {
if (-not $fields.ContainsKey($m.Groups[1].Value)) { $fields[$m.Groups[1].Value] = $m.Groups[2].Value }
}
}
return $fields
}
#G-117 embedded record: zip CENTRAL-DIRECTORY read of the exe-appended archive via
#.NET System.IO.Compression (offsets resolve from the end-of-central-directory
#record, so the prepended executable data is no obstacle). The target is NEVER
#executed - identifying an untrusted stray binary by running it is exactly what an
#identity mechanism should avoid; the cooperative probe (piping a reader script
#into the EXECUTED target) is the documented fallback for hosts without a zip
#reader, with that execution caveat. Returns the record text, or $null when the
#archive has no punkbin-artifact.toml (pre-v1 artifact / not a family runtime).
function Get-PunkRuntimeEmbeddedRecord {
param([string] $Path)
#.NET's ZipArchive does NOT apply the self-extractor offset fixup Info-ZIP
#applies: an exe-appended archive's central-directory offsets are relative to
#the ZIP START, not the file start, and OpenRead on the exe throws. Locate the
#end-of-central-directory record ourselves, compute the prepend length (actual
#CD position minus recorded offset), copy the pure-zip tail to a temp file and
#let .NET read that (deflate/stored handling for free). Works for plain zips
#too (delta 0). No zip64 handling - family images are far below those limits.
try { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop } catch { }
$fullpath = (Resolve-Path -LiteralPath $Path).ProviderPath
$tmpzip = $null
$zip = $null
try {
$fs = [System.IO.File]::OpenRead($fullpath)
try {
$flen = $fs.Length
if ($flen -lt 22) { return $null }
#EOCD signature scan in the trailing 64KB+22 (max comment length)
$scanlen = [Math]::Min($flen, 65557 + 22)
$fs.Seek($flen - $scanlen, [System.IO.SeekOrigin]::Begin) | Out-Null
$buf = New-Object byte[] $scanlen
$read = 0
while ($read -lt $scanlen) {
$n = $fs.Read($buf, $read, $scanlen - $read)
if ($n -le 0) { break }
$read += $n
}
$eocdrel = -1
for ($i = $read - 22; $i -ge 0; $i--) {
if ($buf[$i] -eq 0x50 -and $buf[$i+1] -eq 0x4b -and $buf[$i+2] -eq 0x05 -and $buf[$i+3] -eq 0x06) {
$eocdrel = $i
break
}
}
if ($eocdrel -lt 0) { return $null }
$eocdabs = ($flen - $scanlen) + $eocdrel
$cdsize = [System.BitConverter]::ToUInt32($buf, $eocdrel + 12)
$cdoffset = [System.BitConverter]::ToUInt32($buf, $eocdrel + 16)
$zipstart = $eocdabs - $cdsize - $cdoffset
if ($zipstart -lt 0) { return $null }
$tmpzip = [System.IO.Path]::GetTempFileName()
$out = [System.IO.File]::Open($tmpzip, [System.IO.FileMode]::Create)
try {
$fs.Seek($zipstart, [System.IO.SeekOrigin]::Begin) | Out-Null
$fs.CopyTo($out)
} finally {
$out.Dispose()
}
} finally {
$fs.Dispose()
}
$zip = [System.IO.Compression.ZipFile]::OpenRead($tmpzip)
foreach ($entry in $zip.Entries) {
if ($entry.FullName -eq 'punkbin-artifact.toml') {
$sr = New-Object System.IO.StreamReader($entry.Open())
try { return $sr.ReadToEnd() } finally { $sr.Dispose() }
}
}
return $null
} catch {
return $null
} finally {
if ($zip) { $zip.Dispose() }
if ($tmpzip -and (Test-Path -LiteralPath $tmpzip)) {
Remove-Item -LiteralPath $tmpzip -Force -ErrorAction SilentlyContinue
}
}
}
#G-119 freshness verdict: the relationship between the ACTIVE runtime and the
#SERVER DEFAULT - current/ahead/behind/incomparable - so an operator is never
#silently ahead of or behind the curated recommendation. Revision basis:
#-r<N> parsed from an artifact-named active, else the beside-toml's revision
#field (materialized working copies); the default's -r<N> parses from its
#defaults.txt name. Actives with no revision basis (pre-family) fall back to
#sha1 VALUE-matching against the server listing (a byte-copy of a server
#artifact is identified by content). Prints nothing when active and default
#are the same identity - callers annotate that as '(= server default)'.
#Keep verdict lines in parity with the bash payload's freshness_verdict.
#mode 'list': multi-line (update guidance on behind, current-verdict lines)
#mode 'fetch': one-line divergence note only (current-class results stay silent)
function Write-PunkFreshnessVerdict {
param(
[string] $archfolder,
[string] $activename,
[string] $defaultname,
[string] $sha1local,
[string] $arch,
[string] $mode
)
if ($activename -eq "" -or $defaultname -eq "") { return }
$activefile = Join-Path -Path $archfolder -ChildPath $activename
if (-not (Test-Path -Path $activefile -PathType Leaf)) { return }
$activetoml = Join-Path -Path $archfolder -ChildPath ((Get-PunkRuntimeRootName $activename) + ".toml")
$active_artifact = ""
$toml_revision = ""
if (Test-Path -Path $activetoml -PathType Leaf) {
foreach ($tline in (Get-Content -Path $activetoml)) {
$m = [regex]::Match($tline, '^\s*name\s*=\s*"(.*)"\s*$')
if ($m.Success -and $active_artifact -eq "") { $active_artifact = $m.Groups[1].Value }
$m = [regex]::Match($tline, '^\s*revision\s*=\s*([0-9]+)\s*$')
if ($m.Success -and $toml_revision -eq "") { $toml_revision = $m.Groups[1].Value }
}
}
if ($activename -eq $defaultname -or $active_artifact -eq $defaultname) { return }
$platsuffix = ""
if ($arch -ne $script:PunkLocalPlatform) { $platsuffix = " -platform $arch" }
$arev = ""; $aseries = ""
$am = [regex]::Match($activename, '^(.*)-r([0-9]+)(\.[Ee][Xx][Ee])?$')
if ($am.Success) {
$arev = $am.Groups[2].Value
$aseries = $am.Groups[1].Value + $am.Groups[3].Value
} elseif ($toml_revision -ne "") {
$arev = $toml_revision
$aseries = $activename
}
$drev = ""; $dseries = ""
$dm = [regex]::Match($defaultname, '^(.*)-r([0-9]+)(\.[Ee][Xx][Ee])?$')
if ($dm.Success) {
$drev = $dm.Groups[2].Value
$dseries = $dm.Groups[1].Value + $dm.Groups[3].Value
}
if ($drev -eq "") {
#pre-family default name: no revision to compare - identity check only
$asha = ""; $dsha = ""
if (Test-Path -Path $sha1local -PathType Leaf) {
$asha = Get-PunkFileSha1 -Path $activefile
foreach ($line in (Get-Content -Path $sha1local)) {
$m = [regex]::Match($line, "(.*) [*]${defaultname}$")
if ($m.Success) { $dsha = $m.Groups[1].Value.Trim(); break }
}
}
if ($asha -ne "" -and $asha -eq $dsha) {
if ($mode -eq 'list') { write-host "freshness: active $activename is content-identical to the server default $defaultname" }
} else {
write-host "freshness: server default $defaultname carries no -r<N> revision - no revision comparison with active $activename"
}
return
}
if ($arev -ne "") {
if ($aseries -eq $dseries) {
if ([int]$arev -gt [int]$drev) {
write-host "freshness: active $activename (r$arev) is AHEAD of the server default $defaultname (r$drev)"
} elseif ([int]$arev -lt [int]$drev) {
if ($mode -eq 'fetch') {
write-host "freshness: active $activename (r$arev) is BEHIND the server default $defaultname (r$drev) - activate it with: use $defaultname"
} else {
write-host "freshness: active $activename (r$arev) is BEHIND the server default $defaultname (r$drev)"
write-host " update: punk-runtime.cmd fetch$platsuffix then: punk-runtime.cmd use $defaultname$platsuffix"
}
} else {
if ($mode -eq 'list') { write-host "freshness: active $activename matches the server default revision (r$arev)" }
}
} else {
write-host "freshness: active $activename (r$arev) and the server default $defaultname (r$drev) are different runtime series - no revision comparison"
}
return
}
#no active revision basis (pre-family): sha1 VALUE-match against the server
#listing identifies a byte-copy of a server artifact
$vmatch = ""
$asha = ""
if (Test-Path -Path $sha1local -PathType Leaf) {
$asha = Get-PunkFileSha1 -Path $activefile
}
if ($asha -ne "") {
$dsha = ""
$valdict = @{}
foreach ($line in (Get-Content -Path $sha1local)) {
$m = [regex]::Match($line, "(.*) [*](.*)$")
if ($m.Success) {
$n = $m.Groups[2].Value.Trim()
if ($n -eq "") { continue }
if ($n -eq $defaultname) { $dsha = $m.Groups[1].Value.Trim() }
$valdict[$n] = $m.Groups[1].Value.Trim()
}
}
if ($dsha -ne "" -and $asha -eq $dsha) {
if ($mode -eq 'list') { write-host "freshness: active $activename is content-identical to the server default $defaultname" }
return
}
#deterministic pick: ordinal-first value match, support files
#(.txt/.toml/...) filtered before the pick (bash-payload parity)
$names = @($valdict.Keys)
[array]::Sort($names, [System.StringComparer]::Ordinal)
foreach ($n in $names) {
if (Test-PunkRuntimeSupportName $n) { continue }
if ($valdict[$n] -eq $asha) { $vmatch = $n; break }
}
}
if ($vmatch -ne "") {
$mrev = ""; $mseries = ""
$mm = [regex]::Match($vmatch, '^(.*)-r([0-9]+)(\.[Ee][Xx][Ee])?$')
if ($mm.Success) {
$mrev = $mm.Groups[2].Value
$mseries = $mm.Groups[1].Value + $mm.Groups[3].Value
}
if ($mrev -ne "" -and $mseries -eq $dseries) {
if ([int]$mrev -gt [int]$drev) {
write-host "freshness: active $activename is server artifact $vmatch by content (r$mrev) - AHEAD of the server default $defaultname (r$drev)"
} elseif ([int]$mrev -lt [int]$drev) {
if ($mode -eq 'fetch') {
write-host "freshness: active $activename is server artifact $vmatch by content (r$mrev) - BEHIND the server default $defaultname (r$drev) - activate it with: use $defaultname"
} else {
write-host "freshness: active $activename is server artifact $vmatch by content (r$mrev) - BEHIND the server default $defaultname (r$drev)"
write-host " update: punk-runtime.cmd fetch$platsuffix then: punk-runtime.cmd use $defaultname$platsuffix"
}
} else {
if ($mode -eq 'list') { write-host "freshness: active $activename is content-identical to server artifact $vmatch (the server default revision r$mrev)" }
}
} else {
write-host "freshness: active $activename is server artifact $vmatch by content - a different runtime series than the server default $defaultname (no revision comparison)"
}
} else {
write-host "freshness: active $activename has no revision basis (no -r<N> name, no metadata toml revision, sha1 matches no server artifact) - cannot compare with the server default $defaultname (r$drev)"
}
}
function psmain {
[CmdletBinding()]
#Empty param block (extra params can be added)
param(
[Parameter(Mandatory=$false, Position = 0)][string] $action = ""
)
dynamicparam {
if ($action -eq 'list' -or $action -eq 'platforms') {
#shared dynamic params: -remote for both; -platform is meaningful to
#'list' and ignored by 'platforms' (which enumerates ALL platforms)
$parameterAttribute = [System.Management.Automation.ParameterAttribute]@{
ParameterSetName = "listruntime"
Mandatory = $false
}
$attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attributeCollection.Add($parameterAttribute)
$dynParam1 = [System.Management.Automation.RuntimeDefinedParameter]::new(
'remote', [switch], $attributeCollection
)
#-platform (G-105 cross-build staging): punkbin platform-dir name to list for
$platformAttribute = [System.Management.Automation.ParameterAttribute]@{
ParameterSetName = "listruntime"
Mandatory = $false
}
$platformAttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$platformAttributeCollection.Add($platformAttribute)
$dynParam2 = [System.Management.Automation.RuntimeDefinedParameter]::new(
'platform', [string], $platformAttributeCollection
)
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$paramDictionary.Add('remote', $dynParam1)
$paramDictionary.Add('platform', $dynParam2)
return $paramDictionary
} elseif ($action -eq 'fetch' -or $action -eq 'use' -or $action -eq 'info') {
#GetDynamicParamDictionary ParameterDefinitions
$parameterAttribute = [System.Management.Automation.ParameterAttribute]@{
ParameterSetName = "fetchruntime"
Mandatory = $false
Position = 1
}
$attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attributeCollection.Add($parameterAttribute)
$dynParam1 = [System.Management.Automation.RuntimeDefinedParameter]::new(
'runtime', [string], $attributeCollection
)
#-platform (G-105 cross-build staging): punkbin platform-dir name to fetch
#into / select within (default: the local platform)
$platformAttribute = [System.Management.Automation.ParameterAttribute]@{
ParameterSetName = "fetchruntime"
Mandatory = $false
}
$platformAttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$platformAttributeCollection.Add($platformAttribute)
$dynParam2 = [System.Management.Automation.RuntimeDefinedParameter]::new(
'platform', [string], $platformAttributeCollection
)
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$paramDictionary.Add('runtime', $dynParam1)
$paramDictionary.Add('platform', $dynParam2)
return $paramDictionary
} elseif ($action -eq 'run') {
#GetDynamicParamDictionary ParameterDefinitions
$parameterAttribute = [System.Management.Automation.ParameterAttribute]@{
ParameterSetName = "runargs"
Mandatory = $false
ValueFromRemainingArguments = $true
}
$attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attributeCollection.Add($parameterAttribute)
$dynParam1 = [System.Management.Automation.RuntimeDefinedParameter]::new(
'opts', [string[]], $attributeCollection
)
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$paramDictionary.Add('opts', $dynParam1)
return $paramDictionary
} else {
#accept all args when action is unrecognised - so we can go to help anyway
$parameterAttribute = [System.Management.Automation.ParameterAttribute]@{
ParameterSetName = "invalidaction"
Mandatory = $false
ValueFromRemainingArguments = $true
}
$attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attributeCollection.Add($parameterAttribute)
$dynParam1 = [System.Management.Automation.RuntimeDefinedParameter]::new(
'opts', [string[]], $attributeCollection
)
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$paramDictionary.Add('opts', $dynParam1)
return $paramDictionary
}
}
process {
#Called once - we get a single item being our PSBoundParameters dictionary
#write-host "Bound Parameters:$($PSBoundParameters.Keys)"
switch ($PSBoundParameters.keys) {
'action' {
write-host "got action " $PSBoundParameters.action
Set-Variable -Name $_ -Value $PSBoundParameters."$_"
$known_actions = @("fetch", "list", "use", "run", "info", "platforms", "help")
if (-not($known_actions -contains $action)) {
write-host "action '$action' not understood. Known_actions: $known_actions"
exit 1
}
}
'opts' {
# write-warning "Unused parameters: $($PSBoundParameters.$_)"
}
Default {
# write-warning "Unhandled parameter -> [$($_)]"
}
}
#foreach ($boundparam in $PSBoundParameters.Keys) {
# write-host "k: $boundparam"
#}
}
end {
# PSBoundParameters
#write-host "action:'$action'"
$outbase = $PSScriptRoot
$outbase = Resolve-Path -Path $outbase
#expected script location is the bin folder of a punk project
$rtfolder = Join-Path -Path $outbase -ChildPath "runtime"
#Binary artifact server url. (git is not ideal for this - but will do for now - todo - use artifact system within gitea?)
#overridable for mirrors/testing - keep in sync with the bash payload
$artifacturl = "https://www.gitea1.intx.com.au/jn/punkbin/raw/branch/master"
if ($env:PUNKBIN_URL) {
$artifacturl = $env:PUNKBIN_URL
}
switch ($action) {
'fetch' {
$arch = Resolve-PunkRuntimePlatform $PSBoundParameters["platform"]
$archfolder = Join-Path -Path $rtfolder -ChildPath "$arch"
$archurl = "$artifacturl/$arch"
$sha1url = "$archurl/sha1sums.txt"
foreach ($boundparam in $PSBoundParameters.Keys) {
write-host "fetchopt: $boundparam $($PSBoundParameters[$boundparam])"
}
$runtime = ""
$fetch_default = ""
if ( $PSBoundParameters["runtime"].Length ) {
$runtime = $PSBoundParameters["runtime"]
} else {
#no name given: consult the server's curated defaults.txt - the
#punkbin release recommendation (updated there as part of each
#publication change-set; see punkbin AGENTS.md). Per-platform
#server data, so it works for any -platform, not just local.
$defaultsurl = "$artifacturl/defaults.txt"
$defaultslocal = Join-Path -Path $rtfolder -ChildPath "defaults.txt"
if (-not (Test-Path -Path $rtfolder -PathType Container)) {
new-item -Path $rtfolder -ItemType Directory -force | out-null
}
try {
Invoke-WebRequest -Uri $defaultsurl -OutFile $defaultslocal -ErrorAction Stop
} catch {
if (Test-Path -Path $defaultslocal -PathType Leaf) {
Write-Host "WARNING: could not fetch ${defaultsurl}: $($_.Exception.Message)"
Write-Host "WARNING: using cached copy at $defaultslocal"
} else {
Write-Host "Could not fetch ${defaultsurl}: $($_.Exception.Message)"
Write-Host "No server default available - name a runtime explicitly:"
Write-Host " punk-runtime.cmd fetch <runtimename> ?-platform ${arch}?"
Write-Host " (see available names: punk-runtime.cmd list -remote -platform $arch)"
exit 1
}
}
foreach ($line in (Get-Content -Path $defaultslocal)) {
$line = $line.Trim()
if ($line -eq "" -or $line.StartsWith("#")) { continue }
$parts = -split $line
if ($parts.Count -ge 2 -and $parts[0] -eq $arch) {
$runtime = $parts[1]
break
}
}
if ($runtime -eq "") {
Write-Host "No default recorded for platform '$arch' in the server's defaults.txt - name a runtime explicitly:"
Write-Host " punk-runtime.cmd fetch <runtimename> ?-platform ${arch}?"
Write-Host " (see available names: punk-runtime.cmd list -remote -platform $arch)"
exit 1
}
Write-Host "using server default for ${arch}: $runtime"
$fetch_default = $runtime
}
$fileurl = "$archurl/$runtime"
$output = join-path -Path $archfolder -ChildPath $runtime
$sha1local = join-path -Path $archfolder -ChildPath "sha1sums.txt"
$container = split-path -Path $output -Parent
new-item -Path $container -ItemType Directory -force #create with intermediary folders if not already present
try {
Write-Host "Fetching $sha1url"
Invoke-WebRequest -Uri $sha1url -OutFile $sha1local -ErrorAction Stop
Write-Host "sha1 saved at $sha1local"
} catch {
Write-Host "An error occurred while downloading ${sha1url}: $($_.Exception.Message)"
if ($_.Exception.Response) {
Write-Host "HTTP Status code: $($_.Exception.Response.StatusCode)"
}
if (Test-Path -Path $sha1local -PathType Leaf) {
#this path always fell through to the cached copy - now say so
Write-Host "WARNING: proceeding with cached copy at $sha1local"
}
}
if (Test-Path -Path $sha1local -PathType Leaf) {
$sha1Content = Get-Content -Path $sha1local
$stored_sha1 = ""
foreach ($line in $sha1Content) {
#all sha1sums have * (binary indicator) - review
$match = [regex]::Match($line,"(.*) [*]${runtime}$")
if ($match.Success) {
$stored_sha1 = $match.Groups[1].Value
Write-host "stored hash from sha1sums.txt: $stored_sha1"
break
}
}
if ($stored_sha1 -eq "") {
Write-Host "Unable to locate hash for $runtime in $sha1local - Aborting"
Write-Host "Please download and verify manually"
return
}
$need_download = $false
if (Test-Path -Path $output -PathType Leaf) {
Write-Host "Runtime already found at $output"
Write-Host "Checking sha1 checksum of local file versus sha1 of server file"
$file_sha1 = Get-PunkFileSha1 -Path "$output"
if ($file_sha1 -ne $stored_sha1) {
Write-Host "$runtime on server has different sha1 hash - Download required"
$need_download = $true
}
} else {
Write-Host "$runtime not found locally - Download required"
$need_download = $true
}
if ($need_download) {
Write-Host "Downloading from $fileurl ..."
try {
Invoke-WebRequest -Uri $fileurl -OutFile "${output}.tmp" -ErrorAction Stop
Write-Host "Runtime saved at $output.tmp"
}
catch {
Write-Host "An error occurred while downloading $fileurl $($_.Exception.Message)"
if ($_.Exception.Response) {
Write-Host "HTTP Status code: $($_.Exception.Response.StatusCode)"
}
return
}
Write-Host "comparing sha1 checksum of downloaded file with data in sha1sums.txt"
Start-Sleep -Seconds 1 #REVIEW - give at least some time for windows to do its thing? (av filters?)
$newfile_sha1 = Get-PunkFileSha1 -Path "${output}.tmp"
if ($newfile_sha1 -eq $stored_sha1) {
Write-Host "sha1 checksum ok"
Move-Item -Path "${output}.tmp" -Destination "${output}" -Force
Write-Host "Runtime is available at ${output}"
} else {
Write-Host "WARNING! sha1 of downloaded file at $output.tmp does not match stored sha1 from sha1sums.txt"
return
}
} else {
Write-Host "Local copy of runtime at $output seems to match sha1 checksum of file on server."
Write-Host "No download required"
}
#G-103: family artifacts (-r<N> names) carry a metadata toml alongside
#on the server - fetch it too; absence is fine (pre-family runtimes)
if ($runtime -match '^(.*)-r([0-9]+)(\.[Ee][Xx][Ee])?$') {
$tomlname = (Get-PunkRuntimeRootName $runtime) + ".toml"
$tomlurl = "$archurl/$tomlname"
$tomllocal = Join-Path -Path $archfolder -ChildPath $tomlname
try {
Invoke-WebRequest -Uri $tomlurl -OutFile $tomllocal -ErrorAction Stop
Write-Host "artifact metadata saved at $tomllocal"
} catch {
Write-Host "no artifact metadata toml on server for $runtime (ok for pre-family runtimes)"
}
}
#first fetch establishes the active runtime; later fetches never steal it
if ((Get-PunkActiveRuntime $archfolder) -eq "") {
Set-PunkActiveRuntime $archfolder $runtime
}
#G-119: a no-name fetch is a request for "the default" - when the
#ACTIVE runtime diverges from it, say so (one line) instead of silence
if ($fetch_default -ne "") {
Write-PunkFreshnessVerdict -archfolder $archfolder -activename (Get-PunkActiveRuntime $archfolder) -defaultname $fetch_default -sha1local $sha1local -arch $arch -mode 'fetch'
}
} else {
Write-Host "Unable to consult local copy of sha1sums.txt at $sha1local"
if (Test-Path -Path $output -PathType Leaf) {
Write-Host "A runtime is available at $output - but we failed to retrieve the list of sha1sums from the server"
Write-Host "Unable to check for updated version at this time."
} else {
Write-Host "Please retry - or manually download a runtime from $archurl and verify checksums"
}
}
}
'use' {
#select the active runtime for subsequent 'run' calls. With -platform:
#manage THAT platform folder's selection/materialization (cross-build
#staging - the active.toml travels with the folder when deployed; 'run'
#on this machine only ever consults the local platform).
$arch = Resolve-PunkRuntimePlatform $PSBoundParameters["platform"]
$archfolder = Join-Path -Path $rtfolder -ChildPath "$arch"
$candidates = Get-PunkRuntimeCandidates $archfolder
$rtname = ""
if ( $PSBoundParameters["runtime"].Length ) {
$rtname = $PSBoundParameters["runtime"]
}
if ($rtname -eq "") {
write-host "Usage: punk-runtime.cmd use <runtimename> ?-platform <p>?"
write-host "Installed candidates ($arch):"
foreach ($f in $candidates) {
write-host " $($f.Name)"
}
$current = Get-PunkActiveRuntime $archfolder
if ($current -ne "") {
write-host "Currently active: $current"
}
exit 1
}
if (-not ($candidates | Where-Object Name -eq $rtname)) {
#accept the name without its .exe suffix (root-name convention: G-103
#name handling strips only .exe, so the suffixless spelling is
#unambiguous; exact names - incl. extensionless unix artifacts in
#foreign -platform folders - are matched first above)
if ($candidates | Where-Object Name -eq ($rtname + ".exe")) {
$rtname = $rtname + ".exe"
}
}
if (-not ($candidates | Where-Object Name -eq $rtname)) {
write-host "No runtime named '$rtname' found in $archfolder"
write-host "Installed candidates:"
foreach ($f in $candidates) {
write-host " $($f.Name)"
}
exit 1
}
#G-103 artifact-tier names (-r<N>, immutable): 'use' MATERIALIZES the
#artifact into its WORKING name (name minus -r<N> - what mapvfs and
#projects reference), copies its metadata toml alongside, and selects
#the working name. Republishing artifacts never churns consumers.
$am = [regex]::Match($rtname, '^(.*)-r([0-9]+)(\.[Ee][Xx][Ee])?$')
if ($am.Success) {
$working = $am.Groups[1].Value + $am.Groups[3].Value
$srcexe = Join-Path -Path $archfolder -ChildPath $rtname
$destexe = Join-Path -Path $archfolder -ChildPath $working
Copy-Item -Path $srcexe -Destination $destexe -Force
$srctoml = Join-Path -Path $archfolder -ChildPath ((Get-PunkRuntimeRootName $rtname) + ".toml")
$desttoml = Join-Path -Path $archfolder -ChildPath ((Get-PunkRuntimeRootName $working) + ".toml")
if (Test-Path -Path $srctoml -PathType Leaf) {
Copy-Item -Path $srctoml -Destination $desttoml -Force
write-host "materialized $working from artifact $rtname (metadata toml copied alongside)"
} else {
write-host "materialized $working from artifact $rtname (no metadata toml found beside the artifact)"
}
Set-PunkActiveRuntime $archfolder $working
} else {
Set-PunkActiveRuntime $archfolder $rtname
}
}
'run' {
#launch the active runtime, passing arguments.
#resolution order: PUNK_ACTIVE_RUNTIME env override, active.toml, single
#installed candidate - otherwise error with candidates (no last-in-list guessing).
#LOCAL PLATFORM ONLY - a foreign platform's binaries are not runnable here.
#Only a LEADING -platform is rejected: everything after 'run' belongs to
#the runtime, so a later arg spelled -platform must pass through untouched.
if ($PSBoundParameters.opts.Length -gt 0 -and $PSBoundParameters.opts[0] -eq '-platform') {
write-host "'run' launches the LOCAL platform's active runtime - it takes no -platform option"
write-host "(foreign-platform folders are cross-build staging; deploy them to their platform to run)"
exit 1
}
$arch = $script:PunkLocalPlatform
$archfolder = Join-Path -Path $rtfolder -ChildPath "$arch"
if (-not(Test-Path -Path $archfolder -PathType Container)) {
write-host "No runtimes seem to be installed for $arch`nPlease use 'punk-runtime.cmd fetch' to install"
} else {
$dircontents = Get-PunkRuntimeCandidates $archfolder
if ($dircontents.Count -gt 0) {
#write-host "run.."
write-host "num params: $($PSBoundParameters.opts.count)"
$activename = ""
if ($env:PUNK_ACTIVE_RUNTIME) {
$activename = $env:PUNK_ACTIVE_RUNTIME
if (-not (Test-Path -Path (Join-Path -Path $archfolder -ChildPath $activename) -PathType Leaf)) {
write-host "PUNK_ACTIVE_RUNTIME '$activename' not found in $archfolder"
exit 1
}
} else {
$activename = Get-PunkActiveRuntime $archfolder
if ($activename -ne "" -and -not (Test-Path -Path (Join-Path -Path $archfolder -ChildPath $activename) -PathType Leaf)) {
write-host "active runtime '$activename' (from active.toml) is not present in $archfolder"
write-host "Reselect with: punk-runtime.cmd use <name> (or fetch it: punk-runtime.cmd fetch $activename)"
exit 1
}
if ($activename -eq "") {
if ($dircontents.Count -eq 1) {
$activename = $dircontents[0].Name
} else {
write-host "Multiple runtimes installed and no active runtime selected."
write-host "Select one with: punk-runtime.cmd use <name>"
write-host "Installed candidates:"
foreach ($f in $dircontents) {
write-host " $($f.Name)"
}
exit 1
}
}
}
$active = Join-Path -Path $archfolder -ChildPath $activename
write-host "using: $active"
if ($PSBoundParameters.opts.Length -gt 0) {
$optsType = $PSBoundParameters.opts.GetType() #method can only be called if .opts is not null
write-host "type of opts: $($optsType.FullName)"
foreach ($boundparam in $PSBoundParameters.opts) {
write-host $boundparam
}
Write-Host "opts: $($PSBoundParameters.opts)"
Write-Host "args: $args"
Write-HOst "argscount: $($args.Count)"
$arglist = @()
foreach ($o in $PSBoundParameters.opts) {
$oquoted = $o -replace '"', "`\`""
#$oquoted = $oquoted -replace "'", "`'"
if ($oquoted -match "\s") {
$oquoted = "`"$oquoted`""
}
$arglist += @($oquoted)
}
$arglist = $arglist.TrimEnd(' ')
write-host "arglist: $arglist"
#$arglist = $PSBoundParameters.opts
Start-Process -FilePath $active -ArgumentList $arglist -NoNewWindow -Wait
} else {
#powershell 5.1 and earlier can't accept an empty -ArgumentList value :/ !!
#$arglist = @()
#Start-Process -FilePath $active -ArgumentList $arglist -NoNewWindow -Wait
#Start-Process -FilePath $active -ArgumentList "" -NoNewWindow -Wait
Start-Process -FilePath $active -NoNewWindow -Wait
}
} else {
write-host "No files found in $archfolder"
write-host "No runtimes seem to be installed for $arch`nPlease use 'punk-runtime.cmd fetch' to install."
}
}
}
'list' {
#-platform lists another platform's folder/server dir (cross-build staging)
$arch = Resolve-PunkRuntimePlatform $PSBoundParameters["platform"]
$archfolder = Join-Path -Path $rtfolder -ChildPath "$arch"
$sha1local = join-path -Path $archfolder -ChildPath "sha1sums.txt"
$archurl = "$artifacturl/$arch"
$sha1url = "$archurl/sha1sums.txt"
if ( $PSBoundParameters.ContainsKey('remote') ) {
if (-not (test-path -Path $archfolder -Type Container)) {
new-item -Path $archfolder -ItemType Directory -force #create with intermediary folders if not already present
}
write-host "Checking for available remote runtimes for $arch"
Write-Host "Fetching $sha1url"
#cached fallback (parity with the bash payload): an unreachable server
#falls back to a previously fetched sha1sums.txt with a warning
try {
Invoke-WebRequest -Uri $sha1url -OutFile $sha1local -ErrorAction Stop
Write-Host "sha1 saved at $sha1local"
} catch {
if (Test-Path -Path $sha1local -PathType Leaf) {
Write-Host "WARNING: could not fetch ${sha1url}: $($_.Exception.Message)"
Write-Host "WARNING: using cached copy at $sha1local"
} else {
Write-Host "Unable to fetch $sha1url and no cached copy available: $($_.Exception.Message)"
if ($_.Exception.Response) {
Write-Host "HTTP Status code: $($_.Exception.Response.StatusCode)"
}
return
}
}
$sha1Content = Get-Content -Path $sha1local
$remotedict = @{}
foreach ($line in $sha1Content) {
#all sha1sums have * (binary indicator) - review
$match = [regex]::Match($line,"(.*) [*](.*)$")
if ($match.Success) {
$server_sha1 = $match.Groups[1].Value
$server_rt = $match.Groups[2].Value
$remotedict[$server_rt] = $server_sha1
}
}
$localdict = @{}
if (test-path -Path $archfolder -Type Container) {
#shared candidate filter (bash payload parity - its -remote loop
#already uses list_candidates): excludes directories, build
#copies and .txt/.toml/.tm/.tmp/.log support files, so
#active.toml / metadata tomls / stray logs never show as
#local runtimes in the comparison
$dircontents = Get-PunkRuntimeCandidates $archfolder
foreach ($f in $dircontents) {
$local_sha1 = Get-PunkFileSha1 -Path $(${f}.FullName)
$localdict[$f.Name] = $local_sha1
}
}
#server-default + local-active surfacing (G-103): the server's
#curated defaults.txt names the recommended runtime per platform -
#best-effort fetch (cached/absent both fine for a listing), then
#mark the matching row and the locally active one so "is my
#active the recommended default?" is answerable at a glance.
$activename = Get-PunkActiveRuntime $archfolder
$platform_default = ""
$defaultslocal = Join-Path -Path $rtfolder -ChildPath "defaults.txt"
try {
Invoke-WebRequest -Uri "$artifacturl/defaults.txt" -OutFile $defaultslocal -ErrorAction Stop
} catch { }
if (Test-Path -Path $defaultslocal -PathType Leaf) {
foreach ($line in (Get-Content -Path $defaultslocal)) {
$line = $line.Trim()
if ($line -eq "" -or $line.StartsWith("#")) { continue }
$parts = -split $line
if ($parts.Count -ge 2 -and $parts[0] -eq $arch) {
$platform_default = $parts[1]
break
}
}
}
Write-host "----------------------------------------------------------------------------------------"
Write-host "Runtimes for $arch"
Write-host "Local $archfolder"
Write-host "Remote $archurl"
if ($platform_default -ne "") {
Write-host "server default for ${arch}: $platform_default"
}
if ($activename -ne "") {
#the default names an immutable -r<N> ARTIFACT while the active
#is typically its materialized WORKING name - the beside-toml's
#'name' field records which artifact the working copy came from,
#so match on either identity
$active_artifact = ""
$activetoml = Join-Path -Path $archfolder -ChildPath ((Get-PunkRuntimeRootName $activename) + ".toml")
if (Test-Path -Path $activetoml -PathType Leaf) {
foreach ($tline in (Get-Content -Path $activetoml)) {
$m = [regex]::Match($tline, '^\s*name\s*=\s*"(.*)"\s*$')
if ($m.Success) { $active_artifact = $m.Groups[1].Value; break }
}
}
$matchnote = ""
if ($platform_default -ne "" -and ($activename -eq $platform_default -or $active_artifact -eq $platform_default)) {
$matchnote = " (= server default)"
}
Write-host "active (local): $activename$matchnote"
#G-119 freshness verdict: current/ahead/behind/incomparable when
#the active is not the default identity (silent when it is - the
#annotation above already says so)
Write-PunkFreshnessVerdict -archfolder $archfolder -activename $activename -defaultname $platform_default -sha1local $sha1local -arch $arch -mode 'list'
}
Write-host "----------------------------------------------------------------------------------------"
Write-host (" {0,-35} {1,-33} {2}" -f "Local", "", "Remote")
Write-host "----------------------------------------------------------------------------------------"
# 12345678910234567892023456789302345
$G = "`e[32m" #Green
$Y = "`e[33m" #Yellow
$R = "`e[31m" #Red
$RST = "`e[m"
#explicit ORDINAL sort: Hashtable key enumeration order is
#undefined (and differs between the powershell editions), and
#culture-sensitive Sort-Object collates differently too (NLS vs
#ICU) - ordinal agrees across editions and with bash LC_ALL=C
$localkeys = @($localdict.Keys)
[array]::Sort($localkeys, [System.StringComparer]::Ordinal)
foreach ($key in $localkeys) {
$local_sha1 = $($localdict[$key])
$mid = ""
$ident = ""
if ($remotedict.ContainsKey($key)) {
if ($local_sha1 -eq $remotedict[$key]) {
$rhs = "Same version"
$C = $G
} else {
$rhs = "UPDATE AVAILABLE"
$C = $Y
}
} else {
$C = $R
#G-119 refinement: identity middle column for rows the server
#listing does not name. A materialized working copy is
#identified by its beside-toml source artifact (from=) with
#its CONTENT verified against that artifact's server sha1; a
#tomlless stray by sha1 VALUE-match (sha1=). The Remote
#column then carries a real server relationship for such
#rows instead of the dead-end '(not listed on server)',
#which now means genuinely unknown.
$rowtoml = Join-Path -Path $archfolder -ChildPath ((Get-PunkRuntimeRootName $key) + ".toml")
$artname = ""
if (Test-Path -Path $rowtoml -PathType Leaf) {
foreach ($tline in (Get-Content -Path $rowtoml)) {
$m = [regex]::Match($tline, '^\s*name\s*=\s*"(.*)"\s*$')
if ($m.Success) { $artname = $m.Groups[1].Value; break }
}
if ($artname -eq $key) { $artname = "" }
}
if ($artname -ne "") {
$mid = "from=$artname"
$ident = $artname
if (-not $remotedict.ContainsKey($artname)) {
$rhs = "(artifact not listed on server)"
} elseif ($local_sha1 -eq $remotedict[$artname]) {
$rhs = "Same version"
} else {
#-r<N> artifacts are immutable - differing content
#means a modified local copy or a mismatched
#sidecar, not an update
$rhs = "CONTENT DIFFERS"
}
} else {
#sha1 value-match: the default's row first, else the
#first ordinal match with support files excluded - the
#same pick rule as Write-PunkFreshnessVerdict, so the
#row identity and the freshness line always name the
#same artifact
$vname = ""
if ($platform_default -ne "" -and $remotedict.ContainsKey($platform_default) -and $remotedict[$platform_default] -eq $local_sha1) {
$vname = $platform_default
}
if ($vname -eq "") {
$rnames = @($remotedict.Keys)
[array]::Sort($rnames, [System.StringComparer]::Ordinal)
foreach ($rn in $rnames) {
if (Test-PunkRuntimeSupportName $rn) { continue }
if ($remotedict[$rn] -eq $local_sha1) { $vname = $rn; break }
}
}
if ($vname -ne "") {
$mid = "sha1=$vname"
$ident = $vname
$rhs = "Same version"
} else {
$rhs = "(not listed on server)"
}
}
}
#ansi problems from cmd.exe not in windows terminal - review
$C = ""
$RST = ""
$mark = " "
if ($key -eq $activename) { $mark = "* " }
$annot = ""
#the (server default) annotation follows artifact identity: a
#working copy of (or byte-copy of) the default artifact is the
#default
if ($platform_default -ne "" -and ($key -eq $platform_default -or $ident -eq $platform_default)) { $annot = " (server default)" }
$lhs = "$key".PadRight(35, ' ')
$midp = "$mid".PadRight(33, ' ')
write-host -nonewline "${mark}${C}${lhs}${RST} ${midp} "
write-host "$rhs$annot"
}
$remotekeys = @($remotedict.Keys)
[array]::Sort($remotekeys, [System.StringComparer]::Ordinal)
foreach ($key in $remotekeys) {
if (-not ($localdict.ContainsKey($key))) {
#skip support files riding in the server's sha1sums beside
#the runtimes (artifact metadata tomls etc) - same extension
#set the local candidate filter excludes
if (Test-PunkRuntimeSupportName $key) { continue }
$annot = ""
if ($platform_default -ne "" -and $key -eq $platform_default) { $annot = " (server default)" }
write-host -nonewline (" {0,-35} {1,-33} " -f "-", "")
write-host "$key$annot"
}
}
Write-host "----------------------------------------------------------------------------------------"
Write-host "* = active (local selection)"
} else {
if (test-path -Path $archfolder -Type Container) {
Write-host "-----------------------------------------------------------------------"
Write-Host "Local runtimes for $arch"
$dircontents = Get-PunkRuntimeCandidates $archfolder
$activename = Get-PunkActiveRuntime $archfolder
write-host "$(${dircontents}.count) runtime(s) in $archfolder"
Write-host "-----------------------------------------------------------------------"
foreach ($f in $dircontents) {
$meta = Get-PunkRuntimeMetadataSummary $archfolder $f.Name $arch
$lhs = "$($f.Name)".PadRight(35, ' ')
if ($f.Name -eq $activename) {
write-host "* $lhs (active) $meta"
} else {
write-host " $lhs $meta"
}
}
Write-host "-----------------------------------------------------------------------"
if ($activename -ne "" -and -not ($dircontents | Where-Object Name -eq $activename)) {
write-host "WARNING: active runtime '$activename' (from active.toml) is not present - use 'punk-runtime.cmd use <name>' to reselect"
}
Write-host "Use: 'list -remote' to compare local runtimes with those available on the artifact server"
Write-host "Use: 'use <name>' to select the runtime that 'run' launches"
Write-host "Use: 'use <artifact-r<N>-name>' to materialize an immutable -r<N> artifact into its working name and select it"
} else {
write-host "No runtimes seem to be installed for $arch in $archfolder`nPlease use 'punk-runtime.cmd fetch' to install."
write-host "Use 'punk-runtime.cmd list -remote' to see available runtimes for $arch"
}
}
}
'info' {
#G-117: identity report - the sidecar toml and the record EMBEDDED in
#the artifact's attached image, side by side, disagreements flagged.
#Embedded read = zip central-directory read (the target is NEVER
#executed; see Get-PunkRuntimeEmbeddedRecord). Keep output in parity
#with the bash payload's "info" action.
$arch = Resolve-PunkRuntimePlatform $PSBoundParameters["platform"]
$archfolder = Join-Path -Path $rtfolder -ChildPath "$arch"
$iname = ""
if ( $PSBoundParameters["runtime"].Length ) { $iname = $PSBoundParameters["runtime"] }
if (-not $iname) {
write-host "usage: punk-runtime.cmd info <runtime-name-or-path> ?-platform <p>?"
exit 1
}
$tfile = ""
if (Test-Path -Path $iname -PathType Leaf) {
$tfile = (Resolve-Path -LiteralPath $iname).ProviderPath
} elseif (Test-Path -Path (Join-Path -Path $archfolder -ChildPath $iname) -PathType Leaf) {
$tfile = (Resolve-Path -LiteralPath (Join-Path -Path $archfolder -ChildPath $iname)).ProviderPath
} else {
write-host "punk-runtime info: no such file: '$iname' (checked as a path and in $archfolder)"
exit 1
}
$tbase = Split-Path -Leaf $tfile
$tdir = Split-Path -Parent $tfile
$sidecarfile = Join-Path -Path $tdir -ChildPath ((Get-PunkRuntimeRootName $tbase) + ".toml")
$embedtext = Get-PunkRuntimeEmbeddedRecord $tfile
$localsha1 = Get-PunkFileSha1 -Path $tfile
write-host "-----------------------------------------------------------------------"
write-host "punk-runtime info: $tbase"
write-host " file: $tfile"
write-host " sha1: $localsha1 (computed from the file)"
$havesidecar = Test-Path -Path $sidecarfile -PathType Leaf
if ($havesidecar) {
write-host " sidecar: $sidecarfile"
} else {
write-host " sidecar: (none beside the file - stray/renamed copy?)"
}
if ($null -eq $embedtext) {
write-host " embedded: (no punkbin-artifact.toml in the attached image - pre-v1"
write-host " artifact, or not a punkshell family runtime)"
}
write-host "-----------------------------------------------------------------------"
if (($null -ne $embedtext) -or $havesidecar) {
$efields = @{}
if ($null -ne $embedtext) { $efields = Get-PunkRuntimeRecordFields ($embedtext -split "`r?`n") }
$sfields = @{}
if ($havesidecar) { $sfields = Get-PunkRuntimeRecordFields ([string[]](Get-Content -Path $sidecarfile)) }
write-host (" {0,-20} {1,-36} {2}" -f "field", "embedded", "sidecar")
$disagreements = @()
foreach ($k in @('schema','name','class','variant','working_name','revision','target','build_id','origin','packager','project','project_url','license','build_host_platform','tcl_patchlevel','sha1','size','built')) {
$ev = ""; $sv = ""
if ($efields.ContainsKey($k)) { $ev = $efields[$k] }
if ($sfields.ContainsKey($k)) { $sv = $sfields[$k] }
if (($ev -eq "") -and ($sv -eq "")) { continue }
$evd = "-"; if ($ev -ne "") { $evd = $ev }
$svd = "-"; if ($sv -ne "") { $svd = $sv }
write-host (" {0,-20} {1,-36} {2}" -f $k, $evd, $svd)
#schema absent on one side is pre-v1 tolerance, not a disagreement
if (($ev -ne "") -and ($sv -ne "") -and ($ev -cne $sv)) { $disagreements += $k }
}
write-host "-----------------------------------------------------------------------"
if ($disagreements.Count) {
write-host " !DISAGREE embedded-vs-sidecar on: $($disagreements -join ' ')"
write-host " (the sidecar + sha1sums are the integrity authority; a mismatched pair"
write-host " means this sidecar belongs to a different assembly than this binary)"
} elseif (($null -ne $embedtext) -and $havesidecar) {
write-host " embedded and sidecar records agree on all shared fields"
}
if ($havesidecar -and $sfields.ContainsKey('sha1')) {
if ($sfields['sha1'] -eq $localsha1) {
write-host " sha1: file matches the sidecar record"
} else {
write-host " !SHA1-MISMATCH: file does not match the sidecar record (modified or"
write-host " different assembly; for a materialized working copy compare build_id)"
}
}
}
}
'platforms' {
#enumerate platform folders: local (bin/runtime/*) and, with
#-remote, the server's platforms.txt discovery manifest (raw-file
#servers have no directory listing - the manifest is part of the
#punkbin layout contract; third-party mirrors carry the same file)
$localdirs = @()
if (Test-Path -Path $rtfolder -PathType Container) {
$localdirs = @(Get-ChildItem -Path $rtfolder -Directory | Select-Object -ExpandProperty Name)
[array]::Sort($localdirs, [System.StringComparer]::Ordinal)
}
if ( $PSBoundParameters.ContainsKey('remote') ) {
$manifesturl = "$artifacturl/platforms.txt"
$manifestlocal = Join-Path -Path $rtfolder -ChildPath "platforms.txt"
if (-not (Test-Path -Path $rtfolder -PathType Container)) {
new-item -Path $rtfolder -ItemType Directory -force | out-null
}
try {
Invoke-WebRequest -Uri $manifesturl -OutFile $manifestlocal -ErrorAction Stop
Write-Host "Fetched $manifesturl"
} catch {
if (Test-Path -Path $manifestlocal -PathType Leaf) {
Write-Host "WARNING: could not fetch ${manifesturl}: $($_.Exception.Message)"
Write-Host "WARNING: using cached copy at $manifestlocal"
} else {
Write-Host "No platforms.txt available from ${manifesturl}: $($_.Exception.Message)"
Write-Host "(pre-convention punkbin or third-party mirror without the discovery manifest)"
Write-Host "Name platforms explicitly with -platform; canonical names: 'help platforms' in the punk shell"
exit 1
}
}
$remoteplatforms = @()
foreach ($line in (Get-Content -Path $manifestlocal)) {
$line = $line.Trim()
if ($line -eq "" -or $line.StartsWith("#")) { continue }
$remoteplatforms += $line
}
Write-host "-----------------------------------------------------------------------"
Write-Host "Platforms served by $artifacturl"
Write-host "-----------------------------------------------------------------------"
foreach ($p in $remoteplatforms) {
$marks = @()
if ($p -eq $script:PunkLocalPlatform) { $marks += "local platform" }
if ($localdirs -contains $p) { $marks += "local dir present" }
$lhs = "$p".PadRight(25, ' ')
if ($marks.Count -gt 0) {
write-host " $lhs ($($marks -join ', '))"
} else {
write-host " $lhs"
}
}
foreach ($d in $localdirs) {
if (-not ($remoteplatforms -contains $d)) {
$lhs = "$d".PadRight(25, ' ')
write-host " $lhs (local dir only - not served remotely)"
}
}
Write-host "-----------------------------------------------------------------------"
Write-Host "Use: 'list -remote -platform <name>' to see a platform's runtimes"
} else {
Write-host "-----------------------------------------------------------------------"
Write-Host "Local platform folders under $rtfolder"
Write-host "-----------------------------------------------------------------------"
if ($localdirs.Count -eq 0) {
write-host " (none - 'fetch' creates the local platform's folder)"
}
foreach ($d in $localdirs) {
$lhs = "$d".PadRight(25, ' ')
if ($d -eq $script:PunkLocalPlatform) {
write-host "* $lhs (local platform)"
} else {
write-host " $lhs"
}
}
Write-host "-----------------------------------------------------------------------"
Write-Host "Use: 'platforms -remote' to see platforms served by the artifact server"
Write-Host "Canonical platform names: 'help platforms' in the punk shell"
}
}
'help' {
Show-PunkRuntimeHelp
}
default {
#no action given (unknown actions were already rejected in the
#process block): show the short usage and point at 'help'
Show-PunkRuntimeUsage
write-host ""
write-host "'punk-runtime.cmd help' gives options, env vars and examples."
}
}
return $PSBoundParameters
}
}
#write-host (psmain @args)
#$returnvalue = psmain @args
#Write-Host "Function Returned $returnvalue" -ForegroundColor Cyan
#return $returnvalue
psmain @args | out-null
exit 0
#</powershell-payload>
# -- --- --- --- --- --- --- --- --- --- --- --- --- ---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
# <ctrl-z>

# </ctrl-z>
# -- unreachable by tcl directly if ctrl-z character is in the <ctrl-z> 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)
#>