Compare commits

..
10 Commits
Author SHA1 Message Date
blanceandClaude Fable 5 698c102c1b Stamp VERSION.txt with c3ad14c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNu4zbdNqYpX5jAVcU31GA
2026-08-30 21:26:13 -05:00
blance c3ad14cb88 Print the exact quoted literal after a successful login
The decoded password works and contains a $, so the original suspicion
was right about the upload script even though it was wrong about this
test - typing into a prompt never involved a string literal.

On success the test now prints the line to paste, apostrophes doubled
and single quotes included, and explains why: inside double quotes
PowerShell reads $Pass2026 as a variable and sends a shorter string, so
a correct password still fails. It also flags % for .bat files and
suggests keeping the password in its own file, where no quoting applies.

Checked against a $ password, an embedded apostrophe, a %, and a plain
one that correctly draws no advice at all.
2026-08-30 21:26:13 -05:00
blanceandClaude Fable 5 1965e07c7c Stamp VERSION.txt with b34360c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNu4zbdNqYpX5jAVcU31GA
2026-08-30 21:03:57 -05:00
blance b34360c917 Read FileZilla site exports, decoding the Base64 password
FileZilla works, every copy of its settings fails: the <Pass> value in
an exported sitemanager XML is Base64, not the password. Copied out
verbatim it is rejected by the server every time, which is exactly the
"works in the GUI, fails in a script" shape we have been chasing.

The test can now read the export itself - listing sites by name so the
right practice is picked out of the nine - and decodes the password,
reporting both lengths so the difference is visible. A master-password
protected export (encoding="crypt") cannot be read, and says so.

A typed password that is still Base64 is flagged the same way, since
pasting it by hand is the other route to the same mistake. Guarded
against false positives: it must decode to printable ASCII, so ordinary
passwords are not accused.
2026-08-30 21:03:57 -05:00
blanceandClaude Fable 5 1edd6520a3 Stamp VERSION.txt with 1fa89da
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNu4zbdNqYpX5jAVcU31GA
2026-08-30 20:50:26 -05:00
blance 1fa89da183 Confirm the typed password, and read the auth outcome from the log
Melissa's session log settles the earlier theory: WinSCP negotiated,
sent the password, and srtSSHServer answered "Password authentication
failed". The password was typed into this script rather than read from a
string literal, so PowerShell interpolation was never involved and the
credential is simply not being accepted.

Two changes follow from that. The password is now entered twice and
compared - nothing echoes while typing, so a slipped paste and a good
one look identical, and a test on the wrong string proves nothing.

And the failure report now reads the log rather than dumping matching
lines: it separates "the server rejected the password we sent" from "the
server never offered password auth" from "it failed before any password
was sent", because those three have completely different fixes.
2026-08-30 20:50:26 -05:00
blanceandClaude Fable 5 585befe922 Stamp VERSION.txt with 0747612
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNu4zbdNqYpX5jAVcU31GA
2026-08-30 20:33:03 -05:00
blance 0747612810 Make the login test work next to any uploader, not just this one
Melissa is going with the script she built with ChatGPT, so the
diagnostic has to stand on its own. It now finds WinSCPnet.dll wherever
it is - a WinSCP folder beside the script, one folder up, a normal
WinSCP install under Program Files, or anywhere below the script - and
asks for the path only if all of that misses.

Unblock-File is registered but throws on Linux, so Get-Command was not a
usable guard for it; wrapped instead.
2026-08-30 20:33:03 -05:00
blanceandClaude Fable 5 d499721132 Stamp VERSION.txt with e384719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNu4zbdNqYpX5jAVcU31GA
2026-08-30 20:28:32 -05:00
blance e384719ecb Add per-practice logins, a standalone login test, and pre-commit checks
The ChatGPT transcript showed each CAMBS practice has its own SFTP
account (YSMD is YAM62682 on io.myallylogin.com:14147), not one shared
entity login - so the wizard needed a way to set those without hand-
writing nine practice.json files. Option 5 does that, and the settings
screen now lists which login each practice will actually use.

