Release 0.8.0: Windows installer, twelve fixes from a real Server 2019 install, operator docs

This commit is contained in:
2026-08-05 07:36:43 -04:00
parent 208a572fd7
commit d8e3280dfc
27 changed files with 3189 additions and 396 deletions

View File

@@ -4,7 +4,7 @@
;
; DESIGN NOTE - why this is a thin wrapper and not a reimplementation:
; every install action lives in shopdb-install.ps1, which is verified end to end
; on Windows Server 2025 against both a bundled MySQL 8.0 and an existing MySQL
; on Windows Server 2025 against both a bundled MySQL 8.4 LTS and an existing MySQL
; 5.6. Reimplementing any of it in Pascal Script would create a second code path
; that nobody tests. This file only collects operator input, runs the stages in
; order, and reports which one failed.
@@ -181,6 +181,7 @@ Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
var
DbChoicePage: TInputOptionWizardPage;
DbDetailsPage: TInputQueryWizardPage;
DbCredsPage: TInputQueryWizardPage;
SitePage: TInputQueryWizardPage;
PreflightPage: TWizardPage;
BannerPanel: TPanel;
@@ -402,33 +403,40 @@ begin
'choose the second option - installing a second server would collide on ' +
'port 3306.',
True, False);
DbChoicePage.Add('Install the bundled MySQL 8.0 (new servers only)');
DbChoicePage.Add('Install the bundled MySQL 8.4 LTS (new servers only)');
DbChoicePage.Add('Use a MySQL server this site already runs');
DbChoicePage.SelectedValueIndex := DB_EXISTING;
DbDetailsPage := CreateInputQueryPage(DbChoicePage.ID,
'Existing database', 'Connection details',
'The database and application user must already exist. Your DBA creates ' +
'them; the installer does not, so that it never needs administrative ' +
'rights on your database server. If they need the exact statements:' + #13#10 +
' CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' + #13#10 +
' CREATE USER ''shopdb''@''%'' IDENTIFIED BY ''<password>'';' + #13#10 +
' GRANT ALL PRIVILEGES ON shopdb_flask.* TO ''shopdb''@''%'';' + #13#10 +
'The utf8mb4 charset matters - the default on older servers is latin1.');
'The database and application user must already exist - the installer does ' +
'not create them, so it never needs administrative rights on your database ' +
'server. The exact SQL to give your DBA is in the INSTALL-WINDOWS guide.');
DbDetailsPage.Add('Host:', False);
DbDetailsPage.Add('Port:', False);
DbDetailsPage.Add('Database:', False);
DbDetailsPage.Add('Username:', False);
DbDetailsPage.Add('Password:', True);
DbDetailsPage.Values[0] := '127.0.0.1';
DbDetailsPage.Values[1] := '3306';
DbDetailsPage.Values[2] := 'shopdb_flask';
DbDetailsPage.Values[3] := 'shopdb';
// SPLIT ACROSS TWO PAGES on purpose. CreateInputQueryPage stacks its fields
// below the description and does not scroll or shrink: with five fields the
// last one is drawn past the bottom of the surface and simply does not appear.
// Trimming the description bought one field back and lost the next, which is
// guessing at a pixel budget that varies with DPI and font. Three fields and
// two fields both fit under any reasonable description, at any scaling.
DbCredsPage := CreateInputQueryPage(DbDetailsPage.ID,
'Existing database', 'Sign-in',
'The account the application uses to reach that database. It needs full ' +
'rights on it, and nothing outside it.');
DbCredsPage.Add('Username:', False);
DbCredsPage.Add('Password:', True);
DbCredsPage.Values[0] := 'shopdb';
// How the application is published. Offered only when the bundle actually
// carries a subpath SPA build - Vite compiles the base path in, so this can
// never be a pure runtime switch.
DeployPage := CreateInputOptionPage(DbDetailsPage.ID,
DeployPage := CreateInputOptionPage(DbCredsPage.ID,
'Address', 'How should people reach ShopDB-Flask?',
'Both options serve the same application. The second needs no new DNS name '
+ 'and no port number, because it rides this server''s existing address.',
@@ -647,7 +655,7 @@ end;
// If nothing is wrong the page collapses to a single reassuring statement.
procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean);
var
I, Y, Shown, Fails, Warns, Passes: Integer;
I, Y, Shown, Fails, Warns, Passes, RowHeight, MaxRows, Hidden: Integer;
Parts: TArrayOfString;
Check, Detail, Fix: String;
begin
@@ -655,6 +663,16 @@ begin
Y := ScaleY(4);
Shown := 0;
// How many rows actually FIT, measured, not guessed. The old cap of 6 bore no
// relation to the panel, which holds roughly three: at four or five notes the
// rows below were drawn past the bottom edge and silently vanished - taking
// with them the warning that the bundled MySQL collides on port 3306, which is
// exactly what the operator needs before the very next page.
RowHeight := ScaleY(46) + ScaleY(ROW_GAP);
if RowHeight < 1 then RowHeight := 1;
MaxRows := DetailPanel.Height div RowHeight;
if MaxRows < 1 then MaxRows := 1;
// Blockers first, then warnings - a second pass rather than one, so severity
// ordering does not depend on the order the checks happen to run in.
for I := 0 to GetArrayLength(Lines) - 1 do
@@ -668,6 +686,9 @@ begin
for I := 0 to GetArrayLength(Lines) - 1 do
begin
// Blockers are capped too. Without this the FAIL loop drew every failure,
// however many, straight off the bottom of the panel.
if Shown >= MaxRows then Break;
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] <> 'FAIL' then Continue;
@@ -682,7 +703,7 @@ begin
for I := 0 to GetArrayLength(Lines) - 1 do
begin
if Shown >= 6 then Break;
if Shown >= MaxRows then Break;
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] <> 'WARN' then Continue;
@@ -719,8 +740,13 @@ begin
+ 'verified. Nothing needs your attention.');
end;
if (Shown >= 6) and (Warns > 6 - Fails) then
FooterText.Caption := 'Some notes are not shown. The full check is in the install log.'
// Count what was actually left out rather than inferring it from the old cap,
// which under-reported: with 2 failures and 5 warnings it claimed everything
// was shown while four rows had been dropped.
Hidden := (Fails + Warns) - Shown;
if Hidden > 0 then
FooterText.Caption := IntToStr(Hidden) + ' further item(s) are not shown here. '
+ 'All of them are in the install log.'
else
FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.';
end;
@@ -829,10 +855,76 @@ begin
Log('[shopdb] pre-fill base directory: ' + Result);
end;
// Everything typed in the wizard ends up on a command line that
// CommandLineToArgvW parses, and reaches PowerShell parameters typed [int] or
// [string]. A blank, a space, or a trailing backslash therefore has to be caught
// HERE - by the time the script sees it, the arguments have already shifted.
function IsValidPort(const S: String): Boolean;
var
I, N: Integer;
begin
Result := False;
if (S = '') or (Length(S) > 5) then Exit;
for I := 1 to Length(S) do
if (S[I] < '0') or (S[I] > '9') then Exit; // also rejects spaces and signs
N := StrToIntDef(S, -1);
Result := (N >= 1) and (N <= 65535);
end;
function IsAsciiOnly(const S: String): Boolean;
var
I: Integer;
begin
Result := True;
for I := 1 to Length(S) do
if Ord(S[I]) > 126 then
begin
Result := False;
Exit;
end;
end;
// A path that ends in a backslash - a drive root such as D:\ - closes the
// argument with \" , which CommandLineToArgvW reads as an ESCAPED quote. The
// argument never terminates and every argument after it shifts one place.
// Doubling the backslash inside the quotes is the documented way out.
function QuotePathArg(const P: String): String;
begin
if (P <> '') and (P[Length(P)] = '\') then
Result := '"' + P + '\' + '"'
else
Result := '"' + P + '"';
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
// A drive root is refused outright rather than quoted around. Uninstall
// removes the application directory recursively, so accepting D:\ here would
// mean uninstalling ShopDB-Flask wipes the whole drive.
if CurPageID = wpSelectDir then
begin
if Length(WizardDirValue) <= 3 then
begin
MsgBox('Choose a folder rather than a whole drive.' + #13#10#13#10
+ 'Removing ShopDB-Flask deletes the folder it was installed into, '
+ 'so installing to a drive root would delete everything on that '
+ 'drive when it is uninstalled.', mbError, MB_OK);
Result := False;
end;
end;
if CurPageID = SitePage.ID then
begin
if not IsValidPort(SitePage.Values[1]) then
begin
MsgBox('Enter the port as a number between 1 and 65535.', mbError, MB_OK);
Result := False;
end;
end;
if CurPageID = wpWelcome then
RunPreflight;
@@ -865,8 +957,34 @@ begin
MsgBox('Enter the database host.', mbError, MB_OK);
Result := False;
end
else if (DbDetailsPage.Values[4] = '') and
(not FileExists(InstalledDir + '.env')) then
else if not IsValidPort(DbDetailsPage.Values[1]) then
begin
MsgBox('Enter the database port as a number between 1 and 65535.'
+ #13#10#13#10 + 'MySQL uses 3306 unless it was changed.',
mbError, MB_OK);
Result := False;
end;
end;
end;
if CurPageID = DbCredsPage.ID then
begin
if not UseBundledDb then
begin
if DbCredsPage.Values[0] = '' then
begin
MsgBox('Enter the username the application connects with.', mbError, MB_OK);
Result := False;
end
// Blank is allowed when the password is already ON DISK in a form the
// installer can read: .env from a previous install, or the ACL'd .dbpass
// handoff stage 0 leaves when it created the database itself. Stage 2
// consumes .dbpass on its own, so demanding the password here blocked the
// operator on something the installer already had - and sent them looking
// for a generated secret they were never meant to handle.
else if (DbCredsPage.Values[1] = '') and
(not FileExists(InstalledDir + '.env')) and
(not FileExists(InstalledDir + '.dbpass')) then
begin
// Only required on a FRESH install. On an upgrade, blank means "keep the
// password already in .env", so the operator never has to know it.
@@ -913,6 +1031,13 @@ begin
if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then
begin
DbPageReady := True;
// A stage-0 handoff means the database was created by a previous run of this
// installer and the password is already on disk. Nobody should be asked to
// find or retype a secret the installer generated.
if FileExists(InstalledDir + '.dbpass') then
DbCredsPage.SubCaptionLabel.Caption :=
'This server already has a database created by this installer. Leave the '
+ 'password blank and it will be used automatically.';
if LoadStringsFromFile(InstalledDir + '.env', Lines) then
for I := 0 to GetArrayLength(Lines) - 1 do
if Pos('DATABASE_URL=', Lines[I]) = 1 then
@@ -924,7 +1049,7 @@ begin
Creds := Copy(Existing, 1, Pos('@', Existing) - 1);
Rest := Copy(Existing, Pos('@', Existing) + 1, Length(Existing));
if Pos(':', Creds) > 0 then
DbDetailsPage.Values[3] := Copy(Creds, 1, Pos(':', Creds) - 1);
DbCredsPage.Values[0] := Copy(Creds, 1, Pos(':', Creds) - 1);
if Pos(':', Rest) > 0 then
begin
DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1);
@@ -940,10 +1065,13 @@ begin
end;
// Password intentionally left blank: blank means "keep the current
// one", so an upgrade never needs the operator to know it.
DbDetailsPage.Values[4] := '';
DbCredsPage.Values[1] := '';
DbDetailsPage.SubCaptionLabel.Caption :=
'These are the settings this server is using now. Leave the password '
+ 'blank to keep the current one.';
'These are the settings this server is using now.';
// The hint belongs where the password field actually is - it moved to
// the next page when this one was split.
DbCredsPage.SubCaptionLabel.Caption :=
'Leave the password blank to keep the one this server already uses.';
end;
end;
@@ -994,7 +1122,7 @@ begin
end;
// The bundled path generates its own credentials, so asking for them would be
// meaningless - and any value typed here would be silently ignored.
Result := (PageID = DbDetailsPage.ID) and UseBundledDb;
Result := ((PageID = DbDetailsPage.ID) or (PageID = DbCredsPage.ID)) and UseBundledDb;
end;
// Runs from ssPostInstall, NOT PrepareToInstall.
@@ -1019,22 +1147,31 @@ var
Cmd: String;
begin
if not FileExists(LnkPath) then Exit;
// THREE quotes each side, not four. Four is an escaped quote twice over, which
// keeps the whole expression inside one Pascal literal: LnkPath was never
// interpolated, PowerShell got the bare word "LnkPath", failed to parse, and
// no shortcut ever received the elevation flag. Self-elevation hid it - the
// only symptom was a second window after an extra prompt.
Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "'
+ '$p='''' + LnkPath + ''''; '
+ '$p=''' + LnkPath + '''; '
+ '$b=[IO.File]::ReadAllBytes($p); '
+ '$b[21]=$b[21] -bor 0x20; '
+ '[IO.File]::WriteAllBytes($p,$b)"';
Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if ResultCode <> 0 then
Log('[shopdb] could not mark ' + LnkPath + ' run-as-administrator (exit '
+ IntToStr(ResultCode) + '); the console will self-elevate instead');
end;
function RunInstallStages: String;
var
ResultCode: Integer;
PwFile, Args, Common: String;
PwLines: TArrayOfString;
begin
Result := '';
Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' +
' -AppRoot "' + ExpandConstant('{app}') + '"' +
Common := '-BundleRoot ' + QuotePathArg(ExpandConstant('{tmp}\shopdb-bundle')) +
' -AppRoot ' + QuotePathArg(ExpandConstant('{app}')) +
' -SiteHost "' + SitePage.Values[0] + '"' +
' -SitePort ' + SitePage.Values[1] +
' -OnFailure never' +
@@ -1070,10 +1207,16 @@ begin
// PowerShell transcripts. The installer shreds the file after reading it.
// No password typed on an upgrade means "leave .env alone" - so send no
// password file, and the installer keeps the existing DATABASE_URL.
if DbDetailsPage.Values[4] <> '' then
if DbCredsPage.Values[1] <> '' then
begin
PwFile := ExpandConstant('{tmp}\dbpw.txt');
SaveStringToFile(PwFile, DbDetailsPage.Values[4] + #13#10, False);
// UTF-8, no BOM. SaveStringToFile writes an AnsiString, but the installer
// reads this back with -Encoding UTF8 - so any non-ASCII character in the
// password came out mangled and the database rejected a password that was
// typed correctly, reported as "wrong username or password".
SetArrayLength(PwLines, 1);
PwLines[0] := DbCredsPage.Values[1];
SaveStringsToUTF8FileWithoutBOM(PwFile, PwLines, False);
end
else
PwFile := '';
@@ -1081,7 +1224,7 @@ begin
' -DbHost "' + DbDetailsPage.Values[0] + '"' +
' -DbPort ' + DbDetailsPage.Values[1] +
' -DbName "' + DbDetailsPage.Values[2] + '"' +
' -DbUser "' + DbDetailsPage.Values[3] + '"';
' -DbUser "' + DbCredsPage.Values[0] + '"';
if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"';
end;
@@ -1169,6 +1312,12 @@ end;
; Delegate to the same tested script rather than duplicating removal logic here.
; It removes the site, app pool, firewall rule and application directory, and
; deliberately does NOT drop the database or uninstall MySQL.
Filename: "powershell.exe"; \
;
; Sysnative, for the same reason as the [Run] entry above: the uninstaller is a
; 32-bit process, so a bare "powershell.exe" is resolved through WOW64 to the
; 32-bit PowerShell, which cannot see the IIS provider at all. Removal then
; skipped the site, application pool and application - leaving them pointing at
; a directory it HAD deleted - while Windows reported a clean uninstall.
Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \
RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden