From de7dd133b82400d08967922d12eef77ac2059e95 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Tue, 30 Dec 2025 12:49:20 -0800 Subject: [PATCH 01/29] feat: Module Scaffolding ( Fixes #1 ) --- Reptile.psd1 | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++ Reptile.psm1 | 6 +++ 2 files changed, 139 insertions(+) create mode 100644 Reptile.psd1 create mode 100644 Reptile.psm1 diff --git a/Reptile.psd1 b/Reptile.psd1 new file mode 100644 index 0000000..ab3565b --- /dev/null +++ b/Reptile.psd1 @@ -0,0 +1,133 @@ +# +# Module manifest for module 'Reptile' +# +# Generated by: James Brundage +# +# Generated on: 12/27/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'Reptile.psm1' + +# Version number of this module. +ModuleVersion = '0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '16622757-8355-4457-8807-97bc03cf0b41' + +# Author of this module +Author = 'James Brundage' + +# Company or vendor of this module +CompanyName = 'Start Automating' + +# Copyright statement for this module +Copyright = '2025 Start Automating' + +# Description of the functionality provided by this module +Description = 'Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL.' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = '*' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = '*' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + LicenseUri = 'https://github.com/PowerShellWeb/Reptile/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PowerShellWeb/Reptile' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + ReleaseNotes = @' +'@ + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/Reptile.psm1 b/Reptile.psm1 new file mode 100644 index 0000000..b0d0bb8 --- /dev/null +++ b/Reptile.psm1 @@ -0,0 +1,6 @@ +$commandsPath = Join-Path $PSScriptRoot Commands +foreach ($file in Get-ChildItem -File -Recurse -Path $commandsPath) { + if ($file.Extension -ne '.ps1') { continue } + if ($file.Name -like '*.*.ps1') { continue } + . $file.FullName +} \ No newline at end of file From 4fa3129f509dccad68bb70c6576efd731f23a484 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Tue, 30 Dec 2025 18:11:40 -0800 Subject: [PATCH 02/29] feat: Get-Reptile ( Fixes #1, Fixes #2, Fixes #3, Fixes #4, Fixes #5 ) --- Commands/Get-Reptile.ps1 | 451 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 Commands/Get-Reptile.ps1 diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 new file mode 100644 index 0000000..a24fc06 --- /dev/null +++ b/Commands/Get-Reptile.ps1 @@ -0,0 +1,451 @@ +function Get-Reptile +{ + <# + .SYNOPSIS + Gets Reptiles + .DESCRIPTION + Gets Reptiles - Read Evaluate Print Terminal Input Loop Editor + .NOTES + ## Reptile + ### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL + + Command Lines can be scary. + + Websites feel much safer. + + Reptile gives you simple, scalable and safe web terminals. + + ### Simple + + Reptile run PowerShell in a [data block](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542) + + This only allows whatever commands you choose, and does not allow loops, strong types, or methods. + + All a reptile really does is take input, create a data block, and call PowerShell. + + ### Scalable + + Reptile is built with a [HttpListener](https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542) + and [PowerShell Thread Jobs](https://learn.microsoft.com/en-us/powershell/module/threadjob/start-threadjob?wt.mc_id=MVP_321542). + + This makes Reptile simple to scale: Just launch more than one job. + + ### Safe + + Data statements are a constrained form of PowerShell that primarily process data. + + Data statements can also run any number of -SupportedCommands. + + Data statements cannot access most variables, use methods, reference most types, or loop. + + This makes them fairly ideal for a mostly safe REPL loop. + + If a command is not supported, it will not be run. + + This means that as long as no supported command allow arbitrary code injection, you are safe. + + However, if you ran `reptile -supportedCommand python`, + then that would be a much more dangerous reptile to deal with. + + Which is why there are some additional safety measures. + + #### Additional Safety Measures + + ##### Local Loopback Port + + By default, reptile will run on a random local loopback port. + + This has three security benefits: + + 1. It does not require elevation to administrator + 2. It does not open an external port + 3. It is less predictable + + If you are running reptile locally as intended, you control which scripts you run, and they can run as you. + + If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. + + ##### AST Inspection + + Scripts that are not parsable as a data block will never be run. + + Additionally, if someone succeeds in the miracle of escaping syntax, + and the AST is not a single data statement, it will not run. + + ##### Background Execution + + All data blocks will be evaluated in a background job. + + This is a trade off of performance for security. + + Responses will take longer than they would inline, + but any potential data corruption is quite literally limited in scope. + + The background jobs cannot access the main server thread, + and so have a much more difficult time escalating any potential jailbreaks. + + Additionally, because the responses are run in background _thread_ jobs, + it limits the overall impact of each request, and thus service is harder to deny. + .EXAMPLE + ./DataReplServer.ps1 + .LINK + https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes + #> + [Alias('Reptile','REPL','WebRepl')] + param( + # The rootUrl of the server. By default, a random loopback address. + [string]$RootUrl= + "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", + + # The list of supported commands + [Alias('SupportedCommands')] + [string[]] + $SupportedCommand = @( + 'Get-Random', 'Random', + 'RandomColor', 'RandomAngle' + 'Turtle', 'Get-Turtle', + 'MarkX', 'Markdown' + ), + + # The Reptile's Shell + # This object will be rendered on GET requests. + # It should be HTML. + [PSObject] + [Alias('Repl', 'WebRepl', 'Scales','Scale', 'Skin')] + $Shell = @( + if (Test-Path "./repl.html") { + Get-Content ./repl.html + } else { + "" + "
" + "" + "" + "
" + "" + "" + } + ) -join [Environment]::NewLine, + + [ScriptBlock] + $Initialize = { + Import-Module Turtle, MarkX -Global + + Set-Alias Random Get-Random + function RandomColor { "#{0:x6}" -f (Get-Random -Max 0xffffff) } + function RandomAngle {Get-Random -Min -360 -Max 360 } + }, + + # The number of nodes to run. + [uint32] + $NodeCount = 1 + ) + + if ($SupportedCommand -match '^(?>Invoke-Expression|iex)$') { + Write-Error "No. Invoke-Expression is unsafe. We will not support this." + return + } + + # Create a listener + $httpListener = [Net.HttpListener]::new() + $httpListener.Prefixes.Add($RootUrl) + # and write a warning so that the user knows (and can click it open) + Write-Warning "Listening on $RootUrl $($httpListener.Start())" + + # Make our IO object by packing our job input into a dictionary. + $io = [Ordered]@{ + HttpListener = $httpListener + SupportedCommand = $SupportedCommand + Shell = $Shell + Initialize = $Initialize + } + # Every item in this dictionary becomes a variable in our job. + + # We will add IO to the return objects. + + # If we want things to be "hot-swappable", we can reference $IO + + # For example, we want to reply in a background job: + $ReplyDefinition = { + param([ScriptBlock]$dataBlock, $reply) + # We want to double check the data statement is the only thing + if (-not ( + ($dataBlock.Ast.EndBlock.Statements.Count -eq 1) -and + ($dataBlock.Ast.EndBlock.Statements[0] -is + [Management.Automation.Language.DataStatementAst]) + )) { + $reply.Close() + return + } + + # Then we want to try running the data block + try { + $result = $(& $dataBlock *>&1) + # And respond with the outer XML + if ($result.OuterXml) { + $reply.Close($OutputEncoding.GetBytes($result.OuterXml), $false) + } else { + # or the stringification of the result. + $reply.Close($OutputEncoding.GetBytes("$result"), $false) + } + } catch { + # If anything went wrong, though it feels wrong, we want to respond with 200 + $reply.StatusCode = 200 + # so that the error is clear to an interactive user. + $reply.Close($OutputEncoding.GetBytes("$($_)"), $false) + } + } + $IO.ReplyDefinition = $ReplyDefinition + + $ServerDefinition = { + param([Collections.IDictionary]$io) + + # First, let's unpack. + $psvariable = $ExecutionContext.SessionState.PSVariable + foreach ($key in @($io.Keys)) { + if ($io[$key] -is [PSVariable]) { $psvariable.set($io[$key]) } + else { $psvariable.set($key, $io[$key]) } + } + + filter errorOut { + $err = $_ + if ($err -isnot [Management.Automation.ErrorRecord]) { + return + } + $bestMessage = + if ($err.Exception.InnerException.Message) { + $err.Exception.InnerException.Message + } elseif ($err.Exception.Message) { + $err.Exception.Message + } else { + "$err" + } + + # write out our error + $err | Out-Host + $err | Write-Error + # and, ironically, say things are OK + $reply.StatusCode = 200 + # So we can show the user the error. + $reply.Close($OutputEncoding.GetBytes("$bestMessage"), $false) + } + + filter replaceVariable { + param([string]$variableName, [string]$replacement) + + # Very permissive variable pattern: + # variables can begin with: + $prefixes = @( + ':' # colons (logo style) + '-{2}' # two dashes (css style) + '\$' # dollar signs (PowerShell style) + ) + $variablePattern = "(?>$($prefixes -join '|'))" + ([Regex]::Escape($variableName)) + + $in = $_ + $in -replace $variablePattern, "'$($replacement -replace "'","''")'" + } + + + # Then listen for the next request + :nextRequest while ($httpListener.IsListening) { + $getContext = $httpListener.GetContextAsync() + # (wait for short prime intervals, so we can cancel if we need to). + while (-not $getContext.Wait(17)) { } + $request, $reply = + $getContext.Result.Request, $getContext.Result.Response + + # Switch what we do next based off of the HTTP Method. + switch ($request.httpMethod) { + get { + # If it's get, return the REPL + $reply.ContentType = 'text/html' + $replBytes = $OutputEncoding.GetBytes("$($io.Shell)") + $reply.Close($replBytes, $false) + } + head { + # If it's head, return 0 for content length and close out. + $reply.ContentLength = 0; $reply.Close() + } + default { + # Any other verb we'll try to evaluate the body. + # Of course, if there is no body + if (-not $request.InputStream) { + Write-Host "No input" -ForegroundColor Yellow + $reply.ContentLength = 0 + $reply.Close() # close out + continue nextRequest # and continue to the next request. + } + + # Read our body + $streamReader = [IO.StreamReader]::new($request.InputStream, $request.ContentEncoding) + $inputString = $streamReader.ReadToEnd() + $streamReader.Close() + $streamReader.Dispose() + + # If we cannot parse the body, we'll pass it as a command. + + + $inputParsed = $null + $inputCopy = [Ordered]@{} + # If the content type resembled json + if ($request.ContentType -match '.+?/.{0,}json') { + # try to parse it + $inputParsed = + try { $inputString | ConvertFrom-Json -AsHashtable} + catch { + # and error out if that did not work. + $_ | errorOut + continue nextRequest + } + + # JSON rpc sends a method and parameters + if ($inputParsed.jsonrpc -and + $inputParsed.method + ) { + $jsonRpcParsed = $inputParsed + $inputCopy.input = $jsonRpcParsed.method + + + foreach ($key in $jsonRpcParsed.parameters.keys) { + $inputCopy[$key] = $jsonRpcParsed.parameters[$key] + } + } + } + + # If the content type looks like form data + if ($request.ContentType -eq 'application/x-www-form-urlencoded') { + + # try to parse it + try { $inputParsed = [Web.HttpUtility]::ParseQueryString($inputString) } + catch { + # and error out if that did not work. + $_ | errorOut + continue nextRequest + } + + + + foreach ($key in $inputParsed.Keys) { + $inputCopy[$key] = [Web.HttpUtility]::UrlDecode($inputParsed[$key]) + } + $reply.ContentType = 'text/html' + } + + # If we have parsed the input, + # then it's fairly simple to support variables. + + # (data blocks don't variables, but they guard against injection enough to replace text) + + if ($inputCopy.Count) { + if (-not $inputCopy['Input']) { + $err = + Write-Error "No Input" -TargetObject $request *>&1 + $err | errorOut + continue nextRequest + } + $inputString = $inputCopy['Input'] + foreach ($key in $inputCopy.Keys) { + $inputString = $inputString | replaceVariable $key $inputCopy[$key] + } + } + + # and then write what was attempted and when. + @( + "$($request.RemoteAddr) $($request.httpMethod) $($request.Url) @ $([datetime]::Now)" + "$inputString " + "---" + ) | Write-Host -ForegroundColor DarkYellow + + # Now we try to make it into a data block + $dataBlock = + try { + # First we construct a script block. + # If this fails, the code is invalid. + $inputScriptBlock = + [ScriptBlock]::Create($inputString) + + # data blocks give us an inline restricted language mode + [ScriptBlock]::Create("data $( + if ($SupportedCommand) { "-supportedCommand '$( + # and we can support a limited set of commands. + $SupportedCommand -replace "'","''" -join "','" + )'"} + ) {" + + [Environment]::NewLine + + $inputScriptBlock + + [Environment]::NewLine + + "}") + } catch { + # If we could not make this a data block + $_ | errorOut + continue nextRequest + } + + # This last bit of healthy paranoia is done twice. + # It may not even be possible, but, if, somehow someone managed to inject a _second_ + # command, or, magically make it not a data block, + if ( + ($dataBlock.Ast.EndBlock.Statements.Count -ne 1) -or + ($dataBlock.Ast.EndBlock.Statements[0] -isnot + [Management.Automation.Language.DataStatementAst]) + ) { + # we want to write an error. + $err = + Write-Error "Unbalanced Injection Attempted @ $([datetime]::Now)" -Category SecurityError -TargetObject $request *>&1 + $err | errorOut + continue nextRequest + } + + # Now we can launch an inner thread job to run the script and reply. + $replyJobParameters = @{ + ScriptBlock=$ReplyDefinition + ThrottleLimit=1kb + ArgumentList=$dataBlock, $reply + InitializationScript=$Initialize + } + # Doing this makes the server more resilient, but will be slower than directly handling each request. + Start-ThreadJob @replyJobParameters + } + } + } + + } + + $JobParameters = @{ + ScriptBlock = $ServerDefinition + InitializationScript = $Initialize + ThrottleLimit = 256 + ArgumentList = $io + Name = $RootUrl + } + + foreach ($nodeNumber in 1..$NodeCount) { + # Our server is a thread job + Start-ThreadJob @JobParameters| # Output our job, + Add-Member -NotePropertyMembers @{ # but attach a few properties first: + HttpListener=$httpListener # * The listener (so we can stop it) + IO=$IO # * The IO (so we can change it) + Url="$RootUrl" # The URL (so we can easily access it). + } -Force -PassThru # Pass all of that thru and return it to you. + } +} From 5f108aa6953ddf783d1e69eb8d1ca4c7bf5686af Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Tue, 30 Dec 2025 18:56:47 -0800 Subject: [PATCH 03/29] feat: Turtle Reptile Example ( Fixes #6 ) Work in Progress --- Examples/Turtle.Reptile.ps1 | 40 +++++++++ Examples/TurtleShell.html.ps1 | 149 ++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 Examples/Turtle.Reptile.ps1 create mode 100644 Examples/TurtleShell.html.ps1 diff --git a/Examples/Turtle.Reptile.ps1 b/Examples/Turtle.Reptile.ps1 new file mode 100644 index 0000000..df83af4 --- /dev/null +++ b/Examples/Turtle.Reptile.ps1 @@ -0,0 +1,40 @@ +#requires -Module Turtle, MarkX, oEmbed + +Push-Location $PSScriptRoot + +Reptile -Initialize { + Import-Module Turtle, MarkX, OEmbed -Global + $env:TURTLE_BOT = $true + Set-Alias Random Get-Random + function RandomColor { "#{0:x6}" -f (Get-Random -Max 0xffffff) } + function RandomAngle {Get-Random -Min -360 -Max 360 } + + function ColorWheel { + "" + + "
" + } +} -Repl (./TurtleShell.html.ps1) -SupportedCommand @( + 'Turtle', 'Get-Turtle' + + 'MarkX', 'Markdown', 'Get-MarkX' + + 'Get-OEmbed', 'oEmbed' + + 'Get-Random', 'Random', 'RandomColor', 'RandomAngle' + + 'ColorWheel' +) + +Pop-Location \ No newline at end of file diff --git a/Examples/TurtleShell.html.ps1 b/Examples/TurtleShell.html.ps1 new file mode 100644 index 0000000..c591eba --- /dev/null +++ b/Examples/TurtleShell.html.ps1 @@ -0,0 +1,149 @@ +"" +"" +"" +"" +"" +"
" +"" +"" +"
" + +"" +"
    " +"
" + +"" +"" +"" +"" \ No newline at end of file From b3ba71e804ddf9ae9e4f0498fe440ba90458d136 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Tue, 30 Dec 2025 20:03:01 -0800 Subject: [PATCH 04/29] feat: Turtle Reptile Example ( Fixes #6 ) Improving button behavior and adding examples --- Examples/TurtleShell.html.ps1 | 76 +++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/Examples/TurtleShell.html.ps1 b/Examples/TurtleShell.html.ps1 index c591eba..47c851a 100644 --- a/Examples/TurtleShell.html.ps1 +++ b/Examples/TurtleShell.html.ps1 @@ -3,8 +3,8 @@ "" "" "" "
" -"" "" "
" +"" +"" +"" +"" +"" +"" +"" +"" +"
Sectors" +@" + + + + +"@ +"
" +"
" "" "
    " "
" @@ -51,8 +76,10 @@ function newShell(input, options = {}) { const newListOutput = document.createElement('output') const newListInput = document.createElement('textarea') newListInput.setAttribute('spellcheck','false') + newListInput.setAttribute('rows','3') newListInput.classList.add('repl-input') + const now = new Date() newListInput.id = 'input' + now.getTime() newListInput.value = input @@ -63,6 +90,7 @@ function newShell(input, options = {}) { const newGoButton = document.createElement('button') newGoButton.innerText = 'go' + newGoButton.classList.add('repl-go') newGoButton.addEventListener('click', go) newListDetails.appendChild(newGoButton) newListDetails.appendChild(newListOutput) @@ -84,39 +112,20 @@ function newShell(input, options = {}) { if ( event?.target?.dataset?.input ) { - const response = - await fetch( - window.location.href, - {method: 'POST',body: event?.target?.dataset?.input} - ) - const responseText = await response.text() - - if (event?.target?.dataset?.output) { - for (const out in [ - ...document.querySelectorAll(event?.target?.dataset?.output) - ]) { - out.innerHTML = responseText; - out.animate({ scale: ['0%', '100%'] }, 84); - } - } else { - const out = document.createElement('output') - out.innerHTML = responseText; - const mainOutput = document.getElementById('output') - if (mainOutput?.firstChild) { - mainOutput.insertBefore(out, mainOutput.firstChild) - out.animate({ scale: ['0%', '100%'] }, 84); - } else if (mainOutput) { - mainOutput.appendChild(out) - out.animate({ scale: ['0%', '100%'] }, 84); - } - } + inputScript = event?.target?.dataset?.input + inputId = newShell(inputScript) + const outputId = inputId.replace(/^input/i, 'output') + const out = document.getElementById(outputId) + const response = await fetch(window.location.href, + {method: 'POST',body: inputScript}) + out.innerHTML = await response.text() + out.animate({ scale: ['0%', '100%'] }, 84); return } if (event?.target?.previousSibling?.value && event?.target?.previousSibling?.id.match(/^input/)) { inputId = event?.target?.previousSibling?.id - const outputId = inputId.replace(/^input/i, 'output') - + const outputId = inputId.replace(/^input/i, 'output') } if (! inputScript && inputId == 'input' || ! inputId) { @@ -144,6 +153,5 @@ function newShell(input, options = {}) { }" "document.getElementById('go').addEventListener('click', go)" "" -"" "" "" \ No newline at end of file From da40f45ff1ba359b9d44534dd799a9985f7d4939 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Fri, 2 Jan 2026 12:08:44 -0800 Subject: [PATCH 05/29] feat: Get-Reptile ( Fixes #1, Fixes #2, Fixes #3, Fixes #4, Fixes #5 ) Locking down parameter defaults, no longer double escaping form input, and not tracing the full script (avoiding potential information disclosure) --- Commands/Get-Reptile.ps1 | 75 ++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 index a24fc06..b8b04d5 100644 --- a/Commands/Get-Reptile.ps1 +++ b/Commands/Get-Reptile.ps1 @@ -100,12 +100,7 @@ function Get-Reptile # The list of supported commands [Alias('SupportedCommands')] [string[]] - $SupportedCommand = @( - 'Get-Random', 'Random', - 'RandomColor', 'RandomAngle' - 'Turtle', 'Get-Turtle', - 'MarkX', 'Markdown' - ), + $SupportedCommand = @(), # The Reptile's Shell # This object will be rendered on GET requests. @@ -116,44 +111,44 @@ function Get-Reptile if (Test-Path "./repl.html") { Get-Content ./repl.html } else { + "Reptile" "" - "
" - "" - "" - "
" - "" - "" + "" + "" + "
" + "" + "" + "
" + "" + "" } ) -join [Environment]::NewLine, - - [ScriptBlock] - $Initialize = { - Import-Module Turtle, MarkX -Global - Set-Alias Random Get-Random - function RandomColor { "#{0:x6}" -f (Get-Random -Max 0xffffff) } - function RandomAngle {Get-Random -Min -360 -Max 360 } - }, + # The script used to initialize the reptile. + [ScriptBlock] + $Initialize = {}, # The number of nodes to run. [uint32] @@ -231,6 +226,7 @@ function Get-Reptile if ($err -isnot [Management.Automation.ErrorRecord]) { return } + # Attempt to find the best error message $bestMessage = if ($err.Exception.InnerException.Message) { $err.Exception.InnerException.Message @@ -342,11 +338,10 @@ function Get-Reptile $_ | errorOut continue nextRequest } - - - + foreach ($key in $inputParsed.Keys) { - $inputCopy[$key] = [Web.HttpUtility]::UrlDecode($inputParsed[$key]) + $inputCopy[$key] = $inputParsed[$key] + Write-Host "$key - $($inputCopy[$key])" } $reply.ContentType = 'text/html' } @@ -372,9 +367,7 @@ function Get-Reptile # and then write what was attempted and when. @( "$($request.RemoteAddr) $($request.httpMethod) $($request.Url) @ $([datetime]::Now)" - "$inputString " - "---" - ) | Write-Host -ForegroundColor DarkYellow + ) | Write-Host -ForegroundColor Cyan # Now we try to make it into a data block $dataBlock = From b3cf6edadef48edef6305e34085a727da2bcb853 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Fri, 2 Jan 2026 12:12:06 -0800 Subject: [PATCH 06/29] feat: Calculating Reptile Example ( Fixes #7 ) --- Examples/Calculating.Reptile.ps1 | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Examples/Calculating.Reptile.ps1 diff --git a/Examples/Calculating.Reptile.ps1 b/Examples/Calculating.Reptile.ps1 new file mode 100644 index 0000000..b91ed23 --- /dev/null +++ b/Examples/Calculating.Reptile.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + A Caculating Reptile +.DESCRIPTION + A simple calculator in Reptile. + + PowerShell Data blocks act as a simple calculator. +.NOTES + This explicitly supports no commands and has no initialization script, so it will not use any default values. + + This is about as barebones as you can get. +#> +Reptile -SupportedCommand @() -Initialize {} -Shell @" +Calculating Reptile + +
+ + +
+ + +"@ \ No newline at end of file From e369cb20dd53f48336d50d8bb06c4221ce0b6651 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Fri, 2 Jan 2026 12:20:20 -0800 Subject: [PATCH 07/29] feat: DaysUntil Reptile example ( Fixes #8 ) --- Examples/DaysUntil.Reptile.ps1 | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Examples/DaysUntil.Reptile.ps1 diff --git a/Examples/DaysUntil.Reptile.ps1 b/Examples/DaysUntil.Reptile.ps1 new file mode 100644 index 0000000..a801862 --- /dev/null +++ b/Examples/DaysUntil.Reptile.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS + Days Until Reptile +.DESCRIPTION + A simple single command form. Show the number of days until a date. +.NOTES + This demonstrates how we can build really self-service forms. +#> +Reptile -Initialize { + # We can declare a small function in initialize + function DaysUntil([Parameter(Mandatory)][DateTime]$Date) { + "

$(($date - [DateTime]::Now).TotalDays) days until $($date)

" + } +} -SupportedCommand @( + # We also need to add it to the list of supported commands. + 'DaysUntil' +) -Shell @( + # Our shell is just a form with two inputs: + "
" + # A date selector + "" + # (with a label) + "" + "" + "" + "
" +) \ No newline at end of file From bdc33f6304f461b070b3a44e726529a0b72d4257 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Fri, 2 Jan 2026 12:28:24 -0800 Subject: [PATCH 08/29] feat: Gradient Reptile example ( Fixes #9 ) --- Examples/Gradient.Reptile.ps1 | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Examples/Gradient.Reptile.ps1 diff --git a/Examples/Gradient.Reptile.ps1 b/Examples/Gradient.Reptile.ps1 new file mode 100644 index 0000000..26af7fd --- /dev/null +++ b/Examples/Gradient.Reptile.ps1 @@ -0,0 +1,32 @@ +<# +.SYNOPSIS + Gradient Reptile +.DESCRIPTION + A simple Reptile that makes gradients. +.NOTES + This imports a single module and exposes a pair of commands +#> +Reptile -SupportedCommand @( + 'Gradient','Get-Gradient' +) -Initialize { + Import-Module Gradient +} -Shell @" +Gradient Reptile + +
+ + + + " +'@))" + +
+ + +"@ \ No newline at end of file From 059e2f940e9a3f562e1a1690121aeec80b70aa4c Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Sat, 3 Jan 2026 11:25:30 -0800 Subject: [PATCH 09/29] docs: DaysUntil Reptile ( Fixes #8 ) Adding note --- Examples/DaysUntil.Reptile.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/Examples/DaysUntil.Reptile.ps1 b/Examples/DaysUntil.Reptile.ps1 index a801862..b57a157 100644 --- a/Examples/DaysUntil.Reptile.ps1 +++ b/Examples/DaysUntil.Reptile.ps1 @@ -21,6 +21,7 @@ Reptile -Initialize { "" # (with a label) "" + # and a hidden input containing our script. "" From d98c6092ee285ab35cb75c041fab6ffe25b9ce2c Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Sat, 3 Jan 2026 11:44:26 -0800 Subject: [PATCH 10/29] feat: Turtle Reptile Example ( Fixes #6 ) Improving layout and adding more bells and whistles --- Examples/Turtle.Reptile.ps1 | 11 ++- Examples/TurtleShell.html.ps1 | 167 ++++++++++++++++++++++++++++------ 2 files changed, 151 insertions(+), 27 deletions(-) diff --git a/Examples/Turtle.Reptile.ps1 b/Examples/Turtle.Reptile.ps1 index df83af4..315073b 100644 --- a/Examples/Turtle.Reptile.ps1 +++ b/Examples/Turtle.Reptile.ps1 @@ -24,7 +24,14 @@ Reptile -Initialize { "hsl($($randomOffset + ($n * 45)) 100% 50%)" }) -join ',' ))'>" - } + } + + function say { + $allInput = @($input) + @($args) + foreach ($message in $allInput) { + "

$([Web.HttpUtility]::HtmlEncode($message))

" + } + } } -Repl (./TurtleShell.html.ps1) -SupportedCommand @( 'Turtle', 'Get-Turtle' @@ -35,6 +42,8 @@ Reptile -Initialize { 'Get-Random', 'Random', 'RandomColor', 'RandomAngle' 'ColorWheel' + + 'Say' ) Pop-Location \ No newline at end of file diff --git a/Examples/TurtleShell.html.ps1 b/Examples/TurtleShell.html.ps1 index 47c851a..b8818e5 100644 --- a/Examples/TurtleShell.html.ps1 +++ b/Examples/TurtleShell.html.ps1 @@ -1,8 +1,15 @@ +param( +[string] +$Lucky = @' +turtle lucky +'@ +) + "" "" "" "" -"" -"
" -"" -"" +"" "
" "" +"" + +@" + +"@ +"" +"" +"
" +"Examples" + "" "" "" @@ -43,22 +64,67 @@ "" "" "" +"
" "
Sectors" @" + + + + + + +"@ +"
" +"
" +"
" +"
Pies" +@" + + + + + + + + + + + + "@ "
" +"
" "
" +"" "" "
    " "
" @@ -66,41 +132,52 @@ "" +"" "" "" \ No newline at end of file From 9cdd056c2e3be282ee8ec144704d6e56aba9ca08 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Mon, 5 Jan 2026 19:32:16 -0800 Subject: [PATCH 11/29] feat: Get-Reptile and example updates ( Fixes #1, Fixes #2, Fixes #3, Fixes #4, Fixes #5, Fixes #6, Fixes #7, Fixes #8, Fixes #9 ) Switching nomenclature to command (for more seamless use with the popover apis) --- Commands/Get-Reptile.ps1 | 301 ++++++++++++++++++------------- Examples/Calculating.Reptile.ps1 | 2 +- Examples/DaysUntil.Reptile.ps1 | 6 +- Examples/Gradient.Reptile.ps1 | 2 +- Examples/TurtleShell.html.ps1 | 89 +++++---- 5 files changed, 231 insertions(+), 169 deletions(-) diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 index b8b04d5..037f5b1 100644 --- a/Commands/Get-Reptile.ps1 +++ b/Commands/Get-Reptile.ps1 @@ -138,7 +138,7 @@ function Get-Reptile "" "" "
" - "" + "" "" "
" "" @@ -181,7 +181,7 @@ function Get-Reptile # For example, we want to reply in a background job: $ReplyDefinition = { - param([ScriptBlock]$dataBlock, $reply) + param([ScriptBlock]$dataBlock, $reply, [Collections.IDictionary]$Option) # We want to double check the data statement is the only thing if (-not ( ($dataBlock.Ast.EndBlock.Statements.Count -eq 1) -and @@ -221,8 +221,12 @@ function Get-Reptile else { $psvariable.set($key, $io[$key]) } } + # Now we want to declare several filters for various conditions + + # First up, let's handle how we error out filter errorOut { $err = $_ + # If this is not an error, return. if ($err -isnot [Management.Automation.ErrorRecord]) { return } @@ -245,23 +249,170 @@ function Get-Reptile $reply.Close($OutputEncoding.GetBytes("$bestMessage"), $false) } + # Next let's define a command to construct our data block + + filter getDataBlock([string]$inputString) { + try { + # First we construct a script block. + # If this fails, the code is invalid. + $inputScriptBlock = + [ScriptBlock]::Create($inputString) + + # data blocks give us an inline restricted language mode + [ScriptBlock]::Create("data $( + if ($SupportedCommand) { "-supportedCommand '$( + # and we can support a limited set of commands. + $SupportedCommand -replace "'","''" -join "','" + )'"} + ) {" + + [Environment]::NewLine + + $inputScriptBlock + + [Environment]::NewLine + + "}") + } catch { + # If we could not make this a data block + $_ | errorOut + continue nextRequest + } + } + + # Next, we define how we replace variables filter replaceVariable { param([string]$variableName, [string]$replacement) # Very permissive variable pattern: # variables can begin with: $prefixes = @( - ':' # colons (logo style) - '-{2}' # two dashes (css style) - '\$' # dollar signs (PowerShell style) + ':' # colons (logo style) + '-{2}' # two dashes (css style) + '\$' # dollar signs (PowerShell style) + '@' # sql style / splatting style () ) $variablePattern = "(?>$($prefixes -join '|'))" + ([Regex]::Escape($variableName)) $in = $_ - $in -replace $variablePattern, "'$($replacement -replace "'","''")'" + $in -replace $variablePattern, "'$( + $replacement -replace # First sanitize each value + "'","''" -join # then join into a list of constants + "','" # and now we have multi-value type free variable support + )'" + + # An expanded variable that _somehow_ escapes stringification + # should be be caught by the data block. + + # This allowing us to support parameters without sacrificing safety. + } + + + filter getCommandAndInput { + # Read our body + $streamReader = + [IO.StreamReader]::new($request.InputStream, $request.ContentEncoding) + + $inputString = $streamReader.ReadToEnd() + + $streamReader.Close() + $streamReader.Dispose() + + # If we cannot parse the body, we'll pass it as a command. + $inputParsed = $null + $jsonRpc = $null + $inputCopy = [Ordered]@{} + # If the content type resembled json + if ($request.ContentType -match '.+?/.{0,}json') { + # try to parse it + $inputParsed = + try { $inputString | ConvertFrom-Json -AsHashtable} + catch { + # and error out if that did not work. + $_ | errorOut + continue nextRequest + } + + # JSON rpc sends a method and parameters + if ($inputParsed.jsonrpc -and + $inputParsed.method + ) { + $jsonRpc = $inputParsed + + # Per the json rpc spec, without an id it is a notification + if ($null -eq $jsonRpc.method.id) { + $inputString # emit the input (thus notifying the server owner) + $reply.Close() # and close the request + continue nextRequest + } + + $inputCopy.input = $jsonRpc.method + + foreach ($key in $jsonRpc.parameters.keys) { + $inputCopy[$key] = $jsonRpc.parameters[$key] + } + } + } + + # If the content type looks like form data + if ($request.ContentType -eq 'application/x-www-form-urlencoded') { + # try to parse it + try { $inputParsed = [Web.HttpUtility]::ParseQueryString($inputString) } + catch { + # and error out if that did not work. + $_ | errorOut + continue nextRequest + } + + foreach ($key in $inputParsed.Keys) { + $inputCopy[$key] = $inputParsed[$key] + Write-Host "$key - $($inputCopy[$key])" + } + $reply.ContentType = 'text/html' + } + + # If we have parsed the input, + # then it's fairly simple to support variables. + + # (data blocks don't have variables, + # but they guard against injection enough to support open-ended text input) + if ($inputCopy.Count) { + if (-not $inputCopy['Command']) { + $err = + Write-Error "No Command" -TargetObject $request *>&1 + $err | errorOut + continue nextRequest + } + $inputString = $inputCopy['Command'] + foreach ($key in $inputCopy.Keys) { + $inputString = $inputString | replaceVariable $key $inputCopy[$key] + } + } + + # and then write what was attempted and when. + @( + "$($request.RemoteAddr) $($request.httpMethod) $($request.Url) @ $([datetime]::Now)" + ) | Write-Host -ForegroundColor Cyan + + # Now we try to make it into a data block + $dataBlock = GetDataBlock $inputString + + # This last bit of healthy paranoia is done twice. + # It may not even be possible, but, if, somehow someone managed to inject a _second_ + # command, or, magically make it not a data block, + if ( + ($dataBlock.Ast.EndBlock.Statements.Count -ne 1) -or + ($dataBlock.Ast.EndBlock.Statements[0] -isnot + [Management.Automation.Language.DataStatementAst]) + ) { + # we want to write an error. + $err = + Write-Error "Unbalanced Injection Attempted @ $([datetime]::Now)" -Category SecurityError -TargetObject $request *>&1 + + $err | errorOut + + continue nextRequest + } } + # Then listen for the next request :nextRequest while ($httpListener.IsListening) { $getContext = $httpListener.GetContextAsync() @@ -269,12 +420,16 @@ function Get-Reptile while (-not $getContext.Wait(17)) { } $request, $reply = $getContext.Result.Request, $getContext.Result.Response + + if ($request.Url -match '/xrpc/') { + + } # Switch what we do next based off of the HTTP Method. switch ($request.httpMethod) { get { # If it's get, return the REPL - $reply.ContentType = 'text/html' + $reply.ContentType = 'text/html' $replBytes = $OutputEncoding.GetBytes("$($io.Shell)") $reply.Close($replBytes, $false) } @@ -292,132 +447,30 @@ function Get-Reptile continue nextRequest # and continue to the next request. } - # Read our body - $streamReader = [IO.StreamReader]::new($request.InputStream, $request.ContentEncoding) - $inputString = $streamReader.ReadToEnd() - $streamReader.Close() - $streamReader.Dispose() - - # If we cannot parse the body, we'll pass it as a command. - - - $inputParsed = $null - $inputCopy = [Ordered]@{} - # If the content type resembled json - if ($request.ContentType -match '.+?/.{0,}json') { - # try to parse it - $inputParsed = - try { $inputString | ConvertFrom-Json -AsHashtable} - catch { - # and error out if that did not work. - $_ | errorOut - continue nextRequest - } - - # JSON rpc sends a method and parameters - if ($inputParsed.jsonrpc -and - $inputParsed.method - ) { - $jsonRpcParsed = $inputParsed - $inputCopy.input = $jsonRpcParsed.method - - - foreach ($key in $jsonRpcParsed.parameters.keys) { - $inputCopy[$key] = $jsonRpcParsed.parameters[$key] - } - } - } - - # If the content type looks like form data - if ($request.ContentType -eq 'application/x-www-form-urlencoded') { - - # try to parse it - try { $inputParsed = [Web.HttpUtility]::ParseQueryString($inputString) } - catch { - # and error out if that did not work. - $_ | errorOut - continue nextRequest - } - - foreach ($key in $inputParsed.Keys) { - $inputCopy[$key] = $inputParsed[$key] - Write-Host "$key - $($inputCopy[$key])" - } - $reply.ContentType = 'text/html' - } - - # If we have parsed the input, - # then it's fairly simple to support variables. + $dataBlock = $null - # (data blocks don't variables, but they guard against injection enough to replace text) - - if ($inputCopy.Count) { - if (-not $inputCopy['Input']) { - $err = - Write-Error "No Input" -TargetObject $request *>&1 - $err | errorOut - continue nextRequest - } - $inputString = $inputCopy['Input'] - foreach ($key in $inputCopy.Keys) { - $inputString = $inputString | replaceVariable $key $inputCopy[$key] - } - } - - # and then write what was attempted and when. - @( - "$($request.RemoteAddr) $($request.httpMethod) $($request.Url) @ $([datetime]::Now)" - ) | Write-Host -ForegroundColor Cyan - - # Now we try to make it into a data block - $dataBlock = - try { - # First we construct a script block. - # If this fails, the code is invalid. - $inputScriptBlock = - [ScriptBlock]::Create($inputString) - - # data blocks give us an inline restricted language mode - [ScriptBlock]::Create("data $( - if ($SupportedCommand) { "-supportedCommand '$( - # and we can support a limited set of commands. - $SupportedCommand -replace "'","''" -join "','" - )'"} - ) {" + - [Environment]::NewLine + - $inputScriptBlock + - [Environment]::NewLine + - "}") - } catch { - # If we could not make this a data block - $_ | errorOut - continue nextRequest - } - - # This last bit of healthy paranoia is done twice. - # It may not even be possible, but, if, somehow someone managed to inject a _second_ - # command, or, magically make it not a data block, - if ( - ($dataBlock.Ast.EndBlock.Statements.Count -ne 1) -or - ($dataBlock.Ast.EndBlock.Statements[0] -isnot - [Management.Automation.Language.DataStatementAst]) - ) { - # we want to write an error. - $err = - Write-Error "Unbalanced Injection Attempted @ $([datetime]::Now)" -Category SecurityError -TargetObject $request *>&1 - $err | errorOut - continue nextRequest - } - + . getCommandAndInput + # Now we can launch an inner thread job to run the script and reply. $replyJobParameters = @{ ScriptBlock=$ReplyDefinition ThrottleLimit=1kb - ArgumentList=$dataBlock, $reply + ArgumentList=@( + $dataBlock, $reply, + [Ordered]@{ + 'jsonrpc' = $jsonRpcParsed + } + ) InitializationScript=$Initialize } + # Doing this makes the server more resilient, but will be slower than directly handling each request. Start-ThreadJob @replyJobParameters + + # Clean up any completed requests and continue on with the loop. + Get-Job | + Where-Object State -eq 'Completed' | + Remove-Job -Force } } } diff --git a/Examples/Calculating.Reptile.ps1 b/Examples/Calculating.Reptile.ps1 index b91ed23..ed45a88 100644 --- a/Examples/Calculating.Reptile.ps1 +++ b/Examples/Calculating.Reptile.ps1 @@ -14,7 +14,7 @@ Reptile -SupportedCommand @() -Initialize {} -Shell @" Calculating Reptile
- +
diff --git a/Examples/DaysUntil.Reptile.ps1 b/Examples/DaysUntil.Reptile.ps1 index b57a157..b91be17 100644 --- a/Examples/DaysUntil.Reptile.ps1 +++ b/Examples/DaysUntil.Reptile.ps1 @@ -2,11 +2,11 @@ .SYNOPSIS Days Until Reptile .DESCRIPTION - A simple single command form. Show the number of days until a date. + A simple single command form. Show the number of days until a date. .NOTES This demonstrates how we can build really self-service forms. #> -Reptile -Initialize { +Reptile -Initialize { # We can declare a small function in initialize function DaysUntil([Parameter(Mandatory)][DateTime]$Date) { "

$(($date - [DateTime]::Now).TotalDays) days until $($date)

" @@ -22,7 +22,7 @@ Reptile -Initialize { # (with a label) "" # and a hidden input containing our script. - "" "" diff --git a/Examples/Gradient.Reptile.ps1 b/Examples/Gradient.Reptile.ps1 index 26af7fd..c323965 100644 --- a/Examples/Gradient.Reptile.ps1 +++ b/Examples/Gradient.Reptile.ps1 @@ -21,7 +21,7 @@ Reptile -SupportedCommand @( - " '@))" diff --git a/Examples/TurtleShell.html.ps1 b/Examples/TurtleShell.html.ps1 index b8818e5..7a373d1 100644 --- a/Examples/TurtleShell.html.ps1 +++ b/Examples/TurtleShell.html.ps1 @@ -10,7 +10,7 @@ turtle lucky "" "" "" -"
" -"" "" @@ -50,44 +50,44 @@ $Lucky "" @" - + "@ "" "" "
" "Examples" -"" -"" -"" -"" -"" -"" -"" +"" +"" +"" +"" +"" +"" +"" "
" "
Sectors" @" - - - - - - "@ @@ -96,27 +96,27 @@ $Lucky "
" "
Pies" @" - - - - - - @@ -144,26 +144,26 @@ function newShell(input, options = {}) { newListSummary.innerText = outputItemList.childNodes.length newListDetails.appendChild(newListSummary) const newListGrid = document.createElement('div') - newListGrid.classList.add('repl-input-grid') + newListGrid.classList.add('repl-command-grid') newListDetails.appendChild(newListGrid) const newListInput = document.createElement('textarea') const inputLines = input.split(/(\r\n|\n|\r)/) newListInput.setAttribute('spellcheck','false') - newListInput.setAttribute('autocomplete','repl-input') + newListInput.setAttribute('autocomplete','repl-command') newListInput.setAttribute('rows',inputLines.length - 1) newListInput.setAttribute('disabled', 'true') - newListInput.classList.add('repl-input') - newListInput.id = 'input' + now.getTime() + newListInput.classList.add('repl-command') + newListInput.id = 'command' + now.getTime() newListInput.value = input newListGrid.appendChild(newListInput) const newListOutput = document.createElement('output') - newListOutput.id = newListInput.id.replace(/^input/i, 'output') + newListOutput.id = newListInput.id.replace(/^command/i, 'output') const newGoButton = document.createElement('button') - newGoButton.id = newListInput.id.replace(/^input/i, 'go') + newGoButton.id = newListInput.id.replace(/^command/i, 'go') newGoButton.innerText = 'Go Turtle' newGoButton.classList.add('repl-go') newGoButton.addEventListener('click', go) @@ -192,23 +192,32 @@ function newShell(input, options = {}) { } if ( - event?.target?.dataset?.input + event?.target?.getAttribute && + event?.target?.getAttribute('command') ) { - inputScript = event?.target?.dataset?.input + inputScript = event.target.getAttribute('command') + inputId = newShell(inputScript) - const outputId = inputId.replace(/^input/i, 'output') + + const outputId = inputId.replace(/^command/i, 'output') + const out = document.getElementById(outputId) + const response = await fetch(window.location.href, {method: 'POST',body: inputScript}) + out.innerHTML = await response.text() + out.animate({ scale: ['0%', '100%'] }, 67); + out.scrollIntoView() + const inputElement = document.getElementById(inputId) if (inputElement) { inputElement.removeAttribute('disabled') } const goElement = document.getElementById( - inputId.replace(/^input/i, 'go') + inputId.replace(/^command/i, 'go') ) if (goElement) { goElement.removeAttribute('disabled') @@ -218,11 +227,11 @@ function newShell(input, options = {}) { if (event?.target?.previousSibling?.value && event?.target?.previousSibling?.id.match(/^input/)) { inputId = event?.target?.previousSibling?.id - const outputId = inputId.replace(/^input/i, 'output') + const outputId = inputId.replace(/^command/i, 'output') } - if (! inputScript && inputId == 'input' || ! inputId) { - const repl = document.getElementById('input') + if (! inputScript && inputId == 'command' || ! inputId) { + const repl = document.getElementById('command') repl.animate({ scale: ['100%', '105%','100%'] }, 67); inputId = newShell(repl.value) inputScript = repl.value @@ -234,10 +243,10 @@ function newShell(input, options = {}) { if (! inputScript) { return } - const outputId = inputId.replace(/^input/i, 'output') + const outputId = inputId.replace(/^command/i, 'output') const out = document.getElementById(outputId) const goElement = document.getElementById( - inputId.replace(/^input/i, 'go') + inputId.replace(/^command/i, 'go') ) if (goElement) { goElement.setAttribute('disabled', 'true') From acd24a3533dc50bf34a3f52e23cd43813a0390e9 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Mon, 5 Jan 2026 20:21:21 -0800 Subject: [PATCH 12/29] docs: Reptile README ( Fixes #15 ) --- Commands/Get-Reptile.ps1 | 49 ++++++++++++++- README.md | 126 ++++++++++++++++++++++++++++++++++++++- README.md.ps1 | 2 + 3 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 README.md.ps1 diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 index 037f5b1..130ee75 100644 --- a/Commands/Get-Reptile.ps1 +++ b/Commands/Get-Reptile.ps1 @@ -15,6 +15,50 @@ function Get-Reptile Reptile gives you simple, scalable and safe web terminals. + ### Installing and Importing + + We can install Reptile from the PowerShell Gallery: + + ~~~PowerShell + Install-Module Reptile + ~~~ + + Once installed, we can import it with: + + ~~~PowerShell + Import-Module Reptile -PassThru + ~~~ + + We can also clone the repository and import it from any directory: + + ~~~PowerShell + git clone https://github.com/PowerShellWeb/Reptile + cd ./Reptile + Import-Module ./ -PassThru + ~~~ + + ### Getting Started + + Once installed, we just run reptile: + + ~~~PowerShell + reptile + ~~~ + + This will start a simple terminal with no commands enabled. + + You can still 'run' a few things. + + `2+2` will equal `4`. "a" + "b" + "c" will be `abc`. + + Feel free to play around. + + Reptile runs in Restricted Language mode, and it's pretty restrictive. + + ## Simple, Scalable, Safe + + Reptile gives you simple, scalable and safe web terminals. + ### Simple Reptile run PowerShell in a [data block](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542) @@ -87,7 +131,7 @@ function Get-Reptile Additionally, because the responses are run in background _thread_ jobs, it limits the overall impact of each request, and thus service is harder to deny. .EXAMPLE - ./DataReplServer.ps1 + reptile .LINK https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes #> @@ -308,7 +352,8 @@ function Get-Reptile # Read our body $streamReader = [IO.StreamReader]::new($request.InputStream, $request.ContentEncoding) - + + $inputString = $streamReader.ReadToEnd() $streamReader.Close() diff --git a/README.md b/README.md index 4cdc67a..e1e8d77 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,124 @@ -# Reptile -Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL. +## Reptile +### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL + +Command Lines can be scary. + +Websites feel much safer. + +Reptile gives you simple, scalable and safe web terminals. + +### Installing and Importing + +We can install Reptile from the PowerShell Gallery: + +~~~PowerShell +Install-Module Reptile +~~~ + +Once installed, we can import it with: + +~~~PowerShell +Import-Module Reptile -PassThru +~~~ + +We can also clone the repository and import it from any directory: + +~~~PowerShell +git clone https://github.com/PowerShellWeb/Reptile +cd ./Reptile +Import-Module ./ -PassThru +~~~ + +### Getting Started + +Once installed, we just run reptile: + +~~~PowerShell +reptile +~~~ + +This will start a simple terminal with no commands enabled. + +You can still 'run' a few things. + +`2+2` will equal `4`. "a" + "b" + "c" will be `abc`. + +Feel free to play around. + +Reptile runs in Restricted Language mode, and it's pretty restrictive. + +## Simple, Scalable, Safe + +Reptile gives you simple, scalable and safe web terminals. + +### Simple + +Reptile run PowerShell in a [data block](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542) + +This only allows whatever commands you choose, and does not allow loops, strong types, or methods. + +All a reptile really does is take input, create a data block, and call PowerShell. + +### Scalable + +Reptile is built with a [HttpListener](https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542) +and [PowerShell Thread Jobs](https://learn.microsoft.com/en-us/powershell/module/threadjob/start-threadjob?wt.mc_id=MVP_321542). + +This makes Reptile simple to scale: Just launch more than one job. + +### Safe + +Data statements are a constrained form of PowerShell that primarily process data. + +Data statements can also run any number of -SupportedCommands. + +Data statements cannot access most variables, use methods, reference most types, or loop. + +This makes them fairly ideal for a mostly safe REPL loop. + +If a command is not supported, it will not be run. + +This means that as long as no supported command allow arbitrary code injection, you are safe. + +However, if you ran `reptile -supportedCommand python`, +then that would be a much more dangerous reptile to deal with. + +Which is why there are some additional safety measures. + +#### Additional Safety Measures + +##### Local Loopback Port + +By default, reptile will run on a random local loopback port. + +This has three security benefits: + +1. It does not require elevation to administrator +2. It does not open an external port +3. It is less predictable + +If you are running reptile locally as intended, you control which scripts you run, and they can run as you. + +If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. + +##### AST Inspection + +Scripts that are not parsable as a data block will never be run. + +Additionally, if someone succeeds in the miracle of escaping syntax, +and the AST is not a single data statement, it will not run. + +##### Background Execution + +All data blocks will be evaluated in a background job. + +This is a trade off of performance for security. + +Responses will take longer than they would inline, +but any potential data corruption is quite literally limited in scope. + +The background jobs cannot access the main server thread, +and so have a much more difficult time escalating any potential jailbreaks. + +Additionally, because the responses are run in background _thread_ jobs, +it limits the overall impact of each request, and thus service is harder to deny. diff --git a/README.md.ps1 b/README.md.ps1 new file mode 100644 index 0000000..3754e23 --- /dev/null +++ b/README.md.ps1 @@ -0,0 +1,2 @@ +$help = Get-Help Reptile +$help.alertset.alert.text -join [Environment]::NewLine \ No newline at end of file From 7b9405f4f5123d69b597c4be6f7e624875e48b2d Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Thu, 8 Jan 2026 13:46:28 -0800 Subject: [PATCH 13/29] feat: Turtle Reptile Example ( Fixes #6 ) Improving inner layout and fixing command targeting --- Examples/TurtleShell.html.ps1 | 120 +++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 52 deletions(-) diff --git a/Examples/TurtleShell.html.ps1 b/Examples/TurtleShell.html.ps1 index 7a373d1..eb08510 100644 --- a/Examples/TurtleShell.html.ps1 +++ b/Examples/TurtleShell.html.ps1 @@ -2,61 +2,13 @@ param( [string] $Lucky = @' turtle lucky -'@ -) +'@, -"" -"" -"" -"" -"" -"
" -"" -"" -"
" -"" -"" - -@" - -"@ -"" -"" "
" "Examples" - "" "" "" @@ -66,6 +18,7 @@ $Lucky "" "
" "
Sectors" + @" "@ + "
" + "
" + "
" + "
Pies" + @" "@ + "
" + "
" + +) + +) + + +"" +"" +"" +"" +"" +"
" +"" +"" +"
" +"" +"" + +@" + +"@ +"" +"" +$Examples "" "
" "" @@ -225,7 +241,7 @@ function newShell(input, options = {}) { return } if (event?.target?.previousSibling?.value && - event?.target?.previousSibling?.id.match(/^input/)) { + event?.target?.previousSibling?.id.match(/^command/i)) { inputId = event?.target?.previousSibling?.id const outputId = inputId.replace(/^command/i, 'output') } From b5cc9cd189c9860c45d026c9061e92bd32c79861 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Mon, 12 Jan 2026 14:18:02 -0800 Subject: [PATCH 14/29] feat: Reptile -Name, -Run, and streaming support ( Fixes #17, Fixes #18, Fixes #19 ) --- Commands/Get-Reptile.ps1 | 242 +++++++++++++++++++++++++++++---------- 1 file changed, 181 insertions(+), 61 deletions(-) diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 index 130ee75..fa140c1 100644 --- a/Commands/Get-Reptile.ps1 +++ b/Commands/Get-Reptile.ps1 @@ -130,14 +130,47 @@ function Get-Reptile Additionally, because the responses are run in background _thread_ jobs, it limits the overall impact of each request, and thus service is harder to deny. + + ### Reptile Roadmap + + Reptile will Evolve. + + Reptile is a new project, and will grow and change with time. + Implementation is subject to change. + + The next items on the Reptile Roadmap are: + + * Additional Protocol Support + * JsonRPC + * MCP + * XRPC + * New Examples + * Better Variable Input + * More Turtles (and other useful interactive tools) .EXAMPLE reptile .LINK - https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes + https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542 #> [Alias('Reptile','REPL','WebRepl')] param( - # The rootUrl of the server. By default, a random loopback address. + # The name of specific reptile. + # Will check the current directory and the reptile module directory for reptiles. + # If a single reptile exists with that name, it will be run. + [Alias('Species')] + [string] + $ReptileName, + + # If set, will spawn a new instance of the first matching `-ReptileName/-Species` + [Alias('Hatch')] + [switch] + $Run, + + # The rootUrl of the server. + # By default, a random loopback address. + # Randomized loopback addresses are not exposed to the network, + # and do not require running as admin. + [Alias('ServerURL')] [string]$RootUrl= "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", @@ -194,11 +227,41 @@ function Get-Reptile [ScriptBlock] $Initialize = {}, - # The number of nodes to run. + # The number of reptiles to run. + [Alias('EggCount')] [uint32] $NodeCount = 1 ) + if ($ReptileName) { + if (Test-Path $ReptileName) { + return Get-Item $ReptileName + } + + $foundReptiles = @( + Get-Module Reptile | + Split-Path | + Get-ChildItem -Recurse -File -Filter *.ps1 | + Where-Object Name -match '\p{P}Reptile\p{P}' | + Where-Object Name -like "$ReptileName*" + + Get-ChildItem -Filter *.ps1 | + Where-Object Name -match '\p{P}Reptile\p{P}' | + Where-Object Name -like "$ReptileName*" + ) + + # If we found reptiles and want to run them + if ($foundReptiles -and $Run) { + # Launch the script + & $foundReptiles[0] + return + } + + $foundReptiles + return + } + + if ($SupportedCommand -match '^(?>Invoke-Expression|iex)$') { Write-Error "No. Invoke-Expression is unsafe. We will not support this." return @@ -235,17 +298,56 @@ function Get-Reptile $reply.Close() return } + + $supportedCommand = $option.SupportedCommand + + # Another bit of "should not be possible" paranoia: + # Double-check that the list of supported commands in the data block + # matches our list of supported commands. + $dataStatementAllows = + $dataBlock.Ast.EndBlock.Statements[0].CommandsAllowed -replace + '^["'']' -replace '["'']$' + if (($dataStatementAllows -join ',') -ne ($SupportedCommand -join ',')) { + $reply.close() + return + } + + + $out = if ($option.Out -is [ScriptBlock]) { + $option.Out + } else { + { + param($reply) + + process { + if (-not $reply.OutputStream) { throw "no output stream" ; return } + $in = $_ + if ($in.OuterXml) { + $buffer = $OutputEncoding.GetBytes("$($in.OuterXml)") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + } + elseif ($in.html) { + $buffer = $OutputEncoding.GetBytes("$($in.html)") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + } + else { + # or the stringification of the result. + $buffer = $OutputEncoding.GetBytes("$in") + $reply.OutputStream.Write($buffer, 0, $buffer.Length) + } + } + + end { + if ($reply.Close) { + $reply.Close() + } + } + } + } # Then we want to try running the data block try { - $result = $(& $dataBlock *>&1) - # And respond with the outer XML - if ($result.OuterXml) { - $reply.Close($OutputEncoding.GetBytes($result.OuterXml), $false) - } else { - # or the stringification of the result. - $reply.Close($OutputEncoding.GetBytes("$result"), $false) - } + & $dataBlock *>&1 | & $out $reply } catch { # If anything went wrong, though it feels wrong, we want to respond with 200 $reply.StatusCode = 200 @@ -454,9 +556,29 @@ function Get-Reptile continue nextRequest } - } + # Another bit of "should not be possible" paranoia: + # Double-check that the list of supported commands in the data block + # matches our list of supported commands. + $dataStatementAllows = + $dataBlock.Ast.EndBlock.Statements[0].CommandsAllowed -replace + '^["'']' -replace '["'']$' + if (($dataStatementAllows -join ',') -ne ($SupportedCommand -join ',')) { + # we want to write an error. + $Message = @( + "Supported Commands Change Attempt @ $([datetime]::Now)." + "Expected $SupportedCommand, got $DataStatementAllows" + ) -join ' ' + $err = + Write-Error $Message -Category SecurityError -TargetObject $request *>&1 + + $err | errorOut + continue nextRequest + } + } + # Now that we have prepared all of our functions, + # we have the main request loop. # Then listen for the next request :nextRequest while ($httpListener.IsListening) { @@ -466,58 +588,56 @@ function Get-Reptile $request, $reply = $getContext.Result.Request, $getContext.Result.Response - if ($request.Url -match '/xrpc/') { - + # We will not be able to predict head requests + if ($request.httpMethod -eq 'head') { + # so tell the client that the content length is zero and close out. + $reply.ContentLength = 0; $reply.Close() + continue nextRequest } - # Switch what we do next based off of the HTTP Method. - switch ($request.httpMethod) { - get { - # If it's get, return the REPL - $reply.ContentType = 'text/html' - $replBytes = $OutputEncoding.GetBytes("$($io.Shell)") - $reply.Close($replBytes, $false) - } - head { - # If it's head, return 0 for content length and close out. - $reply.ContentLength = 0; $reply.Close() - } - default { - # Any other verb we'll try to evaluate the body. - # Of course, if there is no body - if (-not $request.InputStream) { - Write-Host "No input" -ForegroundColor Yellow - $reply.ContentLength = 0 - $reply.Close() # close out - continue nextRequest # and continue to the next request. - } - - $dataBlock = $null - - . getCommandAndInput - - # Now we can launch an inner thread job to run the script and reply. - $replyJobParameters = @{ - ScriptBlock=$ReplyDefinition - ThrottleLimit=1kb - ArgumentList=@( - $dataBlock, $reply, - [Ordered]@{ - 'jsonrpc' = $jsonRpcParsed - } - ) - InitializationScript=$Initialize - } - - # Doing this makes the server more resilient, but will be slower than directly handling each request. - Start-ThreadJob @replyJobParameters - - # Clean up any completed requests and continue on with the loop. - Get-Job | - Where-Object State -eq 'Completed' | - Remove-Job -Force - } + if ($request.httpMethod -eq 'get') { + # If it's get, return the REPL + $reply.ContentType = 'text/html' + $replBytes = $OutputEncoding.GetBytes("$($io.Shell)") + $reply.Close($replBytes, $false) + continue nextRequest } + + # Any other verb we'll try to evaluate the body. + # Of course, if there is no body + if (-not $request.InputStream) { + Write-Host "No input" -ForegroundColor Yellow + $reply.ContentLength = 0 + $reply.Close() # close out + continue nextRequest # and continue to the next request. + } + + $dataBlock = $null + + . getCommandAndInput + + # Now we can launch an inner thread job to run the script and reply. + $replyJobParameters = @{ + ScriptBlock=$ReplyDefinition + ThrottleLimit=1kb + ArgumentList=@( + $dataBlock, $reply, + [Ordered]@{ + 'supportedCommand' = $SupportedCommand + 'jsonrpc' = $jsonRpcParsed + } + ) + InitializationScript=$Initialize + } + + # Doing this makes the server more resilient, but will be slower than directly handling each request. + Start-ThreadJob @replyJobParameters + + # Clean up any completed requests and continue on with the loop. + Get-Job | + Where-Object State -eq 'Completed' | + Remove-Job -Force + } } From aa749589ae68f95ca3e221fb22134475cad1e8f2 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Tue, 13 Jan 2026 11:58:49 -0800 Subject: [PATCH 15/29] feat: Turtle Reptile Example ( Fixes #6 ) More minor layout changes --- Examples/Turtle.Reptile.ps1 | 10 +++++++--- Examples/TurtleShell.html.ps1 | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Examples/Turtle.Reptile.ps1 b/Examples/Turtle.Reptile.ps1 index 315073b..94aacfc 100644 --- a/Examples/Turtle.Reptile.ps1 +++ b/Examples/Turtle.Reptile.ps1 @@ -3,12 +3,14 @@ Push-Location $PSScriptRoot Reptile -Initialize { - Import-Module Turtle, MarkX, OEmbed -Global + Import-Module Turtle, MarkX, OEmbed, Gradient -Global $env:TURTLE_BOT = $true Set-Alias Random Get-Random function RandomColor { "#{0:x6}" -f (Get-Random -Max 0xffffff) } function RandomAngle {Get-Random -Min -360 -Max 360 } + function RandomPercent { "$(Get-Random -Min 0.01 -Max 99.99)%" } + function ColorWheel { "" + +@" + + +"@ "
" "" -"" -"
" + + +
+" "" "" @@ -159,89 +242,142 @@ $Examples "
    " "
" -"" "" "" \ No newline at end of file From 39a6f2d7816effa2f3757d3df698cf0e3713fec2 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:26:04 -0800 Subject: [PATCH 18/29] feat: Sleepy Reptile ( Fixes #26 ) --- Examples/Sleepy.Reptile.ps1 | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Examples/Sleepy.Reptile.ps1 diff --git a/Examples/Sleepy.Reptile.ps1 b/Examples/Sleepy.Reptile.ps1 new file mode 100644 index 0000000..b8b5c2a --- /dev/null +++ b/Examples/Sleepy.Reptile.ps1 @@ -0,0 +1,33 @@ +<# +.SYNOPSIS + A Sleepy Reptile +.DESCRIPTION + A Sleepy Reptile. + + A simple sleepy reptile that demonstrates asynchronous output +.NOTES + This exposes a single command `SayWhen`. + + `SayWhen` will say a message after a short sleep +#> +Reptile -SupportedCommand @('SayWhen') -Initialize { + function SayWhen( + [string]$Message = "when", + [ValidateRange('00:00:00', '00:00:15')] + [Timespan]$time = '00:00:01' + ) { + Start-Sleep -Milliseconds $time.TotalMilliseconds + "

$([Web.HttpUtility]::HtmlEncode($message))

" + } +} -Shell ( +@" +
+ + + +
+"@ +) + From 74dababd204b47cbbdfcadc2778e320c87e719f9 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:27:06 -0800 Subject: [PATCH 19/29] feat: Gradient reptile ( Fixes #9 ) Adding labels --- Examples/Gradient.Reptile.ps1 | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Examples/Gradient.Reptile.ps1 b/Examples/Gradient.Reptile.ps1 index c323965..5dd0def 100644 --- a/Examples/Gradient.Reptile.ps1 +++ b/Examples/Gradient.Reptile.ps1 @@ -14,13 +14,16 @@ Reptile -SupportedCommand @( Gradient Reptile
- - - + + + + + " From f4d642dfb678d30a133538b34b5dc893f5ec4955 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:45:42 -0800 Subject: [PATCH 20/29] feat: Reptile Build ( Fixes #32 ) --- .github/workflows/BuildReptile.yml | 497 +++++++++++++++++++++ Build/GitHub/Jobs/BuildReptile.psd1 | 10 + Build/GitHub/Steps/PublishTestResults.psd1 | 10 + Build/Reptile.GitHubWorkflow.PSDevOps.ps1 | 12 + Reptile.tests.ps1 | 25 ++ 5 files changed, 554 insertions(+) create mode 100644 .github/workflows/BuildReptile.yml create mode 100644 Build/GitHub/Jobs/BuildReptile.psd1 create mode 100644 Build/GitHub/Steps/PublishTestResults.psd1 create mode 100644 Build/Reptile.GitHubWorkflow.PSDevOps.ps1 create mode 100644 Reptile.tests.ps1 diff --git a/.github/workflows/BuildReptile.yml b/.github/workflows/BuildReptile.yml new file mode 100644 index 0000000..b9fce19 --- /dev/null +++ b/.github/workflows/BuildReptile.yml @@ -0,0 +1,497 @@ + +name: Build Module +on: + push: + pull_request: + workflow_dispatch: +jobs: + TestPowerShellOnLinux: + runs-on: ubuntu-latest + steps: + - name: InstallPester + id: InstallPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Installs Pester + .Description + Installs Pester + #> + param( + # The maximum pester version. Defaults to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99' + ) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber + Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters + - name: Check out repository + uses: actions/checkout@v2 + - name: RunPester + id: RunPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + $Parameters.NoCoverage = ${env:NoCoverage} + $Parameters.NoCoverage = $parameters.NoCoverage -match 'true'; + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Runs Pester + .Description + Runs Pester tests after importing a PowerShell module + #> + param( + # The module path. If not provided, will default to the second half of the repository ID. + [string] + $ModulePath, + # The Pester max version. By default, this is pinned to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99', + + # If set, will not collect code coverage. + [switch] + $NoCoverage + ) + + $global:ErrorActionPreference = 'continue' + $global:ProgressPreference = 'silentlycontinue' + + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + if (-not $ModulePath) { $ModulePath = ".\$moduleName.psd1" } + $importedPester = Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion + $importedModule = Import-Module $ModulePath -Force -PassThru + $importedPester, $importedModule | Out-Host + + $codeCoverageParameters = @{ + CodeCoverage = "$($importedModule | Split-Path)\*-*.ps1" + CodeCoverageOutputFile = ".\$moduleName.Coverage.xml" + } + + if ($NoCoverage) { + $codeCoverageParameters = @{} + } + + + $result = + Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml @codeCoverageParameters + + "::set-output name=TotalCount::$($result.TotalCount)", + "::set-output name=PassedCount::$($result.PassedCount)", + "::set-output name=FailedCount::$($result.FailedCount)" | Out-Host + if ($result.FailedCount -gt 0) { + "::debug:: $($result.FailedCount) tests failed" + foreach ($r in $result.TestResult) { + if (-not $r.Passed) { + "::error::$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" + } + } + throw "::error:: $($result.FailedCount) tests failed" + } + } @Parameters + - name: PublishTestResults + uses: actions/upload-artifact@main + with: + name: PesterResults + path: '**.TestResults.xml' + if: ${{always()}} + TagReleaseAndPublish: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: TagModuleVersion + id: TagModuleVersion + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.TagAnnotationFormat = ${env:TagAnnotationFormat} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: TagModuleVersion $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The tag version format (default value: '$($imported.Name) $(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagAnnotationFormat = '$($imported.Name) $($imported.Version)' + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Tagging" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $existingTags = git tag --list + + @" + Target Version: $targetVersion + + Existing Tags: + $($existingTags -join [Environment]::NewLine) + "@ | Out-Host + + $versionTagExists = $existingTags | Where-Object { $_ -match $targetVersion } + + if ($versionTagExists) { + "::warning::Version $($versionTagExists)" + return + } + + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $UserEmail) { $UserEmail = "$UserName@github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + git tag -a $targetVersion -m $ExecutionContext.InvokeCommand.ExpandString($TagAnnotationFormat) + git push origin --tags + + if ($env:GITHUB_ACTOR) { + exit 0 + }} @Parameters + - name: ReleaseModule + id: ReleaseModule + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.ReleaseNameFormat = ${env:ReleaseNameFormat} + $Parameters.ReleaseAsset = ${env:ReleaseAsset} + $Parameters.ReleaseAsset = $parameters.ReleaseAsset -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: ReleaseModule $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The release name format (default value: '$($imported.Name) $($imported.Version)') + [string] + $ReleaseNameFormat = '$($imported.Name) $($imported.Version)', + + # Any assets to attach to the release. Can be a wildcard or file name. + [string[]] + $ReleaseAsset + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping GitHub release" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $targetReleaseName = $targetVersion + $releasesURL = 'https://api.github.com/repos/${{github.repository}}/releases' + "Release URL: $releasesURL" | Out-Host + $listOfReleases = Invoke-RestMethod -Uri $releasesURL -Method Get -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + + $releaseExists = $listOfReleases | Where-Object tag_name -eq $targetVersion + + if ($releaseExists) { + "::warning::Release '$($releaseExists.Name )' Already Exists" | Out-Host + $releasedIt = $releaseExists + } else { + $releasedIt = Invoke-RestMethod -Uri $releasesURL -Method Post -Body ( + [Ordered]@{ + owner = '${{github.owner}}' + repo = '${{github.repository}}' + tag_name = $targetVersion + name = $ExecutionContext.InvokeCommand.ExpandString($ReleaseNameFormat) + body = + if ($env:RELEASENOTES) { + $env:RELEASENOTES + } elseif ($imported.PrivateData.PSData.ReleaseNotes) { + $imported.PrivateData.PSData.ReleaseNotes + } else { + "$($imported.Name) $targetVersion" + } + draft = if ($env:RELEASEISDRAFT) { [bool]::Parse($env:RELEASEISDRAFT) } else { $false } + prerelease = if ($env:PRERELEASE) { [bool]::Parse($env:PRERELEASE) } else { $false } + } | ConvertTo-Json + ) -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Content-type" = "application/json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + } + + + + + + if (-not $releasedIt) { + throw "Release failed" + } else { + $releasedIt | Out-Host + } + + $releaseUploadUrl = $releasedIt.upload_url -replace '\{.+$' + + if ($ReleaseAsset) { + $fileList = Get-ChildItem -Recurse + $filesToRelease = + @(:nextFile foreach ($file in $fileList) { + foreach ($relAsset in $ReleaseAsset) { + if ($relAsset -match '[\*\?]') { + if ($file.Name -like $relAsset) { + $file; continue nextFile + } + } elseif ($file.Name -eq $relAsset -or $file.FullName -eq $relAsset) { + $file; continue nextFile + } + } + }) + + $releasedFiles = @{} + foreach ($file in $filesToRelease) { + if ($releasedFiles[$file.Name]) { + Write-Warning "Already attached file $($file.Name)" + continue + } else { + $fileBytes = [IO.File]::ReadAllBytes($file.FullName) + $releasedFiles[$file.Name] = + Invoke-RestMethod -Uri "${releaseUploadUrl}?name=$($file.Name)" -Headers @{ + "Accept" = "application/vnd.github+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } -Body $fileBytes -ContentType Application/octet-stream + $releasedFiles[$file.Name] + } + } + + "Attached $($releasedFiles.Count) file(s) to release" | Out-Host + } + + + + } @Parameters + - name: PublishPowerShellGallery + id: PublishPowerShellGallery + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.Exclude = ${env:Exclude} + $Parameters.Exclude = $parameters.Exclude -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: PublishPowerShellGallery $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + [string[]] + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif', 'docs[/\]*') + ) + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + if (-not $Exclude) { + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif','docs[/\]*') + } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + @" + ::group::PSBoundParameters + $($PSBoundParameters | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Gallery Publish" | Out-Host + return + } + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $foundModule = try { Find-Module -Name $imported.Name -ErrorAction SilentlyContinue} catch {} + + if ($foundModule -and (([Version]$foundModule.Version) -ge ([Version]$imported.Version))) { + "::warning::Gallery Version of $moduleName is more recent ($($foundModule.Version) >= $($imported.Version))" | Out-Host + } else { + + $gk = '${{secrets.GALLERYKEY}}' + + $rn = Get-Random + $moduleTempFolder = Join-Path $pwd "$rn" + $moduleTempPath = Join-Path $moduleTempFolder $moduleName + New-Item -ItemType Directory -Path $moduleTempPath -Force | Out-Host + + Write-Host "Staging Directory: $ModuleTempPath" + + $imported | Split-Path | + Get-ChildItem -Force | + Where-Object Name -NE $rn | + Copy-Item -Destination $moduleTempPath -Recurse + + $moduleGitPath = Join-Path $moduleTempPath '.git' + Write-Host "Removing .git directory" + if (Test-Path $moduleGitPath) { + Remove-Item -Recurse -Force $moduleGitPath + } + + if ($Exclude) { + "::notice::Attempting to Exlcude $exclude" | Out-Host + Get-ChildItem $moduleTempPath -Recurse | + Where-Object { + foreach ($ex in $exclude) { + if ($_.FullName -like $ex) { + "::notice::Excluding $($_.FullName)" | Out-Host + return $true + } + } + } | + Remove-Item + } + + Write-Host "Module Files:" + Get-ChildItem $moduleTempPath -Recurse + Write-Host "Publishing $moduleName [$($imported.Version)] to Gallery" + Publish-Module -Path $moduleTempPath -NuGetApiKey $gk + if ($?) { + Write-Host "Published to Gallery" + } else { + Write-Host "Gallery Publish Failed" + exit 1 + } + } + } @Parameters + BuildReptile: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@main + diff --git a/Build/GitHub/Jobs/BuildReptile.psd1 b/Build/GitHub/Jobs/BuildReptile.psd1 new file mode 100644 index 0000000..af20cce --- /dev/null +++ b/Build/GitHub/Jobs/BuildReptile.psd1 @@ -0,0 +1,10 @@ +@{ + "runs-on" = "ubuntu-latest" + if = '${{ success() }}' + steps = @( + @{ + name = 'Check out repository' + uses = 'actions/checkout@main' + } + ) +} \ No newline at end of file diff --git a/Build/GitHub/Steps/PublishTestResults.psd1 b/Build/GitHub/Steps/PublishTestResults.psd1 new file mode 100644 index 0000000..e8111e8 --- /dev/null +++ b/Build/GitHub/Steps/PublishTestResults.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PublishTestResults' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'PesterResults' + path = '**.TestResults.xml' + } + if = '${{always()}}' +} + diff --git a/Build/Reptile.GitHubWorkflow.PSDevOps.ps1 b/Build/Reptile.GitHubWorkflow.PSDevOps.ps1 new file mode 100644 index 0000000..35312ca --- /dev/null +++ b/Build/Reptile.GitHubWorkflow.PSDevOps.ps1 @@ -0,0 +1,12 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubWorkflow + +Push-Location ($PSScriptRoot | Split-Path) +New-GitHubWorkflow -Name "Build Module" -On Push, + PullRequest, + Demand -Job TestPowerShellOnLinux, + TagReleaseAndPublish, BuildReptile -OutputPath .\.github\workflows\BuildReptile.yml + +Pop-Location \ No newline at end of file diff --git a/Reptile.tests.ps1 b/Reptile.tests.ps1 new file mode 100644 index 0000000..a1badd3 --- /dev/null +++ b/Reptile.tests.ps1 @@ -0,0 +1,25 @@ +describe Reptile { + context 'Is a mostly safe repl' { + it 'Runs things in restricted language' { + $r = Reptile + + Invoke-RestMethod -Uri $r.Url -Body "1+1" -Method Post | + Should -Be 2 + + $r.HttpListener.Stop() + + $r | Remove-Job -Force + } + + it 'Will not run unapproved commands' { + $r = Reptile + + Invoke-RestMethod -Uri $r.Url -Body "Stop-Process -id $pid" -Method Post | + Should -Match "line:\d" + + $r.HttpListener.Stop() + + $r | Remove-Job -Force + } + } +} \ No newline at end of file From 0e5938c16c5f9ba68b034ceee81fcff1f6cdccb0 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:46:30 -0800 Subject: [PATCH 21/29] feat: Reptile Tests ( Fixes #31 ) --- Reptile.tests.ps1 | 2 -- 1 file changed, 2 deletions(-) diff --git a/Reptile.tests.ps1 b/Reptile.tests.ps1 index a1badd3..1e879fe 100644 --- a/Reptile.tests.ps1 +++ b/Reptile.tests.ps1 @@ -7,7 +7,6 @@ describe Reptile { Should -Be 2 $r.HttpListener.Stop() - $r | Remove-Job -Force } @@ -18,7 +17,6 @@ describe Reptile { Should -Match "line:\d" $r.HttpListener.Stop() - $r | Remove-Job -Force } } From 91c3e39440c10b73b05229b57e729dabf6bcb082 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:47:48 -0800 Subject: [PATCH 22/29] feat: Reptile security guide ( Fixes #4, Fixes #29 ) --- security.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 security.md diff --git a/security.md b/security.md new file mode 100644 index 0000000..d217cae --- /dev/null +++ b/security.md @@ -0,0 +1,72 @@ +## Reptile Security + +Reptile is designed to be *mostly* safe. + +By default, without exposing commands, Reptile should be completely safe. + +By exposing any commands, Reptiles are (almost) as dangerous as the commands they can run. + +Please use locally, with commands you have threat modelled. + +If you find a security concern about Reptile, please open an issue. + +If you want to read more about how Reptile works and keeps things mostly safe, read below: + +### Reptile Safety + +Reptile runs in data statements for safety. + +Data statements are a constrained form of PowerShell that primarily process data. + +Data statements can also run any number of -SupportedCommands. + +Data statements cannot access most variables, use methods, reference most types, or loop. + +This makes them fairly ideal for a mostly safe REPL loop. + +If a command is not supported, it will not be run. + +This means that as long as no supported command allow arbitrary code injection, you are safe. + +However, if you ran `reptile -supportedCommand python`, +then that would be a much more dangerous reptile to deal with. + +Which is why there are some additional safety measures. + +#### Additional Safety Measures + +##### Local Loopback Port + +By default, reptile will run on a random local loopback port. + +This has three security benefits: + +1. It does not require elevation to administrator +2. It does not open an external port +3. It is less predictable + +If you are running reptile locally as intended, you control which scripts you run, and they can run as you. + +If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. + +##### AST Inspection + +Scripts that are not parsable as a data block will never be run. + +Additionally, if someone succeeds in the miracle of escaping syntax, +and the AST is not a single data statement, it will not run. + +##### Background Execution + +All data blocks will be evaluated in a background job. + +This is a trade off of performance for security. + +Responses will take longer than they would inline, +but any potential data corruption is quite literally limited in scope. + +The background jobs cannot access the main server thread, +and so have a much more difficult time escalating any potential jailbreaks. + +Additionally, because the responses are run in background _thread_ jobs, +it limits the overall impact of each request, and thus service is harder to deny. From 725b11fbc24baff454d11a2a3584a12d8e6fb55f Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:49:59 -0800 Subject: [PATCH 23/29] feat: Reptile sponsorship ( Fixes #30 ) --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..36bd853 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [StartAutomating] From 827f9387bd232736aa6b4771d9fb5f7bf2083b21 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:54:19 -0800 Subject: [PATCH 24/29] docs: Simplifying synopsis / description --- Commands/Get-Reptile.ps1 | 4 ++-- README.md | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Commands/Get-Reptile.ps1 b/Commands/Get-Reptile.ps1 index c62f1cc..e2ffcfb 100644 --- a/Commands/Get-Reptile.ps1 +++ b/Commands/Get-Reptile.ps1 @@ -2,9 +2,9 @@ function Get-Reptile { <# .SYNOPSIS - Gets Reptiles + Reptile .DESCRIPTION - Gets Reptiles - Read Evaluate Print Terminal Input Loop Editor + Reptile - Read Evaluate Print Terminal Input Loop Editor .NOTES ## Reptile ### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL diff --git a/README.md b/README.md index e1e8d77..50f7d6f 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,20 @@ and so have a much more difficult time escalating any potential jailbreaks. Additionally, because the responses are run in background _thread_ jobs, it limits the overall impact of each request, and thus service is harder to deny. + +### Reptile Roadmap + +Reptile will Evolve. + +Reptile is a new project, and will grow and change with time. +Implementation is subject to change. + +The next items on the Reptile Roadmap are: + +* Additional Protocol Support + * JsonRPC + * MCP + * XRPC +* New Examples +* Better Variable Input +* More Turtles (and other useful interactive tools) From 549e3cabaf589591ce7e5a2f732433533d0f297f Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 15:55:11 -0800 Subject: [PATCH 25/29] docs: Simplifying synopsis / description --- README.md | 2 ++ README.md.ps1 | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 50f7d6f..fc7273a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# Reptile +### Reptile - Read Evaluate Print Terminal Input Loop Editor ## Reptile ### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL diff --git a/README.md.ps1 b/README.md.ps1 index 3754e23..63b5ddd 100644 --- a/README.md.ps1 +++ b/README.md.ps1 @@ -1,2 +1,7 @@ $help = Get-Help Reptile + +"# $($help.SYNOPSIS)" + +"### $($help.Description.text -join [Environment]::NewLine)" + $help.alertset.alert.text -join [Environment]::NewLine \ No newline at end of file From 86be370c308a97faecfa541f31e97a151c9f816a Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 16:03:00 -0800 Subject: [PATCH 26/29] feat: Turtle Reptile Example ( Fixes #6 ) Adding tips --- Examples/Turtle.Reptile.ps1 | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Examples/Turtle.Reptile.ps1 b/Examples/Turtle.Reptile.ps1 index 94aacfc..1f6ea9e 100644 --- a/Examples/Turtle.Reptile.ps1 +++ b/Examples/Turtle.Reptile.ps1 @@ -34,8 +34,31 @@ Reptile -Initialize { "

$([Web.HttpUtility]::HtmlEncode($message))

" } } -} -Repl (./TurtleShell.html.ps1) -SupportedCommand @( - 'Turtle', 'Get-Turtle' + + function tips { + $tips = @( + + '`colorwheel` draws a color wheel' + + '`Get-Random` gets random numbers (or random items)' + + 'You can multiply lists to repeat them: `turtle @("rotate", (360/5), "forward", 42 * 5)`' + + "There are many types of flower (flower, triflower, petalflower, goldenflower)" + + 'Turtle can do math. Try./ `turtle rotate (360/4) forward 42`' + + 'Turtle can make patterns. Just add `pattern` to the end of a command.' + ) + + "

$($tips | + Get-Random | + ConvertFrom-Markdown | + Select-Object -ExpandProperty html + )

" + } +} -Repl (./TurtleShell.html.ps1) -SupportedCommand @( + 'Turtle', 'Get-Turtle', '🐢' 'MarkX', 'Markdown', 'Get-MarkX', @@ -48,6 +71,8 @@ Reptile -Initialize { 'ColorWheel' 'Say' + + 'tip','tips' ) Pop-Location \ No newline at end of file From dc79774b4bc26b16cd479a32319eb2bf31d8a1cb Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 16:06:49 -0800 Subject: [PATCH 27/29] docs: Reptile code of conduct ( Fixes #27 ) --- code_of_conduct.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 code_of_conduct.md diff --git a/code_of_conduct.md b/code_of_conduct.md new file mode 100644 index 0000000..33a4b70 --- /dev/null +++ b/code_of_conduct.md @@ -0,0 +1,7 @@ +## Reptile Code of Conduct + +Please be kind and do not harass any creators or community members. + +Please do not make dangerous reptiles. + +Definitely do not attempt to make Reptile less secure, or introduce changes that might harm others. From 38d305d1bbf8e21d736e4eddb8dd8b7e0892d00f Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 16:08:20 -0800 Subject: [PATCH 28/29] docs: Reptile contributing ( Fixes #28 ) --- contributing.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 contributing.md diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..a77682a --- /dev/null +++ b/contributing.md @@ -0,0 +1,9 @@ +Contributions are welcome! + +If you can think of anything worthwhile to contribute, please file an issue or start a discussion. + +Contributions of dangerous reptiles are not welcome. + +Please carefully consider the security implications of whatever you may wish to contribute. + + From d42b53bb35ea92a5d20baaefa027a881b2a0f6c0 Mon Sep 17 00:00:00 2001 From: StartAutomating Date: Wed, 21 Jan 2026 16:09:45 -0800 Subject: [PATCH 29/29] release: Reptile 0.1 Adding CHANGELOG and Release Notes --- CHANGELOG.md | 16 ++++++++++++++++ Reptile.psd1 | 27 ++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79e0f1c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +## Reptile 0.1: + +* Initial Release of Reptile (#1) +* Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL. +* Reptile is simple (#2) scalable (#3) and mostly safe (#4) +* `Get-Reptile` / `Reptile` / 🦎, 🐊 gets and hatches reptiles (#5, 24) +* Turtle.Reptile is a fun example (#6) + * Is has limited variable support (#22) +* Calculating Reptile is a safe calculator (#7) +* Days Until Reptile tells time until (#8) +* Gradient Reptile generates gradients (#9) +* Sleepy Reptile shows multiple delayed returns (#26) +* `Get-Reptile` can get a specific `-ReptileName/-Name/-Species` (#17) +* `Get-Reptile` can `-Run` or `-Hatch` a new instance (#18) +* `Reptile` can change its skin (#23) +* `Reptile` returns data in chunks (#25) diff --git a/Reptile.psd1 b/Reptile.psd1 index ab3565b..e49cf98 100644 --- a/Reptile.psd1 +++ b/Reptile.psd1 @@ -33,7 +33,7 @@ Copyright = '2025 Start Automating' Description = 'Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL.' # Minimum version of the PowerShell engine required by this module -# PowerShellVersion = '' +PowerShellVersion = '7.0' # Name of the PowerShell host required by this module # PowerShellHostName = '' @@ -69,16 +69,16 @@ Description = 'Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. -FunctionsToExport = '*' +FunctionsToExport = 'Get-Reptile' # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = '*' +CmdletsToExport = @() # Variables to export from this module -VariablesToExport = '*' +VariablesToExport = @() # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = '*' +AliasesToExport = 'Reptile', '🦎','🐊' # DSC resources to export from this module # DscResourcesToExport = @() @@ -108,6 +108,23 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = @' +## Reptile 0.1: + +* Initial Release of Reptile (#1) +* Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL. +* Reptile is simple (#2) scalable (#3) and mostly safe (#4) +* `Get-Reptile` / `Reptile` / 🦎, 🐊 gets and hatches reptiles (#5, 24) +* Turtle.Reptile is a fun example (#6) + * Is has limited variable support (#22) +* Calculating Reptile is a safe calculator (#7) +* Days Until Reptile tells time until (#8) +* Gradient Reptile generates gradients (#9) +* Sleepy Reptile shows multiple delayed returns (#26) +* `Get-Reptile` can get a specific `-ReptileName/-Name/-Species` (#17) +* `Get-Reptile` can `-Run` or `-Hatch` a new instance (#18) +* `Reptile` can change its skin (#23) +* `Reptile` returns data in chunks (#25) + '@ # Prerelease string of this module