fix: harden local installation guide

This commit is contained in:
2026-08-05 09:23:44 +02:00
parent 53d257fb76
commit 65aa52115f
5 changed files with 401 additions and 24 deletions
+122 -6
View File
@@ -89,6 +89,25 @@ mkdir -p /absolute/path/to/thothii-operator/secrets
chmod 0700 /absolute/path/to/thothii-operator/secrets
```
Native Windows PowerShell performs the same setup without POSIX utilities. The ACL commands remove
inherited access from the new operator directory and grant full control only to the current Windows
identity. Stop if either `icacls.exe` command returns a nonzero exit code:
```powershell
$OperatorDir = Join-Path $env:USERPROFILE 'thothii-operator'
$SecretsDir = Join-Path $OperatorDir 'secrets'
$CurrentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
if (Test-Path $OperatorDir) { throw 'Use a new operator directory or review its ACLs manually.' }
New-Item -ItemType Directory -Force -Path $OperatorDir, $SecretsDir | Out-Null
icacls.exe $OperatorDir /inheritance:r
if ($LASTEXITCODE -ne 0) { throw 'Could not remove inherited operator-directory ACLs.' }
icacls.exe $OperatorDir /grant:r "${CurrentUser}:(OI)(CI)F"
if ($LASTEXITCODE -ne 0) { throw 'Could not grant the current user the operator-directory ACL.' }
Copy-Item deploy/env/local.env.example deploy/env/local.env
Copy-Item docs/install/examples/thothii-installation.local.yaml `
(Join-Path $OperatorDir 'thothii-installation.yaml')
```
Edit `deploy/env/local.env`. At minimum set the workspace Git remote, `PI_AUTH_FILE`,
`THT_SECRETS_FILE`, external service endpoints, and the absolute
`THT_WORKSPACE_BINDINGS_ENV_FILE`. Create each secret as a separate regular file under the
@@ -206,30 +225,112 @@ curl --fail http://127.0.0.1:8787/health
"$THTCTL" --installation "$INSTALLATION" pi test
```
Native PowerShell must call `curl.exe` explicitly; Windows PowerShell may otherwise resolve `curl`
to `Invoke-WebRequest`:
```powershell
curl.exe --fail --silent --show-error http://127.0.0.1:8080/health
curl.exe --fail --silent --show-error http://127.0.0.1:8787/health
& $THTCTL --installation $INSTALLATION pi doctor
& $THTCTL --installation $INSTALLATION pi test
```
Open <http://127.0.0.1:8080>. If a check fails, run `thothctl ... logs` or `pi logs`; these are
bounded and sanitize declared secrets. Do not publish either loopback port.
## Update an installation
Commit or back up local operator changes first. Application source updates are separate from Pi
lifecycle updates:
Commit or back up local operator changes first and finish active sessions. A promoted Pi image is
selected by the durable, installation-specific `current-image.yaml` after every base/profile file.
Therefore rebuilding `thothii-core:local` followed by `update --check-only` does not reconcile a
previous `pi update`: the old promoted core would remain selected.
Do not delete or edit the selector. The supported source-update path is a transactional
`pi update --source build` from a clean pulled checkout whose `docker/core.Dockerfile` pins a
different Pi version than the running installation. `thothctl` currently treats the same requested
Pi version as a no-op. The explicit comparison below therefore stops instead of silently deploying
only part of a revision. If it stops, keep the current installation running and wait for a release
with a new Pi pin or a future supported reconciliation command; there is no supported manual
same-version selector-removal procedure.
macOS, Linux, and WSL2:
```sh
"$THTCTL" --installation "$INSTALLATION" stop
git status --short
git diff --quiet
git diff --cached --quiet
git pull --ff-only
git config --local core.autocrlf false
bash scripts/verify-line-endings.sh
SOURCE_REVISION="$(git rev-parse HEAD)"
NEXT_PI_VERSION="$(sed -n 's/^ARG PI_VERSION=//p' docker/core.Dockerfile)"
RUNNING_PI_VERSION="$("$THTCTL" --installation "$INSTALLATION" pi status)"
RUNNING_PI_VERSION="${RUNNING_PI_VERSION#Pi version: }"
if [[ -z "$NEXT_PI_VERSION" || "$NEXT_PI_VERSION" == "$RUNNING_PI_VERSION" ]]; then
echo "Source update stopped: the pulled revision must pin a new Pi version." >&2
exit 1
fi
bash scripts/build-local.sh
bash scripts/build-thothctl.sh
"$THTCTL" --installation "$INSTALLATION" update --check-only
"$THTCTL" --installation "$INSTALLATION" pi update \
--version "$NEXT_PI_VERSION" --source build --yes --drain
"$THTCTL" --installation "$INSTALLATION" start
curl --fail http://127.0.0.1:8080/health
curl --fail http://127.0.0.1:8787/health
printf 'Built source revision: %s\n' "$SOURCE_REVISION"
"$THTCTL" --installation "$INSTALLATION" status
"$THTCTL" --installation "$INSTALLATION" pi status
"$THTCTL" --installation "$INSTALLATION" doctor
```
On native Windows use the PowerShell build launcher and the Git-for-Windows Bash LF check. Review
release notes before updating. Pi has its own transactional `pi update` and rollback workflow in
[Pi management](pi-management.md); never install a package in the running container.
Native Windows PowerShell uses the same fail-closed version comparison and transactional promotion:
```powershell
git status --short
git diff --quiet
if ($LASTEXITCODE -ne 0) { throw 'Commit or back up tracked source changes before update.' }
git diff --cached --quiet
if ($LASTEXITCODE -ne 0) { throw 'Commit or back up staged source changes before update.' }
git pull --ff-only
if ($LASTEXITCODE -ne 0) { throw 'The source pull failed.' }
git config --local core.autocrlf false
& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh
if ($LASTEXITCODE -ne 0) { throw 'The pulled checkout contains CRLF files.' }
$SourceRevision = git rev-parse HEAD
$VersionLine = @(Select-String -Path docker/core.Dockerfile -Pattern '^ARG PI_VERSION=(.+)$')
if ($VersionLine.Count -ne 1) { throw 'Expected exactly one pinned default PI_VERSION.' }
$NextPiVersion = $VersionLine.Matches[0].Groups[1].Value
$RunningPiVersion = (& $THTCTL --installation $INSTALLATION pi status) `
-replace '^Pi version:\s*', ''
if ([string]::IsNullOrWhiteSpace($NextPiVersion) -or $NextPiVersion -eq $RunningPiVersion) {
throw 'Source update stopped: the pulled revision must pin a new Pi version.'
}
powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1
if ($LASTEXITCODE -ne 0) { throw 'The local image build failed.' }
& "C:\Program Files\Git\bin\bash.exe" scripts/build-thothctl.sh
if ($LASTEXITCODE -ne 0) { throw 'The thothctl build failed.' }
& $THTCTL --installation $INSTALLATION update --check-only
if ($LASTEXITCODE -ne 0) { throw 'The installation render check failed.' }
& $THTCTL --installation $INSTALLATION pi update `
--version $NextPiVersion --source build --yes --drain
if ($LASTEXITCODE -ne 0) { throw 'The transactional core update failed.' }
& $THTCTL --installation $INSTALLATION start
if ($LASTEXITCODE -ne 0) { throw 'The installation start failed.' }
curl.exe --fail --silent --show-error http://127.0.0.1:8080/health
curl.exe --fail --silent --show-error http://127.0.0.1:8787/health
Write-Output "Built source revision: $SourceRevision"
& $THTCTL --installation $INSTALLATION status
& $THTCTL --installation $INSTALLATION pi status
& $THTCTL --installation $INSTALLATION doctor
```
The recorded Git revision identifies the clean worktree used for the candidate build. In
`thothctl status`, confirm that `core` reports the installation lifecycle candidate image, then
require `pi status` to equal the new pin and `doctor` to pass. This is the supported running-image
and source-revision evidence; `update --check-only` alone proves only that Compose renders.
Review release notes before updating. See [Pi management](pi-management.md) for rollback; never
install a package in the running container.
## Back up and restore
@@ -278,6 +379,21 @@ docker run --rm -v "$TARGET_VOLUME:/target" -v "$(dirname "$ARCHIVE"):/backup:ro
alpine:3.22 tar -C /target -xzf "/backup/$(basename "$ARCHIVE")"
```
Native PowerShell uses `Split-Path` to produce the read-only archive mount and archive name:
```powershell
$TargetVolume = 'exact-empty-target-volume-name'
$Archive = 'C:\Users\operator\thothii-backups\2026-08-05\exact-volume-name.tgz'
$ArchiveDir = Split-Path -Parent $Archive
$ArchiveName = Split-Path -Leaf $Archive
docker run --rm -v "${TargetVolume}:/target" alpine:3.22 `
sh -ceu 'test -z "$(ls -A /target)"'
if ($LASTEXITCODE -ne 0) { throw 'The restore target volume is not empty.' }
docker run --rm -v "${TargetVolume}:/target" -v "${ArchiveDir}:/backup:ro" `
alpine:3.22 tar -C /target -xzf "/backup/${ArchiveName}"
if ($LASTEXITCODE -ne 0) { throw 'The volume restore failed.' }
```
Restore all four volumes from the same backup set, restore protected operator files separately,
then run `update --check-only`, `start`, `doctor`, registry status/diagnostics, and a known session
before normal use. Never merge an archive into a non-empty volume.
+9 -6
View File
@@ -134,10 +134,13 @@ editing state files.
## Direct support access
Advanced support may inspect the bundled executable directly with the installation's exact
validated Compose file set, for example `docker compose exec core pi --version`. This is read-only
diagnosis, not an update mechanism. Do not run package installers, alter Pi files inside the live
container, mount the Docker socket, expose a browser shell, or use a host Pi as a substitute.
Raw Compose access is unsupported: there is no public operator command that safely reconstructs
the installation's hashed project name, project directory, environment file, optional overrides,
and durable current-image selector for ad-hoc Pi execution. Do not approximate those arguments or
delete/edit lifecycle state for support.
Prefer `thothctl pi status`, `pi doctor`, `pi test`, and `pi logs`, because they include the durable
image selector and redact declared secret values. Share only their sanitized output.
Route direct executable/version checks through `thothctl pi status`, and collect diagnostics with
`thothctl pi doctor`, `thothctl pi test`, and `thothctl pi logs`. These commands are
installation-aware and redact declared secret values. Share only their sanitized output. Do not
run package installers, alter Pi files inside the live container, mount the Docker socket, expose
a browser shell, or use a host Pi as a substitute.
+53 -4
View File
@@ -64,8 +64,17 @@ The safest recovery is to reclone into a new directory. First commit wanted work
backup outside both clones. Then clone with conversion disabled, run the verifier, and copy back
only reviewed changes.
If a reviewed working tree must be repaired in place, make a backup or commit all wanted changes
before continuing. Then use Git's repository attributes to stage a renormalization:
If a reviewed working tree must be repaired in place, Git must first normalize the index, export
that exact index to a separate repair directory, verify the exported bytes, and only then copy the
verified tracked files over the worktree. `git add --renormalize .` alone does not change existing
worktree bytes.
> **WARNING — destructive worktree rewrite.** Make a backup outside the clone or commit every
> wanted tracked change before continuing. The copy step below overwrites tracked worktree bytes
> from the staged index export. Stop if the staged diff does not contain exactly the wanted content;
> untracked files are neither exported nor repaired.
From WSL2, Git Bash, macOS, or Linux:
```sh
git status --short
@@ -73,8 +82,48 @@ git config --local core.autocrlf false
git add --renormalize .
git diff --cached --check
git diff --cached
REPAIR_DIR="$(cd .. && pwd -P)/ThothII-lf-repair"
if [[ -e "$REPAIR_DIR" ]]; then
echo "Choose a new empty LF repair directory: $REPAIR_DIR" >&2
exit 1
fi
mkdir -p "$REPAIR_DIR"
REPAIR_PREFIX="$REPAIR_DIR/"
git checkout-index --all --force --prefix="$REPAIR_PREFIX"
bash scripts/verify-line-endings.sh "$REPAIR_DIR"
# WARNING: destructive copy; make a backup or commit wanted changes before this command.
git ls-files -z | while IFS= read -r -d '' path; do
cp "$REPAIR_DIR/$path" "$path"
done
bash scripts/verify-line-endings.sh
```
Review every staged change before committing. The procedure intentionally avoids destructive Git
resets; replacing the clone is easier to audit and much safer for uncommitted work.
Native Windows PowerShell runs the same Git operations and invokes the byte verifier through Git
for Windows:
```powershell
git status --short
git config --local core.autocrlf false
git add --renormalize .
git diff --cached --check
git diff --cached
$RepairDir = Join-Path (Split-Path -Parent (Get-Location).Path) 'ThothII-lf-repair'
if (Test-Path $RepairDir) { throw 'Choose a new empty LF repair directory.' }
New-Item -ItemType Directory -Path $RepairDir | Out-Null
$RepairPrefix = $RepairDir.Replace('\', '/') + '/'
git checkout-index --all --force --prefix=$RepairPrefix
& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh $RepairDir
if ($LASTEXITCODE -ne 0) { throw 'The staged index export does not satisfy the LF policy.' }
# WARNING: destructive copy; make a backup or commit wanted changes before this command.
git ls-files | ForEach-Object {
Copy-Item -LiteralPath (Join-Path $RepairDir $_) -Destination $_ -Force
}
& "C:\Program Files\Git\bin\bash.exe" scripts/verify-line-endings.sh
if ($LASTEXITCODE -ne 0) { throw 'Tracked worktree bytes were not repaired to the LF policy.' }
```
The first verifier proves the exported index bytes before any overwrite; the final verifier
examines the repaired worktree bytes and must also exit `0`. Review the staged diff again before
committing, then remove the separate repair directory only after inspecting it. The procedure
intentionally avoids `git reset --hard`; replacing the clone is easier to audit and much safer for
uncommitted work.
+89 -2
View File
@@ -4,7 +4,9 @@ set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd -P)"
output="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-test.XXXXXX")"
trap 'rm -f "$output"' EXIT HUP INT TERM
verifier_functions="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-functions.XXXXXX")"
negative_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-docs-negative.XXXXXX")"
trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP INT TERM
"$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output"
@@ -17,7 +19,8 @@ for fixture in \
"server manual canonical base+override references" \
"canonical local base+override fixture" \
"canonical server base+override fixture" \
"relative secret-source fixture rejected"; do
"relative secret-source fixture rejected" \
"CRLF recovery rewrites worktree bytes"; do
grep -Fqx "$fixture passed" "$output" >/dev/null || {
echo "missing fixture verification: $fixture" >&2
cat "$output" >&2
@@ -48,3 +51,87 @@ if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|c
echo "installation manuals still document a bypassed Compose or copied connector override path" >&2
exit 1
fi
# Load only the verifier's function definitions so each deliberately unsafe guide can be checked
# in isolation without invoking Docker-backed Compose fixtures.
sed '/^case "\$mode" in/,$d' "$root/scripts/verify-workspace-install-docs.sh" >"$verifier_functions"
# shellcheck source=/dev/null
source "$verifier_functions"
negative_failures=0
expect_guide_rejected() {
local label="$1" validator="$2" source_guide="$3" relative_path="$4"
local mutation="$5" expected_error="$6"
local fixture_root="$negative_root/${label// /-}"
local fixture_output="$fixture_root/output"
mkdir -p "$fixture_root/$(dirname "$relative_path")"
cp "$source_guide" "$fixture_root/$relative_path"
node - "$fixture_root/$relative_path" "$mutation" <<'NODE'
const fs = require("fs");
const [path, mutation] = process.argv.slice(2);
const original = fs.readFileSync(path, "utf8");
let changed = original;
switch (mutation) {
case "durable-selector":
changed = original.replaceAll("--source build", "--source stale-build");
break;
case "dangerous-volumes":
changed = original.replace("Do **not** run `docker compose down --volumes`", "Run `docker compose down --volumes`");
break;
case "incomplete-powershell":
changed = original.replaceAll("icacls.exe", "Write-Output");
break;
case "broken-crlf":
changed = original.replaceAll("git checkout-index --all --force --prefix=", "git add --renormalize . # ");
break;
case "raw-pi":
changed += "\n```sh\ndocker compose exec core pi --version\n```\n";
break;
default:
throw new Error(`unknown negative-fixture mutation: ${mutation}`);
}
if (changed === original) throw new Error(`negative-fixture mutation made no change: ${mutation}`);
fs.writeFileSync(path, changed);
NODE
set +e
(root="$fixture_root"; set -e; "$validator") >"$fixture_output" 2>&1
local status=$?
set -e
if [[ $status -eq 0 ]]; then
echo "negative fixture accepted: $label" >&2
cat "$fixture_output" >&2
negative_failures=$((negative_failures + 1))
elif ! grep -Fq -- "$expected_error" "$fixture_output"; then
echo "negative fixture failed for the wrong reason: $label" >&2
cat "$fixture_output" >&2
negative_failures=$((negative_failures + 1))
fi
}
expect_guide_rejected \
"durable selector keeps old core" verify_local_guide \
"$root/docs/install/local.md" docs/install/local.md durable-selector \
"installation-aware source update lacks structural token: --source build"
expect_guide_rejected \
"dangerous down volumes instruction" verify_local_guide \
"$root/docs/install/local.md" docs/install/local.md dangerous-volumes \
"docker compose down --volumes must appear only in an explicit prose prohibition"
expect_guide_rejected \
"incomplete native PowerShell path" verify_local_guide \
"$root/docs/install/local.md" docs/install/local.md incomplete-powershell \
"native PowerShell setup lacks structural token: icacls.exe"
expect_guide_rejected \
"renormalize leaves CRLF worktree bytes" verify_windows_line_endings_guide \
"$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md broken-crlf \
"Windows line-ending guide lacks required instruction: git checkout-index --all --force"
expect_guide_rejected \
"raw non-installation-aware Pi access" verify_pi_management_guide \
"$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \
"raw non-installation-aware Compose Pi access is forbidden"
if (( negative_failures != 0 )); then
echo "$negative_failures unsafe installation-document fixtures were accepted" >&2
exit 1
fi
echo "unsafe installation-document fixtures rejected passed"
+128 -6
View File
@@ -100,6 +100,82 @@ verify_local_guide() {
"http://127.0.0.1:8080" \
"git pull --ff-only" \
"docker compose down --volumes"
node - "$guide" <<'NODE'
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
function section(name) {
const marker = `## ${name}`;
const start = source.indexOf(marker);
if (start < 0) throw new Error(`missing section: ${name}`);
const next = source.indexOf("\n## ", start + marker.length);
return source.slice(start, next < 0 ? source.length : next);
}
function blocks(name, language) {
const expression = new RegExp("```" + language + "\\n([\\s\\S]*?)```", "g");
return [...section(name).matchAll(expression)].map((match) => match[1]);
}
function requireTokens(label, text, tokens) {
for (const token of tokens) {
if (!text.includes(token)) throw new Error(`${label} lacks structural token: ${token}`);
}
}
let inCodeFence = false;
for (const line of source.split(/\n/)) {
if (line.trimStart().startsWith("```")) {
inCodeFence = !inCodeFence;
continue;
}
if (!line.includes("docker compose down --volumes")) continue;
const normalized = line.toLowerCase().replaceAll("*", "");
if (inCodeFence || !/(do not|never)/.test(normalized) || /^\s*(docker|&?\s*docker)/.test(normalized)) {
throw new Error("docker compose down --volumes must appear only in an explicit prose prohibition");
}
}
const setupPowerShell = blocks("Create the local operator files", "powershell").join("\n");
requireTokens("native PowerShell setup", setupPowerShell, [
"Copy-Item", "New-Item", "icacls.exe", "/inheritance:r", "/grant:r",
"WindowsIdentity", "deploy/env/local.env.example", "thothii-installation.yaml",
]);
const healthPowerShell = blocks("Start and verify", "powershell").join("\n");
requireTokens("native PowerShell health", healthPowerShell, [
"curl.exe --fail", "http://127.0.0.1:8080/health", "http://127.0.0.1:8787/health",
"pi doctor", "pi test",
]);
const updateShell = blocks("Update an installation", "sh").join("\n");
requireTokens("installation-aware source update", updateShell, [
"NEXT_PI_VERSION", "RUNNING_PI_VERSION", "--source build", "git rev-parse HEAD",
"pi status", "status", "doctor", "curl --fail",
]);
if (!updateShell.includes("NEXT_PI_VERSION\" == \"$RUNNING_PI_VERSION") ||
!updateShell.includes("exit 1")) {
throw new Error("source update must fail closed when thothctl would no-op on the current Pi version");
}
const updatePowerShell = blocks("Update an installation", "powershell").join("\n");
requireTokens("native PowerShell source update", updatePowerShell, [
"$NextPiVersion", "$RunningPiVersion", "--source build", "git rev-parse HEAD",
"pi status", "status", "doctor", "curl.exe --fail", "throw",
]);
const backupPowerShell = blocks("Back up and restore", "powershell").join("\n");
requireTokens("native PowerShell backup/restore", backupPowerShell, [
"$BackupDir", "$Volume", "-czf", "$TargetVolume", "$Archive", "Split-Path -Parent",
"Split-Path -Leaf", "test -z", "-xzf",
]);
for (const block of [setupPowerShell, healthPowerShell, updatePowerShell, backupPowerShell]) {
if (/\$\((dirname|basename)\b|\bmkdir -p\b|\bchmod\s+[0-7]/.test(block)) {
throw new Error("native PowerShell block contains a POSIX-only command sequence");
}
}
NODE
echo "local installation guide contract passed"
}
@@ -118,22 +194,52 @@ verify_windows_line_endings_guide() {
"git config --local core.autocrlf false" \
"bash scripts/verify-line-endings.sh" \
"git add --renormalize ." \
"git checkout-index --all --force" \
"git diff --cached --check" \
"reclone"
if grep -Fq 'git reset --hard' "$guide"; then
node - "$guide" <<'NODE'
node - "$guide" <<'NODE'
const fs = require("fs");
const lines = fs.readFileSync(process.argv[2], "utf8").split(/\n/);
const commands = [
"git add --renormalize .",
"git checkout-index --all --force --prefix=",
"bash scripts/verify-line-endings.sh",
];
let prior = -1;
for (const command of commands) {
const index = lines.findIndex((line, candidate) => candidate > prior && line.trim().includes(command));
if (index < 0) throw new Error(`CRLF recovery lacks ordered command: ${command}`);
prior = index;
}
for (let index = 0; index < lines.length; index += 1) {
if (!lines[index].includes("git reset --hard")) continue;
const command = lines[index].trim();
if (command !== 'cp "$REPAIR_DIR/$path" "$path"' &&
!command.startsWith("Copy-Item -LiteralPath") &&
command !== "git reset --hard") continue;
const warning = lines.slice(Math.max(0, index - 4), index).join(" ").toLowerCase();
if (!warning.includes("warning") || !warning.includes("destructive") ||
!warning.includes("backup") || !warning.includes("commit")) {
throw new Error("git reset --hard lacks an immediate destructive warning requiring backup/commit");
throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit");
}
}
NODE
fi
local fixture
fixture="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")"
trap 'rm -rf "$fixture"' RETURN
git -C "$fixture" init -q
printf '*.sh text eol=lf\n' >"$fixture/.gitattributes"
printf '#!/bin/sh\r\nexit 0\r\n' >"$fixture/repair.sh"
git -C "$fixture" add .gitattributes repair.sh 2>/dev/null
git -C "$fixture" config --local core.autocrlf false
git -C "$fixture" add --renormalize .
local export_dir="$fixture-export"
mkdir -p "$export_dir"
git -C "$fixture" checkout-index --all --force --prefix="$export_dir/"
"$root/scripts/verify-line-endings.sh" "$export_dir"
cp "$export_dir/repair.sh" "$fixture/repair.sh"
"$root/scripts/verify-line-endings.sh" "$fixture"
rm -rf "$export_dir"
echo "CRLF recovery rewrites worktree bytes passed"
echo "Windows line-ending recovery guide contract passed"
}
@@ -163,9 +269,25 @@ verify_pi_management_guide() {
"pi maintenance recover --yes" \
"pi logs" \
"/run/secrets" \
"docker compose exec core pi" \
"Raw Compose access is unsupported" \
"no browser shell" \
"does not mount the Docker socket"
node - "$guide" <<'NODE'
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
if (/docker\s+compose(?:.|\n){0,160}\bexec\b(?:.|\n){0,80}\bcore\b(?:.|\n){0,80}\bpi\b/i.test(source)) {
throw new Error("raw non-installation-aware Compose Pi access is forbidden");
}
const marker = "## Direct support access";
const start = source.indexOf(marker);
const support = start < 0 ? "" : source.slice(start, source.indexOf("\n## ", start + marker.length) < 0
? source.length : source.indexOf("\n## ", start + marker.length));
for (const token of ["unsupported", "thothctl", "pi status", "pi doctor", "pi test", "pi logs"]) {
if (!support.toLowerCase().includes(token.toLowerCase())) {
throw new Error(`direct support section lacks installation-aware diagnostic: ${token}`);
}
}
NODE
echo "Pi management guide contract passed"
}