test-login.bat tests one account on its own, with the details typed in
rather than read from a config, which isolates the account from whatever
a script is doing to the credentials. It reports password length,
stray whitespace, and $ / backtick characters - the last being the usual
reason a login works in the WinSCP window but fails from a script, since
"$pass" interpolates in PowerShell while '$pass' does not. On failure it
prints the authentication methods the server offered.

check.sh runs what has actually caught bugs here: non-ASCII characters,
"$name?" (? is legal in an unbraced variable name, so it renders empty -
this bit twice), parse errors, 5.1 syntax compatibility, and JSON validity.
2026-08-30 20:28:32 -05:00
5 changed files with 666 additions and 4 deletions
+121 -3
View File
@@ -323,6 +323,121 @@ function Add-Practices($entityName) {
}
}
function Get-PracticeNames($entityName) {
$export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1"
if (-not (Test-Path $export1)) { return @() }
return @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name })
}
# Same rules engine.ps1 uses: practice.json replaces or adds to entity.json for
# that one practice, so what gets tested here is what will actually be sent.
function Get-MergedPracticeConfig($entityName, $practiceName, $entityConfig) {
$file = Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName) "practice.json"
$merged = $entityConfig | ConvertTo-Json -Depth 10 | ConvertFrom-Json
if (-not (Test-Path $file)) { return $merged }
$override = Get-Content $file -Raw | ConvertFrom-Json
foreach ($section in $override.PSObject.Properties) {
if ($section.Name -like "_*") { continue }
if ($null -eq $merged.PSObject.Properties[$section.Name]) {
$merged | Add-Member -NotePropertyName $section.Name -NotePropertyValue $section.Value
} elseif ($section.Value -is [System.Management.Automation.PSCustomObject]) {
foreach ($prop in $section.Value.PSObject.Properties) {
$merged.$($section.Name) | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
}
} else {
Set-Field $merged $section.Name $section.Value
}
}
return $merged
}
function Set-PracticeLogin($entityName, $entityConfig) {
$practices = Get-PracticeNames $entityName
if ($practices.Count -eq 0) {
Write-Host ""
Write-Host "No practice folders yet - add some first." -ForegroundColor Yellow
return
}
$choice = Read-Choice "Which practice?" $practices 1
$practiceName = $practices[$choice - 1]
$dir = Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName
$file = Join-Path $dir "practice.json"
$existing = $null
if (Test-Path $file) { $existing = Get-Content $file -Raw | ConvertFrom-Json }
Write-Section "$practiceName"
Write-Host "Anything you set here is used for $practiceName only." -ForegroundColor DarkGray
Write-Host "Anything you leave alone still comes from $entityName." -ForegroundColor DarkGray
$ftps = [ordered]@{}
if ($existing -and $existing.ftps) {
foreach ($p in $existing.ftps.PSObject.Properties) { $ftps[$p.Name] = $p.Value }
}
Write-Host ""
if (Read-YesNo "Does $practiceName have its own username and password?" "y") {
Write-Host ""
$ftps["username"] = Read-Answer "Username for $practiceName" $ftps["username"]
$keep = $false
if ($ftps["password"]) {
Write-Host ""
Write-Host "A password is already saved for $practiceName." -ForegroundColor DarkGray
$keep = Read-YesNo "Keep it?" "y"
}
if (-not $keep) { $ftps["password"] = Read-Answer "Password for $practiceName" $null }
}
Write-Host ""
if (Read-YesNo "Does $practiceName use a different server than the rest of ${entityName}?" "n") {
$srv = Read-ServerDetails "$practiceName" $existing.ftps
$ftps["host"] = $srv.host
$ftps["protocol"] = $srv.protocol
$ftps["username"] = $srv.username
$ftps["password"] = $srv.password
}
Write-Host ""
if (Read-YesNo "Does $practiceName use a different folder on the server?" "n") {
$ftps["tiff_path"] = Read-RemotePath "$practiceName's daily image files" $ftps["tiff_path"]
}
if ($ftps.Count -eq 0) {
if (Test-Path $file) {
Remove-Item $file -Force
Write-Host ""
Write-Host "Nothing set - removed $practiceName's practice.json." -ForegroundColor Gray
} else {
Write-Host ""
Write-Host "Nothing set for $practiceName." -ForegroundColor Gray
}
return
}
$out = [ordered]@{
"_note" = "Settings for $practiceName only. Anything not listed comes from ..\..\entity.json."
ftps = [PSCustomObject]$ftps
}
([PSCustomObject]$out) | ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8
Write-Host ""
Write-Host "Saved $file" -ForegroundColor Green
}
function Show-PracticeLogins($entityName, $entityConfig) {
$practices = Get-PracticeNames $entityName
if ($practices.Count -eq 0) { return }
Write-Host ""
Write-Host " Practices" -ForegroundColor White
foreach ($p in $practices) {
$merged = Get-MergedPracticeConfig $entityName $p $entityConfig
$own = Test-Path (Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $p) "practice.json")
$tag = if ($own) { "own settings" } else { "uses $entityName's" }
Write-Host (" {0,-12} {1,-18} login: {2}" -f $p, $tag, (Show-Value $merged.ftps.username))
}
}
function Edit-Entity($entityName) {
$config = Get-EntityConfig $entityName
if (-not $config) {
@@ -340,15 +455,17 @@ function Edit-Entity($entityName) {
while ($true) {
Write-Header "$entityName - current settings"
Show-Settings $config
Show-PracticeLogins $entityName $config
$choice = Read-Choice "What do you want to change?" @(
"The daily-upload server (address, protocol, login)",
"Where the daily image files go on the server",
"Where PT STMT files from the dated folder go",
"Patient files (server and folder)",
"Give one practice its own login or folder",
"Add practice folders",
"Nothing - save and go back"
) 6
) 7
switch ($choice) {
1 {
@@ -396,8 +513,9 @@ function Edit-Entity($entityName) {
Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray
}
}
5 { Add-Practices $entityName }
6 {
5 { Set-PracticeLogin $entityName $config }
6 { Add-Practices $entityName }
7 {
$file = Save-EntityConfig $entityName $config
Copy-RunBat $entityName
Write-Host ""
+466
View File
@@ -0,0 +1,466 @@
# Standalone SFTP/FTP login test. Nothing is read from a config file - you type
# the details in - so it isolates the account from whatever a script is doing
# with it. Called by test-login.bat.
$scriptRoot = Split-Path -Parent $PSCommandPath
function Read-Answer($question, $default) {
while ($true) {
if ($default) {
Write-Host "$question " -NoNewline -ForegroundColor White
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
Write-Host ": " -NoNewline -ForegroundColor White
} else {
Write-Host "${question}: " -NoNewline -ForegroundColor White
}
$answer = Read-Host
if ([string]::IsNullOrWhiteSpace($answer)) {
if ($default) { return $default }
Write-Host " (required)" -ForegroundColor Yellow
continue
}
return $answer
}
}
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host " Login test" -ForegroundColor Cyan
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host ""
Write-Host "Type the details exactly as they work in WinSCP or FileZilla." -ForegroundColor Gray
# Find WinSCPnet.dll wherever it happens to live - this script is meant to be
# dropped next to whatever uploader is in use, not just this one.
function Find-WinSCPDll {
$parent = Split-Path -Parent $scriptRoot
$candidates = @(
(Join-Path $scriptRoot "WinSCP\WinSCPnet.dll"),
(Join-Path $scriptRoot "WinSCPnet.dll"),
(Join-Path $parent "WinSCP\WinSCPnet.dll"),
(Join-Path $parent "WinSCPnet.dll"),
"C:\Program Files (x86)\WinSCP\WinSCPnet.dll",
"C:\Program Files\WinSCP\WinSCPnet.dll"
)
foreach ($c in $candidates) {
if (Test-Path $c) { return $c }
}
# Anywhere under the folder this script sits in.
$found = Get-ChildItem -Path $scriptRoot -Filter "WinSCPnet.dll" -Recurse -File -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($found) { return $found.FullName }
return $null
}
$winscpDll = Find-WinSCPDll
if (-not $winscpDll) {
Write-Host ""
Write-Host "Could not find WinSCPnet.dll automatically." -ForegroundColor Yellow
Write-Host "It sits either in a WinSCP folder next to your upload script, or" -ForegroundColor Gray
Write-Host "in C:\Program Files (x86)\WinSCP\ if WinSCP is installed normally." -ForegroundColor Gray
Write-Host ""
$winscpDll = Read-Answer "Full path to WinSCPnet.dll" $null
if (-not (Test-Path $winscpDll)) {
Write-Host "Nothing at that path." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "Using $winscpDll" -ForegroundColor DarkGray
# Windows tags files copied from elsewhere as untrusted, which stops Add-Type
# loading them. Harmless everywhere else, so failure here is not worth a word.
try {
Get-ChildItem -Path (Split-Path -Parent $winscpDll) -Recurse -File -ErrorAction SilentlyContinue |
Unblock-File -ErrorAction SilentlyContinue
} catch { }
try {
Add-Type -Path $winscpDll
} catch {
Write-Host "Could not load WinSCPnet.dll - $($_.Exception.Message)" -ForegroundColor Red
Write-Host "If it says the operation is not supported, Windows has the file" -ForegroundColor Yellow
Write-Host "blocked: right-click it, Properties, tick Unblock, OK." -ForegroundColor Yellow
exit 1
}
function Read-Number($question, $min, $max, $default) {
while ($true) {
Write-Host "$question " -NoNewline -ForegroundColor White
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
Write-Host ": " -NoNewline -ForegroundColor White
$a = Read-Host
if ([string]::IsNullOrWhiteSpace($a)) { $a = [string]$default }
$n = 0
if ([int]::TryParse(([string]$a).Trim(), [ref]$n) -and $n -ge $min -and $n -le $max) { return $n }
Write-Host " (a number between $min and $max)" -ForegroundColor Yellow
}
}
# FileZilla keeps site passwords Base64-encoded, so the value sitting in the
# exported XML is not the password - it has to be decoded first. Copying it
# out verbatim fails against the server every time, which is exactly what a
# working FileZilla login plus a failing script looks like.
function Import-FileZillaSite {
Write-Host ""
Write-Host "In FileZilla: File > Export, tick 'Site Manager entries', save the .xml." -ForegroundColor Gray
$path = Read-Answer "Path to the exported .xml" $null
$path = $path.Trim('"').Trim()
if (-not (Test-Path $path)) {
Write-Host " Nothing at that path." -ForegroundColor Red
return $null
}
try {
$xml = [xml](Get-Content $path -Raw)
} catch {
Write-Host " That file is not readable XML - $($_.Exception.Message)" -ForegroundColor Red
return $null
}
$servers = @($xml.SelectNodes("//Server"))
if ($servers.Count -eq 0) {
Write-Host " No sites found in that file." -ForegroundColor Red
return $null
}
$node = $servers[0]
if ($servers.Count -gt 1) {
Write-Host ""
Write-Host "Sites in that file:" -ForegroundColor White
for ($i = 0; $i -lt $servers.Count; $i++) {
$n = $servers[$i]
Write-Host (" {0}. {1} {2}@{3}:{4}" -f ($i + 1), $n.Name, $n.User, $n.Host, $n.Port)
}
$node = $servers[(Read-Number "Which one" 1 $servers.Count 1) - 1]
}
$pass = $null
$passNode = $node.SelectSingleNode("Pass")
if ($passNode) {
$encoding = $passNode.GetAttribute("encoding")
if ($encoding -eq "base64") {
try {
$pass = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($passNode.InnerText))
Write-Host ""
Write-Host " Decoded the password from Base64." -ForegroundColor Green
Write-Host " The XML holds $($passNode.InnerText.Length) characters; the real" -ForegroundColor Gray
Write-Host " password is $($pass.Length). Using the decoded one." -ForegroundColor Gray
} catch {
Write-Host " The password in the XML is not valid Base64." -ForegroundColor Yellow
}
} elseif ($encoding -eq "crypt") {
Write-Host ""
Write-Host " This export is protected by a FileZilla master password," -ForegroundColor Yellow
Write-Host " so the password cannot be read out of it. Type it instead." -ForegroundColor Yellow
} else {
$pass = $passNode.InnerText
}
}
# FileZilla's protocol numbering: 1 is SFTP, 0 is plain FTP, and the
# TLS variants use higher values. Only used as the menu default.
$proto = 1
switch ([string]$node.Protocol) {
"0" { $proto = 3 }
"1" { $proto = 1 }
"3" { $proto = 2 }
"4" { $proto = 2 }
}
return @{
Host = [string]$node.Host
Port = [string]$node.Port
User = [string]$node.User
Password = $pass
Proto = $proto
}
}
$fromXml = $null
Write-Host ""
Write-Host "Where are the details coming from?" -ForegroundColor White
Write-Host " 1. I will type them in"
Write-Host " 2. Read them from a FileZilla site export (.xml)"
if ((Read-Number "Choose 1-2" 1 2 1) -eq 2) {
$fromXml = Import-FileZillaSite
if (-not $fromXml) {
Write-Host ""
Write-Host "Falling back to typing them in." -ForegroundColor Yellow
}
}
Write-Host ""
$serverHost = Read-Answer "Server" $(if ($fromXml) { $fromXml.Host } else { $null })
$serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim()
Write-Host ""
Write-Host "Protocol:" -ForegroundColor White
Write-Host " 1. SFTP"
Write-Host " 2. FTPS"
Write-Host " 3. FTP"
$protoDefault = if ($fromXml) { $fromXml.Proto } else { 1 }
$protoChoice = Read-Number "Choose 1-3" 1 3 $protoDefault
$protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1]
$defaultPort = if ($fromXml -and $fromXml.Port) { $fromXml.Port }
elseif ($protocol -eq "sftp") { 22 } else { 21 }
$port = Read-Number "Port" 1 65535 $defaultPort
$username = Read-Answer "Username" $(if ($fromXml) { $fromXml.User } else { $null })
if ($fromXml -and $fromXml.Password) {
$password = $fromXml.Password
} else {
Write-Host ""
Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray
Write-Host "Password: " -NoNewline -ForegroundColor White
$secure = Read-Host -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
# Nothing echoes while typing, so a slipped paste looks identical to a good
# one. Typing it twice is the only way to be sure of what actually went in.
Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White
$secure2 = Read-Host -AsSecureString
$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2)
$password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2)
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2)
if ($password -ne $password2) {
Write-Host ""
Write-Host " !! The two did not match." -ForegroundColor Red
Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red
Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red
Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow
Write-Host ""
exit 1
}
}
# -- What the credentials actually contain ------------------------------------
Write-Host ""
Write-Host ("-" * 64)
Write-Host "What was entered" -ForegroundColor Cyan
Write-Host ("-" * 64)
Write-Host " Server : [$serverHost] port $port over $($protocol.ToUpper())"
Write-Host " Username : [$username] ($($username.Length) characters)"
Write-Host " Password : $($password.Length) characters"
$warned = $false
if ($username -ne $username.Trim()) {
Write-Host " !! The username has a leading or trailing space." -ForegroundColor Yellow
$warned = $true
}
if ($password -ne $password.Trim()) {
Write-Host " !! The password has a leading or trailing space." -ForegroundColor Yellow
$warned = $true
}
if ($password -match '[\$`]') {
Write-Host " !! The password contains a `$ or a backtick." -ForegroundColor Yellow
Write-Host " In a PowerShell script these MUST be in single quotes:" -ForegroundColor Yellow
Write-Host " `$Password = 'the`$password' <- correct" -ForegroundColor Yellow
Write-Host " `$Password = `"the`$password`" <- WRONG, PowerShell eats `$password" -ForegroundColor Yellow
Write-Host " This is the most common reason a login works in the WinSCP" -ForegroundColor Yellow
Write-Host " window but fails from a script with the same credentials." -ForegroundColor Yellow
$warned = $true
}
if ($password -match '[^\x20-\x7E]') {
Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow
$warned = $true
}
# A password copied straight out of a FileZilla export is still Base64.
if (-not $fromXml -and $password.Length -ge 8 -and $password -cmatch '^[A-Za-z0-9+/]+={0,2}$' -and ($password.Length % 4) -eq 0) {
$decoded = $null
try { $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($password)) } catch { }
if ($decoded -and $decoded -match '^[\x20-\x7E]+$') {
Write-Host " !! This looks like Base64, not a password." -ForegroundColor Yellow
Write-Host " FileZilla stores passwords Base64-encoded in its exported" -ForegroundColor Yellow
Write-Host " XML. Decoded, this is $($decoded.Length) characters - that is" -ForegroundColor Yellow
Write-Host " probably the real password. Re-run and choose option 2 to" -ForegroundColor Yellow
Write-Host " read the export directly, or paste the decoded value." -ForegroundColor Yellow
$warned = $true
}
}
if (-not $warned) {
Write-Host " Nothing unusual about either string." -ForegroundColor Green
}
# -- Try it -------------------------------------------------------------------
$logFile = Join-Path $scriptRoot "login-test.log"
if (Test-Path $logFile) { Remove-Item $logFile -Force }
$opts = New-Object WinSCP.SessionOptions
$opts.HostName = $serverHost
$opts.PortNumber = $port
$opts.UserName = $username
$opts.Password = $password
if ($protocol -eq "sftp") {
$opts.Protocol = [WinSCP.Protocol]::Sftp
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
} else {
$opts.Protocol = [WinSCP.Protocol]::Ftp
if ($protocol -eq "ftps") {
$opts.FtpSecure = [WinSCP.FtpSecure]::Explicit
$opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true
} else {
$opts.FtpSecure = [WinSCP.FtpSecure]::None
}
}
Write-Host ""
Write-Host ("-" * 64)
Write-Host "Connecting" -ForegroundColor Cyan
Write-Host ("-" * 64)
$session = New-Object WinSCP.Session
$session.SessionLogPath = $logFile
$connected = $false
try {
$session.Open($opts)
$connected = $true
Write-Host " CONNECTED" -ForegroundColor Green
} catch {
Write-Host " FAILED" -ForegroundColor Red
Write-Host ""
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
}
if ($connected) {
try {
Write-Host ""
Write-Host " Home directory: $($session.HomePath)" -ForegroundColor Gray
Write-Host " Folders visible at the top level:" -ForegroundColor Gray
$listing = $session.ListDirectory($session.HomePath)
$shown = 0
foreach ($item in $listing.Files) {
if ($item.Name -eq "." -or $item.Name -eq "..") { continue }
$kind = if ($item.IsDirectory) { "[dir] " } else { " " }
Write-Host " $kind$($item.Name)"
$shown++
if ($shown -ge 25) { Write-Host " ... (more)"; break }
}
if ($shown -eq 0) { Write-Host " (nothing - the account may be locked to an empty folder)" }
} catch {
Write-Host " Connected, but could not list the folder: $($_.Exception.Message)" -ForegroundColor Yellow
}
Write-Host ""
$checkPath = Read-Answer "Remote folder to check (or . to skip)" "."
if ($checkPath -ne ".") {
try {
if ($session.FileExists($checkPath)) {
Write-Host " $checkPath ... found" -ForegroundColor Green
} else {
Write-Host " $checkPath ... NOT THERE" -ForegroundColor Red
}
} catch {
Write-Host " Could not check: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
}
$session.Dispose()
# -- The authentication conversation ------------------------------------------
if (-not $connected -and (Test-Path $logFile)) {
Write-Host ""
Write-Host ("-" * 64)
Write-Host "What the server actually said" -ForegroundColor Cyan
Write-Host ("-" * 64)
$log = Get-Content $logFile -ErrorAction SilentlyContinue
$server = $log | Select-String -Pattern "Remote version:" | Select-Object -First 1
if ($server) { Write-Host " $(($server.Line -split 'Remote version:')[1].Trim())" -ForegroundColor Gray }
$methodLine = $log | Select-String -Pattern "Server offered these authentication methods:" | Select-Object -First 1
$methods = ""
if ($methodLine) {
$methods = (($methodLine.Line -split "authentication methods:")[1]).Trim()
Write-Host " Methods the server accepts: $methods" -ForegroundColor Gray
}
$sentPassword = ($log | Select-String -Pattern "Sent password").Count -gt 0
$pwRejected = ($log | Select-String -Pattern "Password authentication failed").Count -gt 0
Write-Host ""
if ($pwRejected -or ($sentPassword -and -not $connected)) {
Write-Host " The password reached the server and the server rejected it." -ForegroundColor Yellow
Write-Host ""
Write-Host " That rules out the script, the protocol, the port and the host" -ForegroundColor Yellow
Write-Host " key - all of those worked. Only three things are left:" -ForegroundColor Yellow
Write-Host ""
Write-Host " 1. The password is not the one this account expects." -ForegroundColor White
Write-Host " Check it is this practice's own password, not another's." -ForegroundColor Gray
Write-Host " 2. The account is locked out from repeated failed tries," -ForegroundColor White
Write-Host " or expired. Log in with the WinSCP window right now - if" -ForegroundColor Gray
Write-Host " that fails too, it is the account, not anything here." -ForegroundColor Gray
Write-Host " 3. The account is set to require a key rather than a" -ForegroundColor White
Write-Host " password, even though the server allows passwords" -ForegroundColor Gray
Write-Host " generally. Only the partner can tell you that." -ForegroundColor Gray
} elseif ($methods -and $methods -notmatch "password") {
Write-Host " The server never offered password authentication." -ForegroundColor Yellow
Write-Host " It accepts: $methods" -ForegroundColor Yellow
Write-Host " A password will never work here - the partner has to enable" -ForegroundColor Yellow
Write-Host " password auth or issue an SSH key for this account." -ForegroundColor Yellow
} else {
Write-Host " It failed before any password was sent - so this is about" -ForegroundColor Yellow
Write-Host " reaching the server, not about the credentials." -ForegroundColor Yellow
}
Write-Host ""
Write-Host " Full log: $logFile" -ForegroundColor Cyan
Write-Host " It contains no password. Send it to Brad." -ForegroundColor Cyan
}
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor Cyan
if ($connected) {
Write-Host " Login works. These details are correct." -ForegroundColor Green
Write-Host ("=" * 64) -ForegroundColor Cyan
# Getting a working password into a script is its own trap, so hand back
# the literal to paste rather than leaving the quoting to chance.
$needsCare = $password -match '[\$`]'
if ($needsCare -or $password -match "'" -or $password -match '%') {
Write-Host ""
Write-Host " Putting this password into a PowerShell script" -ForegroundColor Cyan
Write-Host (" " + ("-" * 60)) -ForegroundColor Cyan
Write-Host ""
Write-Host " Use exactly this line, single quotes included:" -ForegroundColor White
Write-Host ""
Write-Host " `$Password = $("'" + ($password -replace "'", "''") + "'")" -ForegroundColor Green
Write-Host ""
if ($needsCare) {
Write-Host " This password contains a `$ or a backtick. Inside DOUBLE" -ForegroundColor Yellow
Write-Host " quotes PowerShell treats that as a variable and silently" -ForegroundColor Yellow
Write-Host " sends something shorter - the login then fails even though" -ForegroundColor Yellow
Write-Host " the password is right. Single quotes send it untouched." -ForegroundColor Yellow
}
if ($password -match "'") {
Write-Host " The password contains an apostrophe, so it is doubled in" -ForegroundColor Yellow
Write-Host " the line above. That is correct - it stays one character." -ForegroundColor Yellow
}
if ($password -match '%') {
Write-Host " The password contains a %. That is fine in PowerShell, but" -ForegroundColor Yellow
Write-Host " in a .bat file it must be written as %% instead." -ForegroundColor Yellow
}
Write-Host ""
Write-Host " Safer still: keep it out of the script. Put the password on" -ForegroundColor Gray
Write-Host " the first line of its own file and read it back - then no" -ForegroundColor Gray
Write-Host " quoting rules apply to it at all:" -ForegroundColor Gray
Write-Host ' $Password = Get-Content pw.txt -TotalCount 1' -ForegroundColor Gray
} else {
Write-Host ""
Write-Host " Nothing in this password needs special quoting in a script." -ForegroundColor Green
}
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host " If a script using these same details still fails, the script is" -ForegroundColor Green
Write-Host " changing one of them on the way through." -ForegroundColor Green
} else {
Write-Host " Login failed. Compare against the WinSCP window: File > Session" -ForegroundColor Yellow
Write-Host " > Generate Session URL/Code > .NET assembly code. That shows the" -ForegroundColor Yellow
Write-Host " exact settings the working connection uses." -ForegroundColor Yellow
}
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host ""
+1 -1
View File
@@ -1 +1 @@
490c308 2026-08-26 setup wizard + any-file-type upload
c3ad14c 2026-08-30 password quoting guidance
Executable
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Pre-commit checks for the PowerShell scripts. Run from the repo root.
# Needs pwsh on PATH (see the project notes for how it was installed).
set -uo pipefail
cd "$(dirname "$0")"
fail=0
echo "== ASCII only =="
# Windows PowerShell 5.1 reads a BOM-less .ps1 in the system codepage, which
# turns any multi-byte character into mojibake - including quote characters
# that break string literals mid-script.
for f in *.ps1; do
if LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" >/dev/null; then
echo " FAIL $f contains non-ASCII:"
LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" | head -5
fail=1
else
echo " ok $f"
fi
done
echo
echo "== \$var? in a string =="
# ? is a legal character in an unbraced PowerShell variable name, so "$name?"
# looks up a variable called name? and silently renders empty. Use ${name}?.
if grep -nP '\$\w+\?' ./*.ps1; then
echo " FAIL - wrap those as \${name}? or the text disappears at runtime"
fail=1
else
echo " ok none found"
fi
echo
echo "== parse + Windows PowerShell 5.1 compatibility =="
pwsh -NoProfile -Command '
$bad = 0
foreach ($f in Get-ChildItem -Filter *.ps1) {
$e = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$e) | Out-Null
if ($e.Count) {
$bad++
Write-Host " FAIL $($f.Name)"
$e | ForEach-Object { " line $($_.Extent.StartLineNumber): $($_.Message)" }
} else { Write-Host " ok $($f.Name)" }
}
if (Get-Module -ListAvailable PSScriptAnalyzer) {
Import-Module PSScriptAnalyzer
$s = @{ Rules = @{ PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @("5.1","7.0") } } }
$c = Invoke-ScriptAnalyzer -Path . -Recurse -Settings $s -IncludeRule PSUseCompatibleSyntax
if ($c) { $bad++; $c | ForEach-Object { " FAIL 5.1: $($_.ScriptName):$($_.Line) $($_.Message)" } }
else { Write-Host " ok 5.1 syntax clean" }
} else {
Write-Host " skip PSScriptAnalyzer not installed"
}
exit $bad
' || fail=1
echo
echo "== JSON templates =="
python3 - <<'PY' || fail=1
import glob, json, sys
bad = 0
for f in glob.glob("Entities/**/*.json", recursive=True) + ["settings.json"]:
try:
json.load(open(f))
print(" ok %s" % f)
except Exception as e:
print(" FAIL %s: %s" % (f, e)); bad = 1
sys.exit(bad)
PY
echo
if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED"; else echo "CHECKS FAILED"; fi
exit $fail
+4
View File
@@ -0,0 +1,4 @@
@echo off
REM Tests one SFTP/FTP login on its own. Nothing is read from a config file.
powershell.exe -ExecutionPolicy Bypass -File "%~dp0Test-Login.ps1"
pause