diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1deed4170..050278d74 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -115,3 +115,39 @@ jobs:
- name: Run Windows native consent boundary
shell: pwsh
run: pnpm exec node --experimental-strip-types --test tests/native-review-consent.test.ts
+
+ # The installer suites import only Node built-ins and repository files, so no
+ # dependency installation is needed.
+ installer:
+ name: installer (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "24"
+
+ - name: Run installer suites
+ run: node --experimental-strip-types --test tests/install-wizard.test.ts tests/installer-server.test.ts tests/verify-package-files.test.ts tests/installer-probes.test.ts tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts
+
+ # The native tests skip off Windows; here every one must run and pass.
+ - name: Require native Windows installer tests
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $output = & node --experimental-strip-types --test --test-reporter=tap --test-name-pattern "^native Windows" tests/installer-windows-bootstrap.test.ts 2>&1
+ $status = $LASTEXITCODE
+ $output | Write-Output
+ if ($status -ne 0) { exit $status }
+ $text = $output -join "`n"
+ $passes = [regex]::Match($text, "(?m)^# pass (\d+)$")
+ $skips = [regex]::Match($text, "(?m)^# skipped (\d+)$")
+ if (!$passes.Success -or [int]$passes.Groups[1].Value -lt 14 -or !$skips.Success -or [int]$skips.Groups[1].Value -ne 0) { throw "Native Windows installer tests were skipped or not selected" }
diff --git a/README.md b/README.md
index 0903436a9..22c99fd7d 100644
--- a/README.md
+++ b/README.md
@@ -339,6 +339,7 @@ Start with the product-facing destination, then move into the operational refere
| [Telemetry](docs/telemetry.md) | Approved fields and source limitations. |
| [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. |
| [Skill style guide](docs/skill-style-guide.md) | The package skill contract. |
+| [Installation wizard (in development)](docs/install-wizard.md) | The browser installation wizard's design, security model and preview. Not a supported installation path yet. |
Back to top ↑
diff --git a/assets/install-wizard/index.html b/assets/install-wizard/index.html
new file mode 100644
index 000000000..4a4baa25c
--- /dev/null
+++ b/assets/install-wizard/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+Install Gentle Shell
+
+
+
+
+
+Skip to content
+
+
+ >_ Gentle Shell
+ Local installer
+
+
+
+
+ 1 Check
+ 2 Review
+ 3 Install
+ 4 Done
+
+
+
+
+
+ Step 1 of 4 · Check
+ Checking this computer
+ Looking for Node.js, pnpm, Pi and Gentle Shell. Nothing changes until you confirm a plan.
+
+
+ This installer needs JavaScript. Enable it for this page, or close this tab and press Ctrl+C in the terminal. Nothing was installed.
+
+
+
+
+
+
+
+
diff --git a/assets/install-wizard/wizard.css b/assets/install-wizard/wizard.css
new file mode 100644
index 000000000..ffaedc05e
--- /dev/null
+++ b/assets/install-wizard/wizard.css
@@ -0,0 +1,1232 @@
+/* Gentle Shell installation wizard.
+ Dark only, local font stacks only (no web fonts, no external resources):
+ the host serves this file under `default-src 'self'`. Colors follow the
+ gentlemanprogramming.com tokens; every state also has a text label. */
+
+:root {
+ color-scheme: dark;
+ --bg: #1a1218;
+ --bg-deep: #000000;
+ --surface: #20161e;
+ --surface-1: #241822;
+ --surface-2: #342230;
+ --border: #342230;
+ --border-soft: #ffffff17;
+ --text: #f6eff3;
+ --text-muted: #a78e9b;
+ --text-faint: #76616b;
+ --accent: #f095c8;
+ --accent-strong: #ff76c3;
+ --accent-soft: #ffb1dd;
+ --success: #b4e7c7;
+ --warning: #e0c27a;
+ --error: #ff718f;
+ --info: #a9c7ee;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-pill: 999px;
+ --ease: cubic-bezier(.4, 0, .2, 1);
+ --glow: 0 10px 30px #f095c84d;
+ --glow-hover: 0 14px 36px #f095c86b;
+ --shadow-panel: 0 24px 60px #00000073, 0 0 40px #f095c81a;
+ --font-sans: Inter, "SF Pro Text", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: "IosevkaTerm NF", "Iosevka Term", "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, "DejaVu Sans Mono", monospace;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ background: var(--bg);
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ color: var(--text);
+ font: 16px/1.6 var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ scrollbar-color: var(--surface-2) transparent;
+}
+
+::selection {
+ background: #f095c84d;
+ color: var(--text);
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+a {
+ color: var(--accent);
+}
+
+p {
+ margin: 0 0 12px;
+}
+
+p:last-child {
+ margin-bottom: 0;
+}
+
+code {
+ font-family: var(--font-mono);
+ font-size: .9em;
+ color: var(--accent-soft);
+ background: #ffffff0f;
+ border: 1px solid var(--border-soft);
+ border-radius: 6px;
+ padding: .08em .4em;
+ white-space: nowrap;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.skip-link {
+ position: absolute;
+ top: -100px;
+ left: 16px;
+ z-index: 10;
+ padding: 10px 16px;
+ border-radius: var(--radius-sm);
+ background: var(--accent);
+ color: var(--bg);
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.skip-link:focus {
+ top: 12px;
+}
+
+/* Hero glow and a faint dot grid behind everything. */
+/* Faint dot grid behind everything; the hero glow lives behind the panel (.view::before). */
+.backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+}
+
+.backdrop::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background-image: radial-gradient(#ffffff0d 1px, #0000 1px);
+ background-size: 26px 26px;
+ -webkit-mask-image: linear-gradient(180deg, #000 0%, #0000 70%);
+ mask-image: linear-gradient(180deg, #000 0%, #0000 70%);
+}
+
+/* Top bar */
+
+.topbar,
+main,
+.footer {
+ width: min(1040px, 100% - 32px);
+ margin-inline: auto;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 24px 0 8px;
+}
+
+.wordmark {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ margin: 0;
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -.02em;
+}
+
+.wordmark-mark {
+ display: inline-grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border: 1px solid #f095c866;
+ border-radius: var(--radius-sm);
+ background: linear-gradient(140deg, #f095c829, #f095c80a);
+ color: var(--accent);
+ font: 700 14px/1 var(--font-mono);
+ box-shadow: 0 6px 18px #f095c826;
+}
+
+.wordmark-accent {
+ color: var(--accent);
+}
+
+.topbar-note {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 5px 12px;
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius-pill);
+ background: #ffffff08;
+ color: var(--text-muted);
+ font: 500 12px/1.4 var(--font-mono);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--success);
+ box-shadow: 0 0 0 3px #b4e7c726;
+}
+
+main {
+ flex: 1;
+ padding: 24px 0 56px;
+}
+
+/* Stage indicator */
+
+.stages-nav {
+ margin-bottom: 24px;
+}
+
+.stages {
+ display: flex;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: stage;
+}
+
+.stage {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ color: var(--text-muted);
+ font: 600 12px/1.2 var(--font-mono);
+ letter-spacing: .1em;
+ text-transform: uppercase;
+}
+
+.stage:not(:last-child)::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ min-width: 12px;
+ background: var(--border);
+}
+
+.stage-done:not(:last-child)::after {
+ background: linear-gradient(90deg, #b4e7c799, #f095c866);
+}
+
+.stage-dot {
+ display: inline-grid;
+ flex: none;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ background: var(--surface);
+ font-size: 12px;
+ letter-spacing: 0;
+}
+
+.stage-current {
+ color: var(--text);
+}
+
+.stage-current .stage-dot {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--bg);
+ box-shadow: 0 0 0 4px #f095c826, var(--glow);
+}
+
+.stage-done {
+ color: var(--success);
+}
+
+.stage-done .stage-dot {
+ border-color: #b4e7c799;
+ background: #b4e7c726;
+ color: var(--success);
+}
+
+.stage-issue {
+ color: var(--warning);
+}
+
+.stage-issue .stage-dot {
+ border-color: #e0c27a99;
+ background: #e0c27a26;
+ color: var(--warning);
+}
+
+/* Panel and typography */
+
+/* Hero glow from the reference site, behind the panel. Decorative and static:
+ a pseudo-element is not in the accessibility tree and never takes input. */
+.view {
+ position: relative;
+ isolation: isolate;
+}
+
+.view::before {
+ content: "";
+ position: absolute;
+ /* Wider than the panel on large screens, never wider than the viewport:
+ the side inset stops 16px inside the page edge. */
+ inset: -120px max(-12%, calc((100% - 100vw) / 2 + 16px)) -40px;
+ z-index: -1;
+ pointer-events: none;
+ background:
+ radial-gradient(38% 50% at 30% 30%, #f095c838, #0000 70%),
+ radial-gradient(30% 40% at 75% 40%, #b4e7c71f, #0000 70%);
+ filter: blur(32px);
+}
+
+.panel {
+ position: relative;
+ overflow: hidden;
+ padding: clamp(24px, 4.2vw, 52px);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(180deg, #241822f2, #20161ef2);
+ box-shadow: var(--shadow-panel);
+ animation: rise .35s var(--ease) both;
+}
+
+.panel::before {
+ content: "";
+ position: absolute;
+ inset: 0 0 auto;
+ height: 2px;
+ background: linear-gradient(90deg, #0000, var(--accent), #0000);
+ opacity: .7;
+}
+
+.panel-success::before {
+ background: linear-gradient(90deg, #0000, var(--success), var(--accent), #0000);
+}
+
+.panel-warning::before {
+ background: linear-gradient(90deg, #0000, var(--warning), #0000);
+}
+
+.panel-error::before {
+ background: linear-gradient(90deg, #0000, var(--error), #0000);
+}
+
+.eyebrow {
+ margin: 0 0 12px;
+ color: var(--accent);
+ font: 600 12px/1.4 var(--font-mono);
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+
+h1 {
+ max-width: 22ch;
+ margin: 0 0 16px;
+ font-size: clamp(34px, 4.6vw, 58px);
+ font-weight: 800;
+ line-height: 1.08;
+ letter-spacing: -.022em;
+ color: var(--text);
+ background: linear-gradient(100deg, var(--text) 20%, var(--accent));
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+/* Each new step moves focus to its heading so screen readers announce it. The
+ heading is not interactive, so it shows no ring; every control keeps its
+ :focus-visible outline. */
+h1[tabindex="-1"]:focus {
+ outline: none;
+}
+
+h2 {
+ margin: 0;
+}
+
+.lead {
+ max-width: 62ch;
+ margin-bottom: 0;
+ color: var(--text-muted);
+ font-size: clamp(17px, 1.6vw, 18px);
+}
+
+.block {
+ margin-top: 36px;
+}
+
+.section-title {
+ margin: 0 0 14px;
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -.01em;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
+ gap: 16px;
+}
+
+/* Cards and pills */
+
+.card {
+ padding: 22px 24px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
+}
+
+.card p {
+ color: var(--text-muted);
+ font-size: 15px;
+}
+
+.disclosure-changes {
+ border-color: #f095c859;
+ background: linear-gradient(180deg, #f095c80d, #0000 60%), var(--surface);
+ box-shadow: inset 0 1px 0 #f095c81f;
+}
+
+.card-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px 12px;
+ margin-bottom: 10px;
+}
+
+.card-title {
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.detail {
+ margin-top: 12px;
+}
+
+.detail code {
+ display: block;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ padding: 8px 12px;
+ border-color: var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-deep);
+ color: var(--text);
+ font-size: 13px;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius-pill);
+ font: 600 12px/1.4 var(--font-mono);
+ letter-spacing: .02em;
+ white-space: nowrap;
+}
+
+.pill-accent {
+ border-color: #f095c84d;
+ background: #f095c81f;
+ color: var(--accent-soft);
+}
+
+.pill-muted {
+ background: #ffffff0a;
+ color: var(--text-muted);
+}
+
+/* Plan steps */
+
+.plan-steps,
+.next-steps {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: item;
+}
+
+.plan-steps li,
+.next-steps li {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-soft);
+ counter-increment: item;
+}
+
+.plan-steps li:last-child,
+.next-steps li:last-child {
+ border-bottom: 0;
+}
+
+.plan-steps li::before,
+.next-steps li::before {
+ content: counter(item, decimal-leading-zero);
+ display: inline-grid;
+ flex: none;
+ place-items: center;
+ width: 32px;
+ height: 26px;
+ border: 1px solid #f095c84d;
+ border-radius: 6px;
+ background: #f095c812;
+ color: var(--accent);
+ font: 600 12px/1 var(--font-mono);
+}
+
+.plan-step-text {
+ padding-top: 1px;
+}
+
+/* Consent */
+
+.consent {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ margin-top: 32px;
+ padding: 18px 20px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-1);
+ transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
+}
+
+.consent:has(.consent-input:checked) {
+ border-color: #f095c88c;
+ box-shadow: 0 0 0 3px #f095c81a;
+}
+
+.consent:has(.consent-input[aria-invalid="true"]) {
+ border-color: var(--error);
+ box-shadow: 0 0 0 3px #ff718f1f;
+}
+
+.consent-input {
+ flex: none;
+ width: 22px;
+ height: 22px;
+ margin: 1px 0 0;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.consent-label {
+ display: block;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.hint {
+ margin: 4px 0 0;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+/* Buttons */
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ margin-top: 28px;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 46px;
+ padding: 13px 24px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ font: 600 15px/1.2 var(--font-sans);
+ cursor: pointer;
+ transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s var(--ease), background-color .2s var(--ease),
+ color .2s var(--ease);
+}
+
+.button-primary {
+ background: var(--accent);
+ color: var(--bg);
+ box-shadow: var(--glow);
+}
+
+.button-primary:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: var(--glow-hover);
+}
+
+.button-primary:active:not(:disabled) {
+ transform: translateY(0);
+ background: var(--accent-strong);
+}
+
+.button-secondary,
+.button-ghost {
+ background: transparent;
+ color: var(--text);
+ border-color: var(--border);
+}
+
+.button-ghost {
+ border-color: var(--border-soft);
+ color: var(--text-muted);
+}
+
+.button-secondary:hover:not(:disabled),
+.button-ghost:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent-soft);
+}
+
+.button:disabled {
+ opacity: .55;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+/* Alerts */
+
+.alert-region:not(:empty) {
+ margin-bottom: 16px;
+}
+
+.alert {
+ padding: 16px 20px;
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--info);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+}
+
+.alert .actions {
+ margin-top: 14px;
+}
+
+.alert-title {
+ margin: 0 0 4px;
+ font-weight: 700;
+}
+
+.alert-success {
+ border-left-color: var(--success);
+}
+
+.alert-success .alert-title {
+ color: var(--success);
+}
+
+.alert-warning {
+ border-left-color: var(--warning);
+}
+
+.alert-warning .alert-title {
+ color: var(--warning);
+}
+
+.alert-error {
+ border-left-color: var(--error);
+ box-shadow: 0 10px 30px #ff718f14;
+}
+
+.alert-error .alert-title {
+ color: var(--error);
+}
+
+.panel > .alert {
+ margin-top: 28px;
+}
+
+.blockers {
+ display: grid;
+ gap: 12px;
+ margin: 28px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.blockers .alert-title {
+ font-size: 16px;
+}
+
+.meta {
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+/* Progress */
+
+.progress {
+ margin-top: 32px;
+ padding: 18px 20px 20px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+}
+
+.progress-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.progress-label {
+ color: var(--text-muted);
+ font: 600 12px/1.4 var(--font-mono);
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+
+.progress-count {
+ font: 600 14px/1.4 var(--font-mono);
+}
+
+progress {
+ display: block;
+ width: 100%;
+ height: 10px;
+ border: 0;
+ border-radius: var(--radius-pill);
+ overflow: hidden;
+ background: #ffffff0d;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+progress::-webkit-progress-bar {
+ border-radius: var(--radius-pill);
+ background: #ffffff0d;
+}
+
+progress::-webkit-progress-value {
+ border-radius: var(--radius-pill);
+ background: linear-gradient(90deg, var(--info), var(--accent-soft));
+ box-shadow: 0 0 16px #ffb1dd66;
+ transition: inline-size .4s var(--ease);
+}
+
+progress::-moz-progress-bar {
+ border-radius: var(--radius-pill);
+ background: linear-gradient(90deg, var(--info), var(--accent-soft));
+}
+
+.progress-body {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
+ gap: 24px;
+ align-items: start;
+ margin-top: 8px;
+}
+
+.progress-body .block {
+ margin-top: 24px;
+}
+
+.steps {
+ display: grid;
+ gap: 4px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.step {
+ display: grid;
+ grid-template-columns: 26px 1fr auto;
+ align-items: center;
+ gap: 12px;
+ padding: 9px 12px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+}
+
+.step-icon {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border: 1.5px solid var(--border);
+ border-radius: 50%;
+ font: 700 12px/1 var(--font-mono);
+}
+
+.step-label {
+ min-width: 0;
+ font-size: 15px;
+}
+
+.step-status {
+ color: var(--text-muted);
+ font: 600 11px/1.4 var(--font-mono);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.step-done .step-icon {
+ border-color: #b4e7c799;
+ background: #b4e7c733;
+ color: var(--success);
+}
+
+.step-done .step-status {
+ color: var(--success);
+}
+
+.step-running {
+ border-color: #f095c833;
+ background: #f095c80f;
+}
+
+.step-running .step-icon {
+ border-color: #ffffff1f;
+ border-top-color: var(--accent);
+ animation: spin .9s linear infinite;
+}
+
+.step-running .step-status {
+ color: var(--accent-soft);
+}
+
+.step-failed {
+ border-color: #ff718f40;
+ background: #ff718f0f;
+}
+
+.step-failed .step-icon {
+ border-color: #ff718f80;
+ background: #ff718f1f;
+ color: var(--error);
+}
+
+.step-failed .step-status {
+ color: var(--error);
+}
+
+.step-blocked .step-icon {
+ border-color: #e0c27a80;
+ background: #e0c27a1f;
+ color: var(--warning);
+}
+
+.step-blocked .step-status {
+ color: var(--warning);
+}
+
+.step-pending .step-icon {
+ border-style: dashed;
+ border-color: var(--text-faint);
+}
+
+.step-pending .step-label,
+.step-skipped .step-label {
+ color: var(--text-muted);
+}
+
+.step-skipped .step-icon {
+ color: var(--text-muted);
+}
+
+/* Terminal-style log */
+
+.log {
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-deep);
+ box-shadow: inset 0 1px 0 #ffffff0a;
+}
+
+.progress-body > .log {
+ margin-top: 24px;
+}
+
+.log-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border-soft);
+ background: #ffffff06;
+}
+
+.log-dots {
+ display: inline-flex;
+ gap: 6px;
+}
+
+.log-dots span {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #ff718f8c;
+}
+
+.log-dots span:nth-child(2) {
+ background: #e0c27a8c;
+}
+
+.log-dots span:nth-child(3) {
+ background: #b4e7c78c;
+}
+
+.log-title {
+ color: var(--text-muted);
+ font: 600 12px/1.4 var(--font-mono);
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+
+.log-body {
+ max-height: 360px;
+ overflow: auto;
+ padding: 12px 14px 14px;
+ font: 13px/1.7 var(--font-mono);
+}
+
+.log-lines {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.log-line {
+ display: grid;
+ grid-template-columns: 4ch 8ch 1fr;
+ gap: 0 1ch;
+}
+
+.log-seq {
+ color: var(--text-muted);
+}
+
+.log-status {
+ color: var(--info);
+}
+
+.log-done .log-status {
+ color: var(--success);
+}
+
+.log-failed .log-status {
+ color: var(--error);
+}
+
+.log-blocked .log-status {
+ color: var(--warning);
+}
+
+.log-step {
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+.log-reason {
+ grid-column: 3;
+ color: var(--text-muted);
+}
+
+.log-empty {
+ margin: 0;
+ color: var(--text-muted);
+}
+
+.log-empty::after {
+ content: "▍";
+ margin-left: 4px;
+ color: var(--accent);
+ animation: blink 1.1s steps(1) infinite;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+/* Outcome */
+
+.command {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 16px;
+ padding: 12px 12px 12px 18px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-deep);
+ font: 16px/1.4 var(--font-mono);
+}
+
+.command-prompt {
+ color: var(--accent);
+}
+
+.command .command-text {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--text);
+ font-size: 16px;
+}
+
+.command .button {
+ min-height: 36px;
+ margin-left: auto;
+ padding: 6px 14px;
+ font-size: 14px;
+}
+
+.notes,
+.facts {
+ margin: 20px 0 0;
+ padding-left: 20px;
+ color: var(--text-muted);
+ font-size: 15px;
+}
+
+.facts {
+ padding: 0;
+ list-style: none;
+}
+
+.completed {
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border-soft);
+}
+
+.completed summary {
+ width: fit-content;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.completed ul {
+ margin: 12px 0 0;
+ padding: 0;
+ list-style: none;
+ color: var(--text-muted);
+ font-size: 15px;
+}
+
+.completed ul [aria-hidden="true"] {
+ color: var(--success);
+}
+
+.completed .log {
+ margin-top: 12px;
+}
+
+/* Loading */
+
+.loader {
+ display: flex;
+ gap: 8px;
+ margin-top: 32px;
+}
+
+.loader span {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: pulse 1.2s var(--ease) infinite;
+}
+
+.loader span:nth-child(2) {
+ animation-delay: .15s;
+}
+
+.loader span:nth-child(3) {
+ animation-delay: .3s;
+}
+
+noscript .alert {
+ margin-top: 24px;
+}
+
+/* Footer */
+
+.footer {
+ padding: 20px 0 32px;
+ border-top: 1px solid var(--border-soft);
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+.footer strong {
+ color: var(--text);
+ font-weight: 600;
+}
+
+/* Motion */
+
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: .25;
+ transform: scale(.8);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes blink {
+ 50% {
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: .001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: .001ms !important;
+ scroll-behavior: auto !important;
+ }
+
+ .button-primary:hover:not(:disabled) {
+ transform: none;
+ }
+
+ .loader span {
+ opacity: .7;
+ }
+}
+
+/* Small screens */
+
+@media (max-width: 600px) {
+ .topbar {
+ padding-top: 16px;
+ }
+
+ .topbar-note {
+ padding: 4px 10px;
+ font-size: 11px;
+ }
+
+ .stage {
+ gap: 6px;
+ }
+
+ /* Visually hidden, still read: only the current stage shows its name. */
+ .stage:not(.stage-current) .stage-label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ }
+
+ .actions .button {
+ flex: 1 1 100%;
+ }
+
+ .step {
+ gap: 10px;
+ padding: 8px 10px;
+ }
+
+ .step-label {
+ font-size: 14px;
+ }
+
+ .step-status {
+ font-size: 10px;
+ letter-spacing: .06em;
+ }
+
+ .command .button {
+ flex: none;
+ }
+}
+
+/* Windows High Contrast and other forced palettes */
+
+@media (forced-colors: active) {
+ h1 {
+ background: none;
+ -webkit-text-fill-color: currentColor;
+ }
+
+ .button,
+ .card,
+ .alert,
+ .panel {
+ border: 1px solid CanvasText;
+ }
+}
diff --git a/assets/install-wizard/wizard.js b/assets/install-wizard/wizard.js
new file mode 100644
index 000000000..06cfaf58a
--- /dev/null
+++ b/assets/install-wizard/wizard.js
@@ -0,0 +1,799 @@
+// Gentle Shell installation wizard (browser side).
+//
+// Loaded as an ES module under the host's strict CSP (`default-src 'self'`):
+// no inline code or styles, no HTML parsing of server data (DOM APIs and text
+// nodes only) and no requests except same-origin `/api/*` calls. The pure
+// models, renderers and the controller factory are exported so Node tests can
+// load this file without a browser; it starts itself only inside a page.
+
+const API_HEADERS = Object.freeze({ "X-Gentle-Install": "1" });
+const LOG_LIMIT = 200;
+
+/** Human labels for every step the runner logs. Unknown ids are shown as-is. */
+export const stepLabels = Object.freeze({
+ gate: "Safety checks",
+ "check-npm": "Check npm",
+ "check-global-bin": "Check the pnpm global bin directory",
+ "check-existing-stack": "Check for an existing installation",
+ "check-recoverable-stack": "Check the existing installation",
+ "persist-node": "Install Node.js under PNPM_HOME",
+ "persist-package-managers": "Install npm and pnpm under PNPM_HOME",
+ "persist-npm": "Install npm under PNPM_HOME",
+ "persist-pnpm": "Install pnpm under PNPM_HOME",
+ "verify-persistent-runtime": "Verify the persistent Node.js and npm",
+ "verify-persistent-pnpm": "Verify the persistent pnpm",
+ "configure-npm-prefix": "Configure npm's global prefix",
+ "install-global": "Install Pi and Gentle Shell",
+ "verify-global-list": "Verify the installed packages",
+ "verify-shell-bin": "Verify the gentle-shell command",
+ "verify-gentle-ai": "Verify Gentle AI",
+ "shell-setup": "Run gentle-shell setup",
+ "persist-path": "Add the global bin directory to PATH",
+});
+export function stepLabel(id) {
+ return typeof id === "string" && Object.hasOwn(stepLabels, id) ? stepLabels[id] : String(id);
+}
+
+const toolLabels = Object.freeze({
+ node: "Node.js",
+ pnpm: "pnpm",
+ pi: "Pi",
+ shell: "Gentle Shell",
+ gentleAi: "Gentle AI",
+ go: "Go",
+ globalBin: "pnpm global bin directory",
+ setup: "Gentle Shell setup",
+ target: "This computer",
+});
+const statusText = Object.freeze({
+ done: "Done",
+ failed: "Failed",
+ blocked: "Blocked",
+ running: "In progress",
+ pending: "Pending",
+ skipped: "Not run",
+ unknown: "Unknown",
+});
+const stages = Object.freeze([
+ ["check", "Check"],
+ ["review", "Review"],
+ ["install", "Install"],
+ ["done", "Done"],
+]);
+
+function text(value) {
+ return typeof value === "string" ? value : "";
+}
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+// ---------------------------------------------------------------------------
+// Pure models
+// ---------------------------------------------------------------------------
+
+/**
+ * The runner's fixed step sequence for a plan, used to show upcoming steps.
+ * It mirrors runStandardInstall; progressModel tolerates any divergence.
+ */
+export function expectedSteps(actionIds) {
+ const ids = new Set(list(actionIds));
+ const addNpm = ids.has("persist-package-managers") || ids.has("persist-npm");
+ const addPnpm = ids.has("persist-package-managers") || ids.has("persist-pnpm");
+ if (recoveryPlan(actionIds)) {
+ // Setup recovery re-verifies the installed stack and never reinstalls it.
+ const steps = ["check-npm", "check-global-bin", "check-recoverable-stack", "verify-global-list", "verify-shell-bin",
+ "verify-gentle-ai", "shell-setup"];
+ return ids.has("setup-global-bin") ? [...steps, "persist-path"] : steps;
+ }
+ // An npm that is about to be installed is checked after it is added.
+ const steps = [...(addNpm ? [] : ["check-npm"]), "check-global-bin", "check-existing-stack"];
+ if (ids.has("persist-node")) {
+ steps.push("persist-node", "persist-package-managers", "verify-persistent-runtime", "check-npm", "configure-npm-prefix");
+ } else if (addNpm || addPnpm) {
+ const add = ["persist-package-managers", "persist-npm", "persist-pnpm"].find((id) => ids.has(id));
+ steps.push(add);
+ if (addNpm) steps.push("check-npm");
+ if (addPnpm) steps.push("verify-persistent-pnpm");
+ }
+ steps.push("install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup");
+ if (ids.has("setup-global-bin")) steps.push("persist-path");
+ return steps;
+}
+
+/** The runner's fixed setup recovery: only setup, readiness and optionally PATH. */
+function recoveryPlan(actionIds) {
+ const ids = list(actionIds);
+ return ["setup-shell", "verify-readiness"].every((id) => ids.includes(id)) &&
+ ids.every((id) => ["setup-global-bin", "setup-shell", "verify-readiness"].includes(id));
+}
+
+/** Review screen model for a GET /api/plan view. */
+export function planModel(view) {
+ const actions = list(view?.actions).map((action) => ({ id: text(action?.id), description: text(action?.description) }));
+ const blockers = list(view?.blockers).map((blocker) => ({
+ code: text(blocker?.code),
+ tool: text(blocker?.tool),
+ toolLabel: Object.hasOwn(toolLabels, blocker?.tool) ? toolLabels[blocker.tool] : "A required tool",
+ guidance: text(blocker?.guidance),
+ }));
+ let kind = "install";
+ if (blockers.length > 0) kind = "blocked";
+ else if (actions.every((action) => action.id === "verify-readiness")) kind = "nothing";
+ else if (recoveryPlan(actions.map((action) => action.id))) kind = "recovery";
+ const profile = view?.profileChange ?? {};
+ const persistence = view?.persistence ?? {};
+ const tools = list(persistence.tools).filter((tool) => typeof tool === "string");
+ return {
+ planId: text(view?.planId),
+ kind,
+ actions,
+ blockers,
+ disclosures: [
+ {
+ id: "profile",
+ title: "Your PATH",
+ changes: profile.changesProfile === true,
+ state: profile.changesProfile === true ? "Will change" : "No change",
+ text: text(profile.description),
+ detail: typeof profile.binDir === "string" ? profile.binDir : null,
+ },
+ {
+ id: "persistence",
+ title: "Runtimes under PNPM_HOME",
+ changes: tools.length > 0,
+ state: tools.length > 0 ? `Will add ${tools.join(", ")}` : "No change",
+ text: text(persistence.description),
+ detail: typeof persistence.pnpmHome === "string" ? persistence.pnpmHome : null,
+ },
+ ],
+ steps: expectedSteps(actions.map((action) => action.id)),
+ };
+}
+
+function stepItem(id, status) {
+ return { id, label: stepLabel(id), status, statusText: statusText[status] };
+}
+
+/**
+ * Step list for the progress screen: logged entries in order, then the
+ * expected steps that have not run yet. The runner logs a step when it ends,
+ * so the first remaining step is the one in progress.
+ */
+export function progressModel(steps, entries, { running = false, outcome = null } = {}) {
+ const expected = list(steps);
+ const items = [];
+ let cursor = 0;
+ for (const entry of list(entries)) {
+ const at = expected.indexOf(entry?.step, cursor);
+ if (at !== -1) cursor = at + 1;
+ const status = ["done", "failed", "blocked"].includes(entry?.status) ? entry.status : "unknown";
+ items.push(stepItem(text(entry?.step) || "unknown", status));
+ }
+ expected.slice(cursor).forEach((id, index) => {
+ let status = "pending";
+ if (outcome) status = "skipped";
+ else if (running && index === 0) status = "running";
+ items.push(stepItem(id, status));
+ });
+ return {
+ items,
+ done: items.filter((item) => item.status === "done").length,
+ total: items.length,
+ finished: Boolean(outcome),
+ };
+}
+
+const detailCommands = new Map([["shell-setup", "gentle-shell setup"], ["persist-path", "pnpm setup"]]);
+
+/** Final screen model for every runner outcome. Guidance always comes from the host. */
+export function outcomeModel(outcome) {
+ const completed = list(outcome?.completed).map((id) => ({ id: text(id), label: stepLabel(text(id)) }));
+ const guidance = text(outcome?.guidance);
+ const notes = [];
+ if (outcome?.npmPrefix === "configured") notes.push("npm's user-level global prefix now points at PNPM_HOME.");
+ if (outcome?.npmPrefix === "unchanged") notes.push("npm's global prefix was already correct and was left unchanged.");
+ const base = { outcome: outcome?.outcome, guidance, completed, notes, reason: null, failedStep: null, command: null, detail: null, detailCommand: null };
+ if (outcome?.outcome === "ready") {
+ return { ...base, tone: "success", badge: "Installed", title: "Gentle Shell is ready",
+ lead: "Everything is installed and verified on this computer.", command: "gentle-shell",
+ next: ["Open any terminal.", "Run `gentle-shell` to start."] };
+ }
+ if (outcome?.outcome === "terminal-action-required") {
+ return { ...base, tone: "success", badge: "One more step", title: "Open a new terminal",
+ lead: "Your PATH was updated, and only terminals opened from now on can see it.", command: "gentle-shell",
+ next: ["Open a new terminal window or tab.", "Run `gentle-shell` to start."] };
+ }
+ if (outcome?.outcome === "blocked") {
+ return { ...base, tone: "warning", badge: "Blocked", title: "Installation blocked",
+ lead: "Nothing was installed. A safety check stopped the installation before any change.", reason: text(outcome.reason) || null,
+ next: ["Follow the guidance above.", "Run the installer again from your terminal."] };
+ }
+ const id = text(outcome?.failedStep);
+ // The host sends a detail only for these fixed setup commands; bound it again here.
+ const detailCommand = detailCommands.get(id) ?? null;
+ const detail = detailCommand ? Array.from(text(outcome?.detail)).slice(0, 300).join("") : "";
+ return { ...base, outcome: "failed", tone: "error", badge: "Failed", title: "Installation failed",
+ detail: detail || null, detailCommand: detail ? detailCommand : null,
+ lead: id && id !== "unknown"
+ ? `The step “${stepLabel(id)}” failed. Steps that finished before it were kept; nothing was rolled back.`
+ : "The installation stopped unexpectedly. Steps that finished were kept; nothing was rolled back.",
+ failedStep: id && id !== "unknown" ? { id, label: stepLabel(id) } : null,
+ next: ["Follow the guidance above.", "Run the installer again from your terminal."] };
+}
+
+/** The only install request body: null until consent is explicit. */
+export function installBody(planId, consent) {
+ if (consent !== true || typeof planId !== "string" || planId.length === 0) return null;
+ return { planId, consent: true };
+}
+
+/** Maps a POST /api/install answer to the wizard's next step. */
+export function interpretInstall(status, body) {
+ const error = body?.error;
+ if (status === 202) return { next: "progress", message: "" };
+ if (status === 409 && error === "plan-changed") {
+ return { next: "reload-plan", message: "This computer changed since you reviewed the plan. Review the updated plan and confirm again." };
+ }
+ if (status === 409 && error === "install-running") return { next: "progress", message: "" };
+ if (status === 409 && error === "already-completed") return { next: "outcome", message: "" };
+ if (status === 401 || status === 403) return { next: "session", message: "" };
+ if (status === 400) return { next: "error", message: "The installer rejected the request. Review the plan and try again." };
+ return { next: "error", message: "The installer could not start. Check the terminal for details, then try again." };
+}
+
+/** Progress polling: quick while steps finish, slower while quiet, bounded on errors. */
+export const pollPolicy = Object.freeze({ initialMs: 500, maxMs: 4000, factor: 1.5, errorMaxMs: 8000, maxFailures: 5, maxIdle: 3 });
+export function initialPoll() {
+ return { delay: pollPolicy.initialMs, failures: 0, idle: 0, stop: false, reason: null };
+}
+export function nextPoll(state, event) {
+ const policy = pollPolicy;
+ if (event.type === "error") {
+ const failures = state.failures + 1;
+ const stop = failures >= policy.maxFailures;
+ return { delay: Math.min(Math.max(state.delay, policy.initialMs) * 2, policy.errorMaxMs), failures, idle: state.idle, stop,
+ reason: stop ? "errors" : null };
+ }
+ if (event.outcome) return { delay: policy.initialMs, failures: 0, idle: 0, stop: true, reason: "outcome" };
+ // Neither running nor finished should not last: the host is in an unexpected state.
+ const idle = event.running ? 0 : state.idle + 1;
+ if (idle >= policy.maxIdle) return { delay: state.delay, failures: 0, idle, stop: true, reason: "stalled" };
+ const delay = event.count > 0 ? policy.initialMs : Math.min(Math.round(state.delay * policy.factor), policy.maxMs);
+ return { delay, failures: 0, idle, stop: false, reason: null };
+}
+
+// ---------------------------------------------------------------------------
+// Rendering: DOM APIs and text nodes only
+// ---------------------------------------------------------------------------
+
+const headingOf = new WeakMap();
+
+function el(doc, tag, attributes = {}, ...children) {
+ const node = doc.createElement(tag);
+ for (const [name, value] of Object.entries(attributes)) {
+ if (value === undefined || value === null || value === false) continue;
+ node.setAttribute(name, value === true ? "" : String(value));
+ }
+ for (const child of children.flat()) {
+ if (child === undefined || child === null || child === false) continue;
+ node.append(typeof child === "string" ? doc.createTextNode(child) : child);
+ }
+ return node;
+}
+
+/** Text with `code` spans: backtick segments become elements, never markup. */
+function rich(doc, value) {
+ return text(value).split("`").map((part, index) => (index % 2 === 1 ? el(doc, "code", {}, part) : doc.createTextNode(part)));
+}
+
+function panel(doc, { stage, eyebrow, title, lead, tone }, ...children) {
+ const heading = el(doc, "h1", { id: "view-title", tabindex: "-1" }, title);
+ const node = el(doc, "section", { class: `panel${tone ? ` panel-${tone}` : ""}`, "aria-labelledby": "view-title", "data-stage": stage,
+ "data-tone": tone },
+ el(doc, "p", { class: "eyebrow" }, eyebrow),
+ heading,
+ lead ? el(doc, "p", { class: "lead" }, rich(doc, lead)) : null,
+ ...children);
+ headingOf.set(node, heading);
+ return node;
+}
+
+function actionButton(doc, label, variant, onClick) {
+ const node = el(doc, "button", { type: "button", class: `button button-${variant}` }, label);
+ node.addEventListener("click", (event) => {
+ event?.preventDefault?.();
+ onClick(node);
+ });
+ return node;
+}
+
+/** Stage list; `stopped` marks the install stage when it ended blocked or failed. */
+export function renderStages(doc, current, { stopped = false } = {}) {
+ const index = stages.findIndex(([id]) => id === current);
+ return stages.map(([id, label], position) => {
+ let state = position < index ? "done" : position === index ? "current" : "upcoming";
+ if (stopped && id === "install") state = "issue";
+ const glyph = { done: "✓", issue: "!" }[state] ?? String(position + 1);
+ const note = { done: " (completed)", issue: " (stopped)" }[state];
+ return el(doc, "li", { class: `stage stage-${state}`, "aria-current": state === "current" ? "step" : null },
+ el(doc, "span", { class: "stage-dot", "aria-hidden": "true" }, glyph),
+ el(doc, "span", { class: "stage-label" }, label),
+ note ? el(doc, "span", { class: "sr-only" }, note) : null);
+ });
+}
+
+export function renderLoading(doc) {
+ return panel(doc, { stage: "check", eyebrow: "Step 1 of 4 · Check", title: "Checking this computer",
+ lead: "Looking for Node.js, pnpm, Pi and Gentle Shell. Nothing changes until you confirm a plan." },
+ el(doc, "div", { class: "loader", "aria-hidden": "true" }, el(doc, "span"), el(doc, "span"), el(doc, "span")));
+}
+
+function disclosureCard(doc, item) {
+ return el(doc, "article", { class: `card disclosure${item.changes ? " disclosure-changes" : ""}` },
+ el(doc, "div", { class: "card-head" },
+ el(doc, "h2", { class: "card-title" }, item.title),
+ el(doc, "span", { class: `pill ${item.changes ? "pill-accent" : "pill-muted"}` },
+ el(doc, "span", { "aria-hidden": "true" }, item.changes ? "● " : "○ "), item.state)),
+ el(doc, "p", {}, rich(doc, item.text)),
+ item.detail ? el(doc, "p", { class: "detail" }, el(doc, "code", {}, item.detail)) : null);
+}
+
+/** Review screen. handlers: install({ consent, checkbox, button }), reload(), close(). */
+export function renderPlan(doc, model, handlers) {
+ const close = actionButton(doc, "Close installer", "ghost", () => handlers.close());
+ if (model.kind === "blocked") {
+ return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Something needs attention first",
+ lead: "Nothing was changed. Resolve these items, then check again.", tone: "warning" },
+ el(doc, "ul", { class: "blockers", role: "list" }, model.blockers.map((blocker) => el(doc, "li", { class: "alert alert-warning" },
+ el(doc, "h2", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, "! "), blocker.toolLabel),
+ el(doc, "p", {}, rich(doc, blocker.guidance)),
+ el(doc, "p", { class: "meta" }, "Code: ", el(doc, "code", {}, blocker.code))))),
+ el(doc, "div", { class: "actions" }, actionButton(doc, "Check again", "primary", () => handlers.reload()), close));
+ }
+ if (model.kind === "nothing") {
+ return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Gentle Shell is already set up",
+ lead: "This computer already has everything the wizard installs." },
+ el(doc, "p", {}, rich(doc, "Run `gentle-shell` in a terminal. To upgrade, run `gentle-shell update`.")),
+ el(doc, "div", { class: "actions" }, close));
+ }
+ const recovery = model.kind === "recovery";
+ const checkbox = el(doc, "input", { type: "checkbox", id: "consent", class: "consent-input", "aria-describedby": "consent-hint" });
+ const install = actionButton(doc, recovery ? "Complete setup" : "Install Gentle Shell", "primary", (button) =>
+ handlers.install({ consent: checkbox.checked === true, checkbox, button }));
+ checkbox.addEventListener("change", () => {
+ if (checkbox.checked) checkbox.removeAttribute("aria-invalid");
+ handlers.consentChanged?.(checkbox.checked === true);
+ });
+ const heading = recovery
+ ? { title: "Finish setting up Gentle Shell",
+ lead: "Gentle Shell is already installed; this completes setup. Nothing is reinstalled, and nothing changes until you confirm." }
+ : { title: "Review the installation plan",
+ lead: "Nothing changes until you confirm. This is exactly what the installer will do on this computer." };
+ return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", ...heading },
+ el(doc, "section", { class: "block", "aria-labelledby": "changes-title" },
+ el(doc, "h2", { id: "changes-title", class: "section-title" }, "What changes on this computer"),
+ el(doc, "div", { class: "grid" }, model.disclosures.map((item) => disclosureCard(doc, item)))),
+ el(doc, "section", { class: "block", "aria-labelledby": "steps-title" },
+ el(doc, "h2", { id: "steps-title", class: "section-title" }, recovery ? "Setup steps" : "Installation steps"),
+ el(doc, "ol", { class: "plan-steps" }, model.actions.map((action) => el(doc, "li", {},
+ el(doc, "span", { class: "plan-step-text" }, rich(doc, action.description)))))),
+ el(doc, "div", { class: "consent" },
+ checkbox,
+ el(doc, "div", {},
+ el(doc, "label", { for: "consent", class: "consent-label" }, "I reviewed this plan and agree to these changes on this computer."),
+ el(doc, "p", { id: "consent-hint", class: "hint" }, "The installer runs only the steps listed above. Existing installations are not replaced."))),
+ el(doc, "div", { class: "actions" }, install, close));
+}
+
+function logLine(doc, entry) {
+ const status = text(entry.status) || "unknown";
+ return el(doc, "li", { class: `log-line log-${status}` },
+ el(doc, "span", { class: "log-seq" }, String(entry.seq).padStart(3, "0")),
+ el(doc, "span", { class: "log-status" }, status),
+ el(doc, "span", { class: "log-step" }, text(entry.step)),
+ entry.reason ? el(doc, "span", { class: "log-reason" }, text(entry.reason)) : null);
+}
+
+function logPanel(doc, entries) {
+ const lines = el(doc, "ol", { class: "log-lines" });
+ const empty = el(doc, "p", { class: "log-empty" }, "Waiting for the first step…");
+ const node = el(doc, "section", { class: "log", "aria-labelledby": "log-title" },
+ el(doc, "div", { class: "log-head" },
+ el(doc, "span", { class: "log-dots", "aria-hidden": "true" }, el(doc, "span"), el(doc, "span"), el(doc, "span")),
+ el(doc, "h2", { id: "log-title", class: "log-title" }, "Installer log")),
+ el(doc, "div", { class: "log-body", tabindex: "0", role: "region", "aria-label": "Installer log lines" }, empty, lines));
+ const update = (all) => {
+ lines.replaceChildren(...all.map((entry) => logLine(doc, entry)));
+ if (all.length > 0) empty.setAttribute("hidden", "");
+ else empty.removeAttribute("hidden");
+ };
+ update(entries);
+ return { node, update };
+}
+
+const stepIcons = Object.freeze({ done: "✓", failed: "✕", blocked: "!", running: "", pending: "", skipped: "–", unknown: "?" });
+function stepRow(doc, item) {
+ return el(doc, "li", { class: `step step-${item.status}`, "aria-current": item.status === "running" ? "step" : null },
+ el(doc, "span", { class: "step-icon", "aria-hidden": "true" }, stepIcons[item.status]),
+ el(doc, "span", { class: "step-label" }, item.label),
+ el(doc, "span", { class: "step-status" }, item.statusText));
+}
+
+/** Progress screen with in-place updates, so focus stays on the heading. */
+export function renderProgress(doc) {
+ const bar = el(doc, "progress", { id: "install-progress", max: "1", value: "0", "aria-describedby": "progress-count" });
+ const count = el(doc, "span", { id: "progress-count", class: "progress-count" }, "Starting…");
+ const steps = el(doc, "ol", { class: "steps" });
+ const log = logPanel(doc, []);
+ const node = panel(doc, { stage: "install", eyebrow: "Step 3 of 4 · Install", title: "Installing Gentle Shell",
+ lead: "This can take a few minutes. Keep this tab and the terminal open." },
+ el(doc, "div", { class: "progress" },
+ el(doc, "div", { class: "progress-head" }, el(doc, "label", { for: "install-progress", class: "progress-label" }, "Progress"), count),
+ bar),
+ el(doc, "div", { class: "progress-body" },
+ el(doc, "section", { class: "block", "aria-labelledby": "progress-steps-title" },
+ el(doc, "h2", { id: "progress-steps-title", class: "section-title" }, "Steps"), steps),
+ log.node));
+ const update = (model, entries) => {
+ bar.setAttribute("max", String(Math.max(model.total, 1)));
+ bar.setAttribute("value", String(model.done));
+ count.textContent = model.total > 0 ? `${model.done} of ${model.total} steps` : "Starting…";
+ steps.replaceChildren(...model.items.map((item) => stepRow(doc, item)));
+ log.update(entries);
+ };
+ return { node, update };
+}
+
+/** Final screen. handlers: close(), copy?(text) -> Promise. */
+export function renderOutcome(doc, model, handlers, entries = []) {
+ const children = [
+ el(doc, "div", { class: `alert alert-${model.tone}` },
+ el(doc, "p", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, model.tone === "success" ? "✓ " : model.tone === "warning" ? "! " : "✕ "),
+ model.badge),
+ el(doc, "p", {}, rich(doc, model.guidance))),
+ ];
+ if (model.command) {
+ const command = el(doc, "code", { class: "command-text" }, model.command);
+ const copy = handlers.copy
+ ? actionButton(doc, "Copy", "ghost", async (button) => {
+ const copied = await handlers.copy(model.command);
+ button.textContent = copied ? "Copied" : "Copy failed";
+ })
+ : null;
+ children.push(el(doc, "section", { class: "block", "aria-labelledby": "next-title" },
+ el(doc, "h2", { id: "next-title", class: "section-title" }, "Next"),
+ el(doc, "ol", { class: "next-steps" }, model.next.map((line) => el(doc, "li", {}, el(doc, "span", { class: "plan-step-text" }, rich(doc, line))))),
+ el(doc, "div", { class: "command" }, el(doc, "span", { class: "command-prompt", "aria-hidden": "true" }, "$"), command, copy)));
+ } else {
+ children.push(el(doc, "section", { class: "block", "aria-labelledby": "next-title" },
+ el(doc, "h2", { id: "next-title", class: "section-title" }, "What to do next"),
+ el(doc, "ol", { class: "next-steps" }, model.next.map((line) => el(doc, "li", {}, el(doc, "span", { class: "plan-step-text" }, rich(doc, line)))))));
+ }
+ if (model.notes.length > 0) children.push(el(doc, "ul", { class: "notes" }, model.notes.map((note) => el(doc, "li", {}, note))));
+ const facts = [];
+ if (model.failedStep) facts.push(el(doc, "li", {}, "Failed step: ", el(doc, "code", {}, model.failedStep.id)));
+ if (model.reason) facts.push(el(doc, "li", {}, "Reason: ", el(doc, "code", {}, model.reason)));
+ if (facts.length > 0) children.push(el(doc, "ul", { class: "facts" }, facts));
+ // Untrusted process output: one plain text node, never `rich` code spans.
+ if (model.detail) {
+ children.push(el(doc, "section", { class: "block", "aria-labelledby": "setup-detail-title" },
+ el(doc, "h2", { id: "setup-detail-title", class: "section-title" }, `Last error from ${model.detailCommand}`),
+ el(doc, "p", { class: "detail" }, el(doc, "code", {}, model.detail))));
+ }
+ if (model.completed.length > 0) {
+ children.push(el(doc, "details", { class: "completed" },
+ // A blocked run only passed read-only checks before it stopped.
+ el(doc, "summary", {}, `${model.outcome === "blocked" ? "Checks that passed" : "Completed steps"} (${model.completed.length})`),
+ el(doc, "ul", {}, model.completed.map((item) => el(doc, "li", {}, el(doc, "span", { "aria-hidden": "true" }, "✓ "), item.label)))));
+ }
+ if (entries.length > 0) {
+ const log = logPanel(doc, entries);
+ children.push(el(doc, "details", { class: "completed" }, el(doc, "summary", {}, `Installer log (${entries.length} entries)`), log.node));
+ }
+ children.push(el(doc, "div", { class: "actions" }, actionButton(doc, "Close installer", model.tone === "success" ? "primary" : "secondary",
+ () => handlers.close())));
+ return panel(doc, { stage: "done", eyebrow: "Step 4 of 4 · Done", title: model.title, lead: model.lead, tone: model.tone }, ...children);
+}
+
+export function renderClosed(doc, outcome) {
+ const installed = outcome?.outcome === "ready" || outcome?.outcome === "terminal-action-required";
+ return panel(doc, { stage: "done", eyebrow: "Finished", title: "Installer closed",
+ lead: installed
+ ? "You can close this tab. Run `gentle-shell` in a terminal to start."
+ : "You can close this tab. The terminal shows the same result, and nothing else will run." });
+}
+
+export function renderSession(doc) {
+ return panel(doc, { stage: "check", eyebrow: "Session ended", title: "This session has ended",
+ lead: "The installer link works once, in the browser that opened it, and stops when the installer closes. Run the installer again from your terminal to get a new link." });
+}
+
+// ---------------------------------------------------------------------------
+// Controller
+// ---------------------------------------------------------------------------
+
+function isProgress(body) {
+ return body !== null && typeof body === "object" && Array.isArray(body.entries);
+}
+const sessionEnded = (status) => status === 401 || status === 403;
+const noAnswer = "The installer did not answer. Check that the terminal is still running it, then try again.";
+
+/**
+ * createWizard({ document, fetch, setTimeout, clearTimeout, clipboard? })
+ * Expects #view, #stages, #alert (role=alert) and #status (role=status).
+ */
+export function createWizard({ document: doc, fetch: request, setTimeout: later, clearTimeout: cancel, clipboard = null }) {
+ const roots = {
+ view: doc.getElementById("view"),
+ stages: doc.getElementById("stages"),
+ alert: doc.getElementById("alert"),
+ status: doc.getElementById("status"),
+ };
+ const state = { plan: null, steps: [], entries: [], lastSeq: 0, poll: initialPoll(), timer: null, outcome: null, busy: false,
+ progress: null, running: false };
+
+ async function api(path, { method = "GET", body } = {}) {
+ const init = { method, headers: { ...API_HEADERS }, credentials: "same-origin", cache: "no-store" };
+ if (body !== undefined) {
+ init.headers["Content-Type"] = "application/json";
+ init.body = JSON.stringify(body);
+ }
+ const response = await request(path, init);
+ let data = null;
+ try {
+ data = await response.json();
+ } catch {
+ data = null;
+ }
+ return { status: response.status, body: data };
+ }
+
+ function announce(message) {
+ if (roots.status) roots.status.textContent = message;
+ }
+ function clearError() {
+ roots.alert?.replaceChildren();
+ }
+ function showError(message, retry = null, { tone = "error", title = "Problem" } = {}) {
+ const retryButton = retry ? actionButton(doc, "Try again", "secondary", () => {
+ clearError();
+ retry();
+ }) : null;
+ roots.alert?.replaceChildren(el(doc, "div", { class: `alert alert-${tone}` },
+ el(doc, "p", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, tone === "error" ? "✕ " : "! "), title),
+ el(doc, "p", {}, message),
+ retryButton ? el(doc, "div", { class: "actions" }, retryButton) : null));
+ }
+ function stopPolling() {
+ if (state.timer !== null) cancel(state.timer);
+ state.timer = null;
+ }
+ function show(node, { focus = true } = {}) {
+ const stage = node.getAttribute("data-stage");
+ const stopped = stage === "done" && ["warning", "error"].includes(node.getAttribute("data-tone"));
+ roots.stages?.replaceChildren(...renderStages(doc, stage, { stopped }));
+ roots.view.replaceChildren(node);
+ if (focus) headingOf.get(node)?.focus();
+ }
+ function ingest(entries) {
+ const fresh = list(entries).filter((entry) => Number.isInteger(entry?.seq) && entry.seq > state.lastSeq);
+ for (const entry of fresh) state.lastSeq = Math.max(state.lastSeq, entry.seq);
+ state.entries = [...state.entries, ...fresh].slice(-LOG_LIMIT);
+ return fresh;
+ }
+
+ async function resume() {
+ clearError();
+ let result;
+ try {
+ result = await api("/api/progress?after=0");
+ } catch {
+ showError(noAnswer, resume);
+ return;
+ }
+ if (sessionEnded(result.status)) return showSession();
+ if (result.status !== 200 || !isProgress(result.body)) {
+ showError(noAnswer, resume);
+ return;
+ }
+ ingest(result.body.entries);
+ if (result.body.outcome) return showOutcome(result.body.outcome);
+ if (result.body.running === true) return showProgress();
+ return loadPlan();
+ }
+
+ async function loadPlan(notice = "") {
+ clearError();
+ let result;
+ try {
+ result = await api("/api/plan");
+ } catch {
+ showError(noAnswer, () => loadPlan(notice));
+ return;
+ }
+ if (sessionEnded(result.status)) return showSession();
+ if (result.status === 409 && result.body?.error === "install-running") return showProgress();
+ if (result.status !== 200 || result.body === null || typeof result.body !== "object") {
+ showError("The installer could not check this computer. Check the terminal for details, then try again.", () => loadPlan(notice));
+ return;
+ }
+ state.plan = planModel(result.body);
+ state.steps = state.plan.steps;
+ show(renderPlan(doc, state.plan, { install, reload: () => loadPlan(), close, consentChanged: (checked) => { if (checked) clearError(); } }));
+ if (notice) showError(notice, null, { tone: "warning", title: "Plan updated" });
+ }
+
+ async function install({ consent, checkbox, button }) {
+ if (state.busy) return;
+ const body = installBody(state.plan?.planId, consent);
+ if (body === null) {
+ showError("Check the box to confirm that you reviewed the plan. Nothing was installed.");
+ checkbox.setAttribute("aria-invalid", "true");
+ checkbox.focus();
+ return;
+ }
+ clearError();
+ state.busy = true;
+ button.disabled = true;
+ let result;
+ try {
+ result = await api("/api/install", { method: "POST", body });
+ } catch {
+ // The request may have reached the host before the connection broke: check
+ // once, and follow a running or finished installation instead of offering a retry.
+ const current = await currentProgress();
+ state.busy = false;
+ if (current?.outcome) return showOutcome(current.outcome);
+ if (current?.running) return showProgress();
+ button.disabled = false;
+ showError(noAnswer);
+ return;
+ }
+ state.busy = false;
+ const next = interpretInstall(result.status, result.body);
+ if (next.next === "progress") return showProgress();
+ if (next.next === "reload-plan") return loadPlan(next.message);
+ if (next.next === "outcome") return resume();
+ if (next.next === "session") return showSession();
+ button.disabled = false;
+ showError(next.message, () => loadPlan());
+ }
+
+ /** One GET /api/progress?after=0, or null when the host does not answer usefully. */
+ async function currentProgress() {
+ try {
+ const result = await api("/api/progress?after=0");
+ if (result.status !== 200 || !isProgress(result.body)) return null;
+ ingest(result.body.entries);
+ return { running: result.body.running === true, outcome: result.body.outcome ?? null };
+ } catch {
+ return null;
+ }
+ }
+
+ function progressSnapshot() {
+ return progressModel(state.steps, state.entries, { running: state.running, outcome: state.outcome });
+ }
+
+ function showProgress() {
+ stopPolling();
+ state.running = true;
+ state.poll = initialPoll();
+ state.progress = renderProgress(doc);
+ state.progress.update(progressSnapshot(), state.entries);
+ show(state.progress.node);
+ announce("Installation started.");
+ schedule(state.poll.delay);
+ }
+
+ function schedule(ms) {
+ stopPolling();
+ state.timer = later(() => {
+ state.timer = null;
+ void pollOnce();
+ }, ms);
+ }
+
+ function pollFailed(event) {
+ state.poll = nextPoll(state.poll, event);
+ if (state.poll.stop) return pollStopped();
+ schedule(state.poll.delay);
+ }
+
+ function pollStopped() {
+ const message = state.poll.reason === "stalled"
+ ? "The installer stopped reporting progress. Check the terminal, then try again."
+ : "Lost contact with the installer. Check that the terminal is still running it, then try again.";
+ showError(message, () => {
+ state.poll = initialPoll();
+ schedule(0);
+ });
+ }
+
+ async function pollOnce() {
+ let result;
+ try {
+ result = await api(`/api/progress?after=${state.lastSeq}`);
+ } catch {
+ return pollFailed({ type: "error" });
+ }
+ if (sessionEnded(result.status)) return showSession();
+ if (result.status !== 200 || !isProgress(result.body)) return pollFailed({ type: "error" });
+ const fresh = ingest(result.body.entries);
+ const outcome = result.body.outcome ?? null;
+ state.running = result.body.running === true;
+ state.poll = nextPoll(state.poll, { type: "entries", count: fresh.length, running: state.running, outcome });
+ if (outcome) return showOutcome(outcome);
+ clearError();
+ state.progress?.update(progressSnapshot(), state.entries);
+ const last = fresh.at(-1);
+ if (last) announce(`${stepLabel(last.step)}: ${statusText[last.status] ?? last.status}.`);
+ if (state.poll.stop) return pollStopped();
+ schedule(state.poll.delay);
+ }
+
+ function showOutcome(outcome) {
+ stopPolling();
+ state.outcome = outcome;
+ state.running = false;
+ const model = outcomeModel(outcome);
+ const copy = clipboard ? async (value) => {
+ try {
+ await clipboard.writeText(value);
+ announce("Command copied.");
+ return true;
+ } catch {
+ return false;
+ }
+ } : undefined;
+ show(renderOutcome(doc, model, { close, copy }, state.entries));
+ announce(`${model.title}.`);
+ }
+
+ function showSession() {
+ stopPolling();
+ clearError();
+ show(renderSession(doc));
+ }
+
+ async function close() {
+ if (state.busy) return;
+ state.busy = true;
+ let result;
+ try {
+ result = await api("/api/shutdown", { method: "POST", body: {} });
+ } catch {
+ state.busy = false;
+ showError("The installer did not answer. It may already be closed; check the terminal.");
+ return;
+ }
+ state.busy = false;
+ if (result.status === 200) {
+ stopPolling();
+ clearError();
+ show(renderClosed(doc, state.outcome));
+ announce("Installer closed.");
+ return;
+ }
+ if (sessionEnded(result.status)) return showSession();
+ if (result.status === 409) {
+ showError("An installation is still running. Wait for it to finish, then close the installer.");
+ return;
+ }
+ showError("The installer could not close. Press Ctrl+C in the terminal to stop it.");
+ }
+
+ return { start: resume, state };
+}
+
+if (typeof window !== "undefined" && typeof document !== "undefined" && typeof fetch === "function") {
+ const app = createWizard({
+ document,
+ fetch: (...args) => fetch(...args),
+ setTimeout: (fn, ms) => window.setTimeout(fn, ms),
+ clearTimeout: (id) => window.clearTimeout(id),
+ clipboard: window.navigator?.clipboard ?? null,
+ });
+ void app.start();
+}
diff --git a/bin/gentle-shell-install.mjs b/bin/gentle-shell-install.mjs
new file mode 100644
index 000000000..983387a59
--- /dev/null
+++ b/bin/gentle-shell-install.mjs
@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+import { spawn as spawnChild } from "node:child_process";
+import * as fsPromises from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, posix, resolve, win32 } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { collectInventory, planPreflight } from "../scripts/installer-preflight.mjs";
+import { createProbes, hostAdapters, userEnvironment } from "../scripts/installer-probes.mjs";
+import { packageNativeGentleAi, pnpmInvocation, runStandardInstall } from "../scripts/installer-runner.mjs";
+import { createInstallerServer } from "../scripts/installer-server.mjs";
+
+// Browser installation wizard entry, started by the bootstrap with no argv.
+// Thin wiring only: real probes and adapters, the standard runner and the
+// loopback host. Exit 0 keeps the bootstrap's tools; nonzero removes them.
+
+/** Fixed per-platform browser opener for a local file, spawned with shell:false; null when none. */
+export function openerFor(platform, file, env) {
+ if (platform === "darwin") return { command: "/usr/bin/open", args: [file] };
+ if (platform === "linux") return { command: "xdg-open", args: [file] };
+ if (platform === "win32") {
+ // Absolute path: Windows would otherwise search the current directory first.
+ const systemRoot = env.SystemRoot ?? env.SYSTEMROOT;
+ if (typeof systemRoot !== "string" || !win32.isAbsolute(systemRoot)) return null;
+ return { command: win32.join(systemRoot, "System32", "rundll32.exe"), args: ["url.dll,FileProtocolHandler", file] };
+ }
+ return null;
+}
+
+function escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
+}
+
+/** Static page that refreshes to the session URL; no script. */
+export function redirectPage(url) {
+ const href = escapeHtml(url);
+ return [
+ "",
+ ' ',
+ ' ',
+ ` `,
+ "Gentle Shell installation wizard ",
+ `Continue to the Gentle Shell installation wizard
`,
+ "",
+ ].join("\n");
+}
+
+/**
+ * Writes the redirect page into a fresh private directory (0700, file 0600)
+ * under tempDir, so the one-time code never appears in the opener's argv.
+ * remove() is idempotent and best effort: it deletes the file, then the directory.
+ */
+export async function writeRedirect(url, { tempDir = tmpdir(), files = fsPromises } = {}) {
+ const dir = await files.mkdtemp(join(tempDir, "gentle-shell-install-"));
+ const file = join(dir, "open.html");
+ let removed = false;
+ const remove = async () => {
+ if (removed) return;
+ removed = true;
+ await files.unlink(file).catch(() => {});
+ await files.rmdir(dir).catch(() => {});
+ };
+ try {
+ await files.chmod(dir, 0o700);
+ await files.writeFile(file, redirectPage(url), { mode: 0o600, flag: "wx" });
+ } catch (error) {
+ await remove();
+ throw error;
+ }
+ return { dir, file, remove };
+}
+
+/**
+ * Opens url through a private redirect file with the fixed opener (shell:false,
+ * detached). Returns { file, remove } while the file exists, or null when nothing
+ * was opened; a failed opener removes the file. The printed URL stays the fallback.
+ */
+export async function openBrowser(url, { platform = process.platform, env = process.env, tempDir = tmpdir(), files = fsPromises,
+ spawn = spawnChild } = {}) {
+ // Resolve the opener first so no file is written when there is none.
+ if (openerFor(platform, "", env) === null) return null;
+ let redirect;
+ try {
+ redirect = await writeRedirect(url, { tempDir, files });
+ } catch {
+ return null;
+ }
+ const opener = openerFor(platform, redirect.file, env);
+ try {
+ const child = spawn(opener.command, opener.args, { shell: false, detached: true, stdio: "ignore", windowsHide: true });
+ child.once("error", () => {
+ void redirect.remove();
+ });
+ child.unref();
+ } catch {
+ await redirect.remove();
+ return null;
+ }
+ return { file: redirect.file, remove: redirect.remove };
+}
+
+/** 0 only for a completed installation; blocked, failed or none is 1. */
+export function exitCodeFor(outcome) {
+ return ["ready", "terminal-action-required"].includes(outcome?.outcome) ? 0 : 1;
+}
+
+/** The runner's environment: the user's real PATH, so a temporary bootstrap npm
+ * or node never counts as persistent and globalBin.onPath stays truthful. A
+ * POSIX bootstrap pnpm (passed only through PATH) stays reachable as the last
+ * PATH entry; Windows passes pnpm through the GENTLE_INSTALL_PNPM_* handoff.
+ */
+export async function runnerEnvironment({ platform, env, fs }) {
+ const user = userEnvironment({ platform, env });
+ if (platform === "win32" || (await pnpmInvocation(user, platform, fs)) !== null) return user;
+ const wizard = await pnpmInvocation(env, platform, fs);
+ if (wizard === null || wizard.prefix.length > 0) return user;
+ const rest = String(user.PATH ?? "").split(posix.delimiter).filter((entry) => entry.length > 0);
+ return { ...user, PATH: [...rest, posix.dirname(wizard.command)].join(posix.delimiter) };
+}
+
+async function main() {
+ const { platform, arch, env } = process;
+ const { run, fs } = hostAdapters();
+ // The redirect file is removed once the code is redeemed, or when the host closes.
+ let redeemed = false;
+ let redirect = null;
+ const host = createInstallerServer({
+ onRedeemed: () => {
+ redeemed = true;
+ void redirect?.remove();
+ },
+ assetsDir: fileURLToPath(new URL("../assets/install-wizard/", import.meta.url)),
+ collectPlan: async () => {
+ // Fresh probes every time: createProbes caches its global package listing.
+ const inventory = await collectInventory({ platform, arch, probes: createProbes({ platform, env, run, fs }) });
+ return { inventory, plan: planPreflight(inventory) };
+ },
+ runInstall: async (request, log) => runStandardInstall(request, {
+ platform,
+ nodePath: process.execPath,
+ env: await runnerEnvironment({ platform, env, fs }),
+ run,
+ fs,
+ verifyGentleAi: packageNativeGentleAi,
+ log,
+ }),
+ });
+ let started;
+ try {
+ started = await host.listen();
+ } catch {
+ console.error("The installation wizard could not start its local server. Nothing was installed.");
+ process.exitCode = 1;
+ return;
+ }
+ console.log("Gentle Shell installation wizard");
+ console.log(`Open this link in your browser (it works once and expires in 2 minutes):\n ${started.url}`);
+ console.log("Press Ctrl+C to stop the wizard.");
+
+ let signalled = false;
+ const stop = () => {
+ signalled = true;
+ host.close("signal");
+ };
+ process.once("SIGINT", stop);
+ process.once("SIGTERM", stop);
+ const opening = openBrowser(started.url).then((opened) => {
+ redirect = opened;
+ // The printed URL may have been redeemed while the file was being written.
+ if (redeemed) void opened?.remove();
+ return opened;
+ });
+ const { reason, outcome } = await host.closed;
+ process.off("SIGINT", stop);
+ process.off("SIGTERM", stop);
+ await (await opening)?.remove();
+ const code = exitCodeFor(outcome);
+ if (outcome?.guidance) console.log(outcome.guidance);
+ else if (reason === "idle") console.log("The wizard closed after 30 minutes without activity. Nothing was installed.");
+ else console.log("The wizard closed without installing anything.");
+ if (signalled) process.exit(code);
+ process.exitCode = code;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ main().catch(() => {
+ console.error("The installation wizard stopped unexpectedly.");
+ process.exitCode = 1;
+ });
+}
diff --git a/docs/install-wizard.md b/docs/install-wizard.md
new file mode 100644
index 000000000..0bc0b2ad0
--- /dev/null
+++ b/docs/install-wizard.md
@@ -0,0 +1,1287 @@
+# Installation wizard groundwork
+
+The preflight planner, real host probes, POSIX bootstrap, Windows prerequisite
+foundation, the standard installation runner module (including runtime
+persistence), the secure local wizard host with its packaged entry and the
+interactive browser wizard UI are implemented. Windows native validation
+remains unavailable locally.
+**This is not a supported installation path yet:** published bootstrap
+artifacts, real browsers and native macOS/Windows clean-machine runs are still
+unverified (T7); one Linux clean-container run passed (see
+[Clean-machine acceptance](#clean-machine-acceptance-t7)). For ordinary
+installation and terminal use, follow the [README](../README.md).
+
+## What is available
+
+`scripts/installer-preflight.mjs` exports four small integration surfaces:
+
+| API | Contract |
+| --- | --- |
+| `requirements` | Repository-derived Node/Pi minima, pnpm acquisition pin, package version, native installer pin and Windows Go minimum. |
+| `collectInventory({ platform, arch, probes })` | Calls injected named read-only probes serially. Missing/failed probes become unknown; error text is not retained. |
+| `planPreflight(inventory)` | Pure classification and ordered action intents; no commands, downloads, installation or setup execution. |
+| `pnpmGlobalBin({ platform, env })` | Pure pnpm 11 global-bin resolution: `{ pnpmHome, path, onPath }` or `null` when unknowable. |
+| `persistencePins` | Fixed runtime persisted under PNPM_HOME: Node 24.21.0, its bundled npm 11.19.0 and the pnpm acquisition pin. |
+
+`scripts/installer-probes.mjs` supplies the real read-only probes for
+`collectInventory`; see [Host probes](#host-probes).
+
+Current requirements come from `package.json` and
+[`gentle-ai-installer.mjs`](../scripts/gentle-ai-installer.mjs): Node ≥22.19.0,
+Pi ≥0.99.1, pnpm acquisition 11.1.1, Gentle Shell package 4.0.0 and package-local
+Gentle AI 4.0.0. Windows native source builds require Go ≥1.25.10 when the
+required usable native binary is missing. A reusable binary does not require Go.
+Pins change with the repository; the API reads the metadata rather than copying
+these values into a second installer.
+
+## Probe adapter contract
+
+Supply `platform` as `linux`, `darwin` or `win32`, and `arch` as `x64` or `arm64`.
+These are planning targets, **not evidence of native OS/libc support**. Other
+pairs produce an unsupported-target blocker and no actions. Native lanes,
+minimum OS versions, libc compatibility, artifact trust and elevation handling
+remain to be verified in later units.
+
+Probes are named `node`, `pnpm`, `pi`, `shell`, `gentleAi`, `go`, `globalBin`, and
+`setup`. `collectInventory` has no default probes; the real implementations live
+in [`installer-probes.mjs`](#host-probes). Probes must use bounded read-only
+checks, never launcher setup, postinstall, paid model requests, credential
+inspection or home writes. The collector cannot enforce purity of
+caller-provided functions; adapter implementations require review.
+
+- Tool observations use `{ available: true, version, usable: true }`; absence is
+ `{ available: false }`. Unknown availability, usability or unparseable versions
+ block planning. Versions are exact stable `major.minor.patch` strings (optional
+ `v` prefix); prereleases and raw command output are unknown, not silently reused.
+- Node may add `persistent` (resolvable from the user's PATH without bootstrap
+ tool directories) and `npm` (a genuine npm resolves there) booleans; pnpm
+ may add `persistent` (resolvable from the user's PATH). A `false` value
+ yields the runtime persistence intents below; absent or `null` values add
+ nothing, and the runner's own npm gate still applies.
+- Shell additionally needs `global: true`, proving normal global ownership.
+ Compatible newer Node, Pi and Shell versions are reused, never downgraded.
+- pnpm additionally needs `compatible: true`, based on its actual Node engine and
+ required global-install capabilities. The acquisition pin is not a minimum:
+ newer compatible pnpm is reusable. Do not infer compatibility from its version
+ alone; the later acquisition adapter must validate the pinned engine too.
+- Gentle AI additionally needs `compatible: true`: evidence from the normal
+ package-local binary resolution/integrity contract, for the selected Shell
+ installation. Its exact installer pin is required; another version blocks
+ rather than being overwritten. Do not confuse an unrelated PATH binary with
+ the package's reusable native binary. For a newer reusable Shell, the adapter
+ must establish native-pin compatibility before presenting this evidence.
+- Global-bin observations use `{ available: true, path, writable: true, onPath }`.
+ A known missing PATH entry yields an explicit setup intent. Unknown or
+ non-writable directories block; no directory is created or permission probed
+ by writing. pnpm 11 places global executables in **`$PNPM_HOME/bin`**, not
+ `$PNPM_HOME`, and `pnpm root -g`/`bin -g` fail when that directory is not on
+ PATH. Probes must report `pnpmGlobalBin().path`, whose `onPath` compares PATH
+ entries with that bin directory. PNPM_HOME is the user's existing absolute
+ value; otherwise pnpm's documented default (`$XDG_DATA_HOME/pnpm` or
+ `~/.local/share/pnpm` on Linux, `~/Library/pnpm` on macOS,
+ `%LOCALAPPDATA%\pnpm` on Windows). A relative PNPM_HOME or missing home is
+ unknown, not guessed.
+- `setup` is a boolean evidence of normal Shell setup readiness. Unknown setup
+ on an existing stack blocks; a missing Shell/native binary needs normal setup.
+
+## Reading a plan
+
+Tool statuses distinguish `unavailable`, `unknown`, `incompatible`, `reusable`,
+`needs-setup` and `not-required`. Any blocker suppresses all actions: repairing an
+existing incompatible or uncertain component requires a later explicit decision,
+not automatic replacement. `ready` means no acquisition/setup is indicated by
+this inventory, **not that verification has executed**.
+
+A clean target receives these intents in dependency order:
+
+1. Acquire and verify Node, then pnpm; prepare the usable global-bin environment.
+ Persist only what is missing. A bootstrap-only reusable Node gets the full
+ group, always together: `persist-node` (`persist-runtime`, version
+ 24.21.0), `persist-package-managers` (`install-global`) and
+ `configure-npm-prefix` (`configure`). A persistent Node is never replaced:
+ it gets at most one intent, `persist-npm` (npm 11.19.0) when no genuine
+ npm resolves, `persist-pnpm` (pnpm 11.1.1) when pnpm is bootstrap-only, or
+ `persist-package-managers` when both are missing.
+2. On Windows, acquire and verify Go before missing native provisioning.
+3. Install Pi globally, then `gentle-pi` globally (its existing postinstall owns
+ native installation). For an existing Shell with missing native binary, call
+ the existing installer instead.
+4. Run normal Shell setup and verify stack readiness. Verification is always
+ included, even when all components can be reused.
+
+A recoverable setup (the pinned stack is installed, Gentle AI is verified and
+only setup did not finish) plans exactly `setup-global-bin` (only when the
+global bin is off PATH), `setup-shell` and `verify-readiness`: never an
+acquisition, persistence or installation intent. See
+[Setup recovery](#setup-recovery).
+
+Actions are structured `{ id, kind, target, version? }` descriptors, **not shell
+commands**. Later runners must re-inventory after changes, verify prerequisite
+versions and global-bin readiness before dependent steps, obtain explicit consent
+and stop on mandatory failures. They must preserve existing versions/paths and
+use normal global package ownership and default `~/.gentle-shell/agent` semantics.
+No alternate product root, Engram database/server, credential migration or custom
+companion installer belongs in this plan.
+
+## Remaining boundaries
+
+`npm:` registry sources are package references, not npm executable calls. Pi's
+`npmCommand` supports pnpm, but independent upstream `npm exec` control remains
+unverified. **This preflight does not establish an npm-free installation chain.**
+
+The consent UI is the local wizard host described below. A published
+distribution bundle and native acceptance evidence remain future work in the
+[feature plan](../odd/tasks/browser-install-wizard.md). Deterministic injected
+tests do not prove clean-machine installation on Windows, macOS or Linux.
+
+Focused verification:
+
+```sh
+node --experimental-strip-types --test tests/installer-server.test.ts tests/verify-package-files.test.ts tests/installer-probes.test.ts tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts
+sh -n scripts/bootstrap.sh
+```
+
+## Host probes
+
+`scripts/installer-probes.mjs` exports `createProbes({ platform, env, run, fs,
+home?, verifyGentleAi? })`, returning the eight named probes for
+`collectInventory`. `env` is the wizard's own environment, with bootstrap tools
+first on PATH. Every probe returns the documented shape or the unknown shape
+`{ available: null }`; adapter errors are caught and never retained.
+
+| Export | Contract |
+| --- | --- |
+| `bootstrapRoots({ platform, env })` | Bootstrap tool roots: an absolute `GENTLE_BOOTSTRAP_TOOLS` plus every PATH entry segment named `.gentle-shell-bootstrap-tools.*` (case-insensitive on Windows). |
+| `userEnvironment({ platform, env })` | A copy of `env` whose PATH omits entries inside those roots: the user's real PATH. |
+| `hostAdapters({ maxOutputBytes?, maxTextBytes? })` | Real `run` and read-only `fs` adapters, described below. |
+
+Adapters:
+
+- `run(command, argv, { env, deadlineMs })` is the runner's process contract:
+ argv arrays with `shell:false`, SIGKILL at the deadline and stdout bounded
+ to `maxOutputBytes` (default 1 MiB, reported as `truncated: true`). stderr
+ is discarded because it can hold private paths, unless the caller passes
+ `stderrTail: ` (a positive integer, clamped to 4 KiB): then stderr is
+ piped and only its last bytes are kept and returned as `stderrTail`, also on a
+ deadline. Only the runner's `shell-setup` and `persist-path` steps ask for it. A spawn failure returns
+ `code: null`. The deadline signals the direct child only, not descendants;
+ it then destroys the child's pipes and settles as timed out (`code: null`,
+ `signal: "SIGKILL"`) without waiting for `close`, which a descendant holding
+ stdout could otherwise delay indefinitely.
+- `fs` offers `isFile`, `isDirectory`, `exists` (only ENOENT/ENOTDIR are
+ absent), `realpath`, `readText` (regular files up to `maxTextBytes`) and
+ `writable` (an access check, never a write). No probe creates, deletes or
+ writes anything.
+
+Probes run only fixed argv: `node --version`, `pnpm --version`,
+`pnpm list -g --depth 0 --json` (once, shared), `go version` and npm's
+`npm-cli.js --version`. Deadlines are 10 seconds for versions and 30 seconds for
+the listing; truncated, nonzero, signalled or timed-out output is unknown.
+
+| Probe | Evidence |
+| --- | --- |
+| `node` | The first `node` on the user's real PATH (Go `exec.LookPath` order, PATHEXT on Windows), else the bootstrap one. `persistent` says which. `npm` is the runner's genuine-npm proof in the user's real PATH with `$PNPM_HOME/bin` first, so a bootstrap npm never counts. A Windows `.cmd`/`.bat` cannot run with `shell:false` and is unknown. |
+| `pnpm` | The runner's invocation (bootstrap handoff, or a POSIX `pnpm` on PATH). `compatible` requires a successful run (pnpm checks its Node engine at startup) at the pinned major and at least the pin, because the runner's argv is verified for pnpm 11 only. `persistent` is whether any `pnpm` resolves on the real PATH. |
+| `pi`, `shell` | pnpm-global entries from the listing. A package that is not pnpm-global but whose command (`pi`, `gentle-shell`) resolves on the real PATH is unknown, never absent, so another installation is not duplicated. Shell is `usable` only when `$PNPM_HOME/bin/gentle-shell` (`.cmd` on Windows) exists. |
+| `gentleAi` | Absent without gentle-pi or without its package-native binary. Otherwise compatible only for this package version, a listed path that resolves inside PNPM_HOME and `verifyGentleAi` (default `packageNativeGentleAi`) success; anything else is unknown. |
+| `go` | `go version` from the real PATH; `go1.22` normalizes to `1.22.0`; devel and release-candidate builds are unknown. |
+| `globalBin` | `pnpmGlobalBin` over the real PATH; `writable` is write access on the nearest existing ancestor of `$PNPM_HOME/bin` (itself included). A non-directory ancestor is not writable. |
+| `setup` | `false` when gentle-pi is absent. `{ available: true, recoverable: true }` for the pinned stack this pnpm installed (see [Setup recovery](#setup-recovery)), using the runner's `recoverableStackRoot`. Unknown otherwise, because an existing Shell's setup readiness has no read-only evidence. |
+
+A failed, empty or unparseable listing makes Pi, Shell, Gentle AI and setup
+unknown, which blocks planning. A package listed in two projects is ambiguous
+and unknown.
+
+## Standard installation runner
+
+`scripts/installer-runner.mjs` exports `runStandardInstall({ plan, consent },
+adapters)`, the fixed installation step the local wizard host
+(`bin/gentle-shell-install.mjs`, below) calls after bootstrap and a fresh
+preflight. It is a pure module: every process, filesystem,
+environment, integrity and log effect goes through adapters supplied by trusted
+local code, never through the browser.
+
+### Request contract
+
+The request is exactly `{ plan, consent }`. `plan` must be an unmodified
+`planPreflight` result: every action must equal a descriptor preflight can emit
+(same id, kind, target and repository-derived version, no extra keys). Extra
+request keys, commands, URLs, roots, environment or altered versions are
+rejected as `invalid-request` before any process runs. `consent` must be the
+boolean `true`; anything else returns `consent-required` with no commands. The
+consent covers the whole displayed plan, including the PATH profile change and
+the runtime persistence and npm prefix actions below, so the wizard must show
+those changes before asking.
+
+### Adapters
+
+| Adapter | Contract |
+| --- | --- |
+| `platform` | `linux`, `darwin` or `win32`. |
+| `nodePath` | Absolute Node executable of the trusted host, used for npm proof and `gentle-shell setup`. |
+| `env` | The user's environment. Never mutated; child environments are copies. |
+| `home` | Optional; defaults to `HOME` (`USERPROFILE` on Windows). |
+| `run(command, argv, { env, deadlineMs })` | Argv arrays with `shell:false` semantics and a hard deadline; returns `{ code, signal, timedOut, stdout }` with bounded output. |
+| `fs` | Read-only `isFile`, `realpath`, `readText` (also used to read pnpm's npm shim). The runner has no delete or write operation. |
+| `verifyGentleAi({ packageRoot, platform, env, home })` | Returns `{ ok: true }` only for package-native integrity. `packageNativeGentleAi` is the default implementation over `runtime/gentle-ai-binary.mjs`. |
+| `log({ step, status, reason? })` | Receives step identifiers and statuses only, never raw command output or error text. |
+
+### Fixed sequence
+
+No-process gates, all returning `blocked`:
+
+1. Valid request and explicit consent.
+2. Preflight blockers are absent. Two plans are supported. The clean-stack
+ plan must contain `install-pi`, `install-shell`, `setup-shell` and
+ `verify-readiness`, plus optional `setup-global-bin` and at most one exact
+ persistence variant: `persist-node` + `persist-package-managers` +
+ `configure-npm-prefix`, or `persist-package-managers` alone, or
+ `persist-npm` alone, or `persist-pnpm` alone. The
+ [setup recovery](#setup-recovery) plan is exactly `setup-shell` and
+ `verify-readiness`, plus optional `setup-global-bin`, and nothing else.
+ Prerequisite acquisition intents (Node, pnpm, Go), `provision-native`, any
+ other persistence combination, other partial existing stacks and fully
+ reused stacks are `unsupported-plan`.
+3. On Windows, Go must be `reusable` in a clean-stack plan because gentle-pi's
+ postinstall may build Gentle AI from source; the runner never acquires Go
+ (`go-required`). A recovery runs no postinstall, so it needs no Go.
+4. `pnpmGlobalBin` resolves PNPM_HOME (`pnpm-home-unknown` otherwise) and
+ `nodePath` is absolute.
+5. pnpm comes from the bootstrap handoff `GENTLE_INSTALL_PNPM_NODE` +
+ `GENTLE_INSTALL_PNPM_ENTRY` (both absolute), or on POSIX from a `pnpm`
+ executable on PATH. Windows requires the handoff because a `.cmd` shim cannot
+ run with `shell:false` (`pnpm-unavailable`).
+
+Every child process receives the user's environment plus `PNPM_HOME` and
+`$PNPM_HOME/bin` first on PATH.
+
+Pre-install checks, returning `blocked` on a false result or adapter error:
+
+1. `check-npm`: Gentle AI's normal Pi install always runs an Engram init step
+ that can use `npm exec` and stops on failure. The runner resolves `npm` the
+ way Go's `exec.LookPath` (used by Gentle AI) does: absolute child PATH
+ directories in order and, on Windows, every child PATHEXT extension in
+ PATHEXT order (case-insensitive keys; default `.COM;.EXE;.BAT;.CMD`). That
+ first candidate must be the npm package's own `bin/npm-cli.js` (symlink
+ target on POSIX; on Windows the candidate must be `npm.cmd`, with
+ `node_modules/npm` beside it), its `package.json` must name `npm` with a
+ stable version, and `node npm-cli.js --version` must print that version.
+ A candidate in `$PNPM_HOME/bin` must instead be pnpm's global npm: a POSIX
+ symlink, or a shim whose single quoted `npm-cli.js` target (`$basedir/...`
+ on POSIX, `%~dp0\...` or `%dp0%\...` on Windows, or absolute), must
+ resolve (realpath) to `node_modules/npm/bin/npm-cli.js` inside PNPM_HOME,
+ and the package must be exactly `npm@11.19.0`. Two different targets, a
+ target outside PNPM_HOME or another version are rejected. When the plan
+ adds npm, this check runs after the add and fails as a mutating step. On
+ Windows an `npm.com`, `npm.exe` or `npm.bat` that resolves first, in the
+ same or an earlier directory, blocks as `npm-shadowed`; any other mismatch
+ blocks as `npm-unavailable`. No npm wrapper is created.
+2. `check-global-bin`: `pnpm bin -g` must succeed and equal `$PNPM_HOME/bin`
+ (`global-bin-mismatch`; case-insensitive on Windows).
+3. `check-existing-stack`: `pnpm list -g --depth 0 --json` runs before any
+ mutation. If `@earendil-works/pi-coding-agent` or `gentle-pi` appears in the
+ dependencies, devDependencies or optionalDependencies of any listed project,
+ the runner blocks as `existing-stack` and never overwrites it, whatever the
+ caller's plan says. A failed, timed-out or unparseable listing, or an
+ unexpected JSON shape, blocks as `global-list-unavailable`. A recovery runs
+ `check-recoverable-stack` instead (see [Setup recovery](#setup-recovery)).
+
+Mutating and verification steps, returning `failed` with `failedStep` and the
+`completed` step list. When the plan contains a persistence variant, its
+[runtime persistence](#runtime-persistence) steps run first, after
+`check-global-bin` and `check-existing-stack`:
+
+1. `install-global`: exactly one
+ `pnpm add -g @earendil-works/pi-coding-agent@1.0.0 gentle-pi@ --allow-build=gentle-pi`.
+ Pi is gentle-pi's optional peer, so both resolve in one command. pnpm 11
+ silently skips global postinstall scripts by default; the package-scoped
+ approval runs only gentle-pi's postinstall, which provisions Gentle AI. No
+ blanket build approval is ever passed.
+2. `verify-global-list`: `pnpm list -g --depth 0 --json` must report exactly the
+ two pinned versions in the single listed project that owns gentle-pi
+ (other projects, such as the persisted npm and pnpm, are ignored), and
+ gentle-pi's absolute `path` must resolve inside PNPM_HOME.
+3. `verify-shell-bin`: `$PNPM_HOME/bin/gentle-shell` (`gentle-shell.cmd` on
+ Windows) exists.
+4. `verify-gentle-ai`: package-native integrity of the installed package. A
+ declared development override (`GENTLE_PI_GENTLE_AI_DEV_BINARY` or its
+ registration file) is never package-native proof and fails.
+5. `shell-setup`: the installed public CLI, `node /bin/gentle-shell.mjs
+ setup`, with the child environment minus every `GENTLE_BOOTSTRAP_*` and
+ `GENTLE_INSTALL_*` key (case-insensitive on Windows); pnpm commands keep the
+ handoff. Nonzero, signal or deadline fails. The runner neither duplicates
+ setup logic nor writes the automatic provisioning marker. It requests the
+ last 4 KiB of setup's stderr, where `gentle-shell setup` reports errors, and
+ a failure carries `detail`: the last error line (containing `Error:` or a
+ pnpm `ERR_` code), else the last non-empty line, without terminal escapes,
+ control or bidi characters, with the user's home replaced by `~` and at most
+ 300 characters (`setupErrorDetail`). The raw output is not kept; only this
+ step and `persist-path` have a detail.
+6. `persist-path`, only when `$PNPM_HOME/bin` was not on the user's own PATH:
+ `pnpm setup`, then the outcome is `terminal-action-required` with
+ `action: "open-new-terminal"`. A child environment never proves that a fresh
+ terminal resolves `gentle-shell`. On POSIX, `pnpm setup` picks the profile
+ to edit from the inherited `SHELL` variable (bash, zsh, fish, ksh, dash, sh
+ or nushell); without it pnpm 11.1.1 fails with `ERR_PNPM_UNKNOWN_SHELL`,
+ with another shell `ERR_PNPM_UNSUPPORTED_SHELL`. A terminal session always
+ exports `SHELL`; a launcher that does not (for example `docker exec`, some
+ service managers or IDE tasks) makes this step fail. pnpm prints these
+ errors on stdout, so a failure takes `detail` from the same 4-KiB stderr
+ tail or, when that has no line, from stdout. Before checking the shell, the
+ observed pnpm 11.1.1 also installs the latest `@pnpm/exe` globally ("Installing
+ pnpm CLI globally"), which is upstream behavior the runner does not control.
+
+`ready` requires every step above to complete and the global bin to already be
+on the user's PATH. Skipped, unverified or failed mandatory steps never yield
+`ready`, and no existing installation, home or data is deleted on any outcome.
+Deadlines are 30 seconds for probes and npm config commands, and 20 minutes
+each for the install, persistence and setup steps, including `pnpm setup`,
+which installs `@pnpm/exe` from the registry.
+
+### Setup recovery
+
+`gentle-shell setup` runs after `add -g`, so a setup that stops (for example on
+the [GitHub API limit](#github-api-limit-during-setup)) or a failed `pnpm setup`
+leaves Pi and gentle-pi installed. Rerunning the installer then completes that
+installation instead of blocking as an existing stack.
+
+Detection is the setup probe, with the same rule the runner applies
+(`recoverableStackRoot`): the global listing names Pi and gentle-pi exactly
+once each, both in the single project that owns gentle-pi, Pi at exactly
+1.0.0 and gentle-pi at exactly this package's version, and gentle-pi's path
+resolves (realpath) inside PNPM_HOME. Preflight also requires Pi, Shell and
+Gentle AI to be `reusable`. Anything else (another version, a second listing,
+a path outside PNPM_HOME, Pi missing) keeps the setup probe unknown, so a
+foreign or other-version stack stays blocked exactly as before.
+
+The recovery plan is all or nothing: `setup-shell`, `verify-readiness` and
+`setup-global-bin` only when the global bin is off PATH. The runner then runs
+`check-npm`, `check-global-bin` and `check-recoverable-stack`, which lists the
+global packages again and blocks as `existing-stack-unverified` when the stack
+no longer matches (or as `global-list-unavailable` when pnpm cannot list).
+It skips `install-global` (never `add -g`) and continues with the unchanged
+`verify-global-list`, `verify-shell-bin`, `verify-gentle-ai`, `shell-setup`
+and, when planned, `persist-path`, with the same outcomes. A recovery never
+persists the runtime: the earlier run persisted it before installing the
+stack, and `check-npm` still proves a genuine npm in the child environment.
+
+Rerunning `gentle-shell setup` is acceptable because it is the public,
+rerunnable command the clean installation already runs; the runner adds no
+setup logic of its own. The review screen says "Gentle Shell is already
+installed; this completes setup" and offers **Complete setup**.
+
+### Runtime persistence
+
+A bootstrap-acquired Node lives in a temporary tools directory, so a later
+terminal would not find `node` or the genuine npm that Gentle AI's Engram step
+needs. Every persistence step uses the same pnpm argv prefix and child
+environment as the stack install, and the runner persists only what is missing.
+
+For a bootstrap-only Node, the full group runs these fixed steps:
+
+1. `persist-node`: `pnpm runtime set node 24.21.0 -g`, which installs Node under
+ PNPM_HOME and links `node` into `$PNPM_HOME/bin`.
+2. `persist-package-managers`: `pnpm add -g npm@11.19.0 pnpm@11.1.1`, which
+ adds the `npm`, `npx` and `pnpm` shims to `$PNPM_HOME/bin`. No build
+ approval of any kind is passed.
+3. `verify-persistent-runtime`: in the child environment both `node` and `npm`
+ must resolve (`exec.LookPath` order) from `$PNPM_HOME/bin`, the node must be
+ spawnable (`.exe`/`.com` on Windows) and print `v24.21.0`.
+4. `check-npm`: the genuine-npm gate above, now accepting the pinned shim.
+5. `configure-npm-prefix`: with a pnpm-managed node, npm's default prefix is
+ derived from a path inside pnpm's store, so `npm install -g` would write
+ there. The runner reads `npm config get prefix` with the persisted node.
+ Unless an `npm_config_prefix` (any case) or `PREFIX` variable is set, it
+ asks pnpm for its store with `pnpm store path` (30-second deadline); a
+ failed, non-absolute or unresolvable answer fails this step rather than
+ assuming `$PNPM_HOME/store`. Only when the effective prefix resolves inside
+ that store does it run
+ `npm config set prefix --location=user`, then require the
+ effective prefix to equal PNPM_HOME. An explicit user prefix elsewhere
+ makes npm report that prefix, so it is never overwritten. A failed read,
+ write or verification fails this step with the completed list.
+
+The outcome then also reports `npmPrefix: "configured"` or `"unchanged"`.
+
+A persistent Node is never replaced or shadowed: there is no `runtime set` and
+no prefix change. One fixed add runs, then only the matching checks:
+
+| Intent | Command | Then |
+| --- | --- | --- |
+| `persist-npm` | `pnpm add -g npm@11.19.0` | `check-npm` |
+| `persist-pnpm` | `pnpm add -g pnpm@11.1.1` | `verify-persistent-pnpm` |
+| `persist-package-managers` | `pnpm add -g npm@11.19.0 pnpm@11.1.1` | `check-npm`, `verify-persistent-pnpm` |
+
+`check-npm` is the genuine-npm gate above. `verify-persistent-pnpm` requires
+the first `pnpm` in the child environment to be in `$PNPM_HOME/bin` (`.cmd`
+on Windows), resolving like the npm shim to `node_modules/pnpm/bin/pnpm.mjs`
+inside PNPM_HOME, with `package.json` `pnpm@11.1.1`, and `node pnpm.mjs
+--version` printing `11.1.1`. When pnpm is not added, `check-npm` stays a
+pre-install gate.
+`npm config get` ignores `--location`, and `.npmrc` files can hold credentials,
+so the runner never reads them: a user-level prefix that itself points into
+the store is indistinguishable from the default and would be replaced.
+
+### Remaining T7 real-machine checks
+
+The runner is verified only with deterministic fake adapters. Observed pnpm
+11.1.1 facts come from one Linux laboratory run. Before claiming support, T7
+must establish on real Windows, macOS and Linux machines:
+
+- `pnpm list -g --json` reports a dependency `path` that resolves inside
+ PNPM_HOME for the installed layout, and prints a parseable JSON array (for
+ example `[]`) when no global package exists yet; empty output would block
+ a clean installation as `global-list-unavailable`;
+- Windows npm resolution matches Gentle AI's actual lookup, including its
+ current-directory handling;
+- the scoped `--allow-build=gentle-pi` postinstall provisions package-native
+ Gentle AI, including Windows source builds with the user's Go;
+- `gentle-shell setup` completes the Engram init step with genuine npm, and
+ whether Gentle AI selects `pnpm dlx` or `npm exec`;
+- `pnpm setup` makes `gentle-shell` resolvable in a fresh terminal of each
+ supported shell (the Linux lab showed it does not make `pnpm` itself
+ resolvable);
+- the chosen deadlines suffice on slow networks, and pnpm's registry and
+ update-notice traffic is acceptable;
+- interrupted installs are recoverable by rerunning the wizard without data loss;
+- `pnpm runtime set node -g` on Windows links a spawnable `node.exe` (a `.cmd`
+ shim fails `verify-persistent-runtime`), and pnpm's real `npm.cmd`/POSIX
+ shim text matches the accepted target forms;
+- npm's Windows global layout: it places executables in the prefix itself, so
+ a PNPM_HOME prefix may not put `npm install -g` executables on PATH; also
+ whether Windows npm's default prefix lands in the store at all;
+- `pnpm store path` reports a directory (for example `$PNPM_HOME/store/v11`)
+ that actually contains the effective npm prefix of a pnpm-managed node;
+- pnpm's real global `pnpm` shim targets `node_modules/pnpm/bin/pnpm.mjs`, and
+ a persistent Node missing npm has a prefix outside the store (the variant
+ without `runtime set` configures no prefix);
+- download integrity of the Node runtime and the npm and pnpm tarballs fetched
+ during persistence (the Linux lab saw a lockfile hash matching the Node pin,
+ not download-time verification);
+- pnpm's update notifier contacting registry.npmjs.org during these commands;
+- `pnpm list -g --json` listing persisted npm/pnpm and the stack as separate
+ projects with a usable gentle-pi `path`;
+- the probes' `access(W_OK)` writability check, which ignores Windows ACLs, and
+ process deadlines that kill only the direct child.
+
+## Local wizard host and entry
+
+`bin/gentle-shell-install.mjs` is the entry the bootstrap's `launchWizard`
+starts with no argv and inherited stdio. It is not a `package.json` `bin`
+command; it ships inside the package's `bin/` directory and is listed in
+`scripts/verify-package-files.mjs` with the other installer files
+(`installerPaths`).
+
+### Entry wiring
+
+- Probes: `createProbes` over the wizard's own environment with
+ `hostAdapters()` (argv spawn with `shell:false`, deadlines, bounded output,
+ read-only fs). Each plan request creates fresh probes, then
+ `collectInventory` and `planPreflight` run server-side.
+- Runner: `runStandardInstall` with `nodePath: process.execPath`, the same
+ adapters, `packageNativeGentleAi` and `runnerEnvironment`: the user's real
+ PATH from `userEnvironment`, so a temporary bootstrap npm or node never counts
+ as persistent and `onPath` stays truthful. A POSIX bootstrap pnpm, which the
+ bootstrap passes only through PATH, stays reachable as the **last** PATH
+ entry (its directory holds only the `pnpm` wrapper); Windows uses the
+ `GENTLE_INSTALL_PNPM_*` handoff, which `userEnvironment` keeps.
+- The entry prints the session URL, then opens it through a private redirect
+ file so the one-time code never appears in the opener's argv (visible to
+ local process listings). `writeRedirect` creates a fresh `mkdtemp` directory
+ `gentle-shell-install-*` under the OS temp dir (mode 0700) holding
+ `open.html` (mode 0600, exclusive create): a static page with a meta refresh
+ and a link to the session URL, no script. `openBrowser` passes only that file
+ path to a fixed opener spawned with `shell:false` and detached:
+ `/usr/bin/open` on macOS, `xdg-open` on Linux, `%SystemRoot%\System32\rundll32.exe
+ url.dll,FileProtocolHandler` on Windows (absolute, so the current directory is
+ never searched). Without an opener nothing is written; a failed write opens
+ nothing; an opener that fails to start removes the file. The file and its
+ directory are removed (file, then directory, best effort) once the code is
+ redeemed (`onRedeemed`) or when the host closes. File modes are POSIX only;
+ on Windows the per-user `%TEMP%` ACLs apply. The printed URL stays the
+ fallback.
+
+| Exit code | When |
+| --- | --- |
+| 0 | The last installation outcome was `ready` or `terminal-action-required`. Node, npm and pnpm now persist under `$PNPM_HOME` or were already the user's, so the bootstrap removes the tools directory it created. |
+| 1 | `blocked`, `failed`, a shutdown or idle close without a completed installation, a signal, or a host start failure. The bootstrap removes its tools. |
+
+SIGINT and SIGTERM close the server and exit with the code above. An
+installation that is still running is not cancelled cleanly: Ctrl+C reaches the
+runner's children through the terminal's process group, but SIGTERM does not.
+
+Known debt, not fixed yet: on SIGINT or SIGTERM, `close` does not wait for a
+running installation to settle, and runner deadlines kill only the direct child
+process, not the processes it started (for example, the ones `pnpm` or
+`gentle-shell setup` spawn).
+
+### Security model
+
+`scripts/installer-server.mjs` exports `createInstallerServer({ collectPlan,
+runInstall, assetsDir, now?, random?, limits?, onRedeemed? })`, a
+dependency-free `node:http` host. Every effect is injected; tests use fake
+plans and runners. `onRedeemed()` runs once when the one-time code is used; an
+exception from it is ignored.
+
+- Binds `127.0.0.1` on port 0 only and asserts the bound IPv4 address; never
+ `localhost`, `::` or `0.0.0.0`.
+- Every request needs `Host` exactly `127.0.0.1:`, otherwise 421 before
+ any authorization (a missing Host included).
+- Every response carries `Content-Security-Policy: default-src 'self';
+ frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
+ `X-Content-Type-Options: nosniff`, `Cache-Control: no-store`,
+ `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`,
+ `Cross-Origin-Resource-Policy: same-origin` and
+ `Cross-Origin-Opener-Policy: same-origin`. No CORS header is ever sent; error
+ bodies are generic codes such as `{ "error": "forbidden" }`.
+- Session: a 32-byte random secret becomes the cookie
+ `gentle_install_session` (`HttpOnly; SameSite=Strict; Path=/`). The printed
+ URL `http://127.0.0.1:/session?code=` carries a separate random
+ one-time code that expires after 2 minutes; a valid `GET /session` consumes it
+ and answers 200 with a minimal HTML page that sets the cookie and refreshes
+ to `/` (` `, no script or
+ style, same strict headers). It is not a 303: reached from the `file:`
+ redirect page, a redirect chain counts as cross-site and the
+ `SameSite=Strict` cookie could be withheld on `/`, while a navigation started
+ by this same-origin page carries it. Code and cookie comparisons use
+ `timingSafeEqual`. Every asset and `/api/*` request needs the cookie (401).
+- A request with an `Origin` other than `http://127.0.0.1:` is rejected
+ (403), including GET. Every `/api/*` request, GET included, needs the header
+ `X-Gentle-Install: 1` (403 otherwise, before any probe or plan rotation), so
+ a cross-origin fetch always needs a CORS preflight, which never succeeds.
+ POST also requires that exact Origin and `Content-Type: application/json`
+ (415 otherwise).
+- Bodies are limited to 1 KiB (413, by declared length and while streaming).
+ Server `headersTimeout` is 10 s and `requestTimeout` 30 s.
+- The host closes after 30 minutes without activity while no installation
+ runs (`limits.idleMs`, checked every 15 s with the injected clock). Only a
+ request with the right Host and a valid session cookie, a successful code
+ redemption or a finished installation counts as activity; wrong-Host,
+ unauthenticated or bad-code traffic never extends the timeout.
+
+### Endpoints
+
+Fixed allowlist, matched on the raw request path: anything else is 404, a
+known path with another method is 405. Only `/session` and `/api/progress`
+take a query string. Assets map to fixed file names in `assetsDir`; no request
+path reaches the filesystem, so traversal forms (`/../`, `%2e%2e`, `%2f`) are
+simply unknown paths.
+
+| Route | Contract |
+| --- | --- |
+| `GET /session?code=` | Consumes the one-time code, sets the cookie and returns 200 `text/html` that refreshes to `/`; 401 for a missing, wrong, used or expired code. |
+| `GET /`, `/wizard.js`, `/wizard.css` | `index.html`, `wizard.js`, `wizard.css` from `assetsDir`; 404 when absent. |
+| `GET /api/plan` | Runs `collectPlan()` server-side, stores the plan under a new opaque `planId` and returns a view model: `actions` (`id` plus a fixed English description), `blockers` (`code`, `tool` — a preflight tool key such as `node` or `gentleAi`, otherwise `unknown` — and guidance), `profileChange` (whether `pnpm setup` edits the shell profile or Windows user PATH, and the bin directory), `persistence` (which of node, npm and pnpm go under `$PNPM_HOME`) and `ready`. 409 while installing. Accepts no input. |
+| `POST /api/install` | Body exactly `{ "planId": string, "consent": true }` (400 otherwise, including extra keys or a plan). 409 `install-running` while an installation runs; 409 `already-completed` once an installation has a final outcome (one installation per wizard run; the outcome is never replaced); 409 `plan-changed` for a stale `planId` or when a fresh re-inventory differs from the stored plan. Otherwise 202, and the runner receives the server-stored plan with `consent: true`. |
+| `GET /api/progress?after=` | Entries `{ seq, step, status, reason }` after `seq` from a ring buffer of the last 200; values outside `[a-z][a-z0-9-]*` become `unknown`, and no other runner field is kept. Also `running` and the final `outcome` with fixed guidance. |
+| `POST /api/shutdown` | Body empty or `{}`. Closes the host (409 while installing). |
+
+`guidance` (exported) holds fixed English text for every runner blocked
+reason, every failed step, every preflight blocker code and both successful
+outcomes, plus a generic fallback; a runner exception becomes a `failed`
+outcome with the fallback. A failed `shell-setup` or `persist-path` outcome also passes the
+runner's `detail` through, bounded again (string only, control and bidi
+characters removed, at most 300 characters); the host drops it for any other
+step. When that detail names GitHub's rate limit (`rate limit`, or `GitHub API`
+together with `403`), the guidance becomes `guidance.setupRateLimit`: GitHub's
+anonymous API limit was reached on this network, wait up to an hour and run the
+installer again. No token or credential is ever requested. For `persist-path`,
+a detail with `ERR_PNPM_UNKNOWN_SHELL` or `ERR_PNPM_UNSUPPORTED_SHELL` selects
+`guidance.persistPathShell`: open a regular terminal and run the installer
+again, or add `$PNPM_HOME/bin` to PATH manually. `scripts/installer-runner.mjs` exports the frozen
+arrays `blockedReasons` and `failedSteps`; a test requires guidance for exactly
+those entries, and the runner tests check that every reason and step their
+scenarios observe is listed.
+
+### Wizard UI
+
+`assets/install-wizard/index.html`, `wizard.js` (an ES module) and
+`wizard.css` are the whole browser side. They load only from the host itself
+and follow its CSP: no inline script or style, no external fonts, images or
+requests, and server strings are inserted as text nodes only (never parsed as
+HTML).
+
+| Screen | What the user sees |
+| --- | --- |
+| Check | Shown while `GET /api/progress?after=0` and `GET /api/plan` run. A running installation resumes the Install screen; a finished one shows its outcome instead of a new plan. |
+| Review | Each fixed action with its description, the `profileChange` and `persistence` disclosures (state as text: "Will change", "No change"), one consent checkbox and **Install Gentle Shell** (for a setup recovery, "Finish setting up Gentle Shell" and **Complete setup**). Without the checkbox no request is sent; the error is announced and focus moves to the checkbox. Preflight blockers replace the plan with their guidance, **Check again** and **Close installer**. |
+| Install | Expected runner steps (`expectedSteps`) with a text status (Done, In progress, Pending, Failed, Blocked, Not run), a `` bar and a terminal-style log of `{ seq, step, status, reason }` entries. |
+| Done | `ready`: run `gentle-shell`. `terminal-action-required`: open a new terminal, then run `gentle-shell`. `blocked`/`failed`: the host's guidance, the reason or failed step, the steps that finished and the log. A failed `shell-setup` or `persist-path` with a detail also shows it under **Last error from gentle-shell setup** or **Last error from pnpm setup** as one plain text node (backticks stay literal). Every outcome offers **Close installer** (`POST /api/shutdown`). |
+
+Behavior worth knowing:
+
+- `409 plan-changed` reloads the plan, clears consent and explains why;
+ `409 install-running` and `already-completed` resume the current state; 401
+ or 403 ends the session with instructions to run the installer again. When
+ the install request itself fails on the network, the wizard checks
+ `/api/progress` once: a running or finished installation is followed
+ instead of an error, and the install request is never re-sent
+ automatically.
+- Progress polling starts at 500 ms, backs off by 1.5× to 4 s while nothing
+ changes, resets when entries arrive and stops on the final outcome. Five
+ consecutive errors, or three answers that are neither running nor finished,
+ stop polling and offer **Try again**. The client keeps the last 200 log
+ entries, like the host.
+- Accessibility: landmarks, one `h1` per screen that receives focus on each
+ step change (it is not interactive, so it shows no focus ring), a polite
+ `role="status"` region for progress and the closed state, an assertive
+ `role="alert"` region for errors, labelled controls, native buttons and
+ checkbox (keyboard operable), visible `:focus-visible` rings on every
+ control, text labels for every color state, `prefers-reduced-motion` and
+ `forced-colors` support.
+- Visual language: the gentlemanprogramming.com dark palette (accent
+ `#f095c8` on `#1a1218`) and its static hero glow behind the panel, with
+ local font stacks only and a text wordmark; no logos, brand images or
+ favicon are copied.
+
+`wizard.js` exports its pure models (`planModel`, `progressModel`,
+`outcomeModel`, `expectedSteps`, `installBody`, `interpretInstall`,
+`nextPoll`), its renderers and `createWizard({ document, fetch, setTimeout,
+clearTimeout, clipboard? })`; it starts itself only inside a page.
+`tests/install-wizard.test.ts` drives the controller with a fake DOM (where
+`innerHTML` throws), fake fetch and fake timers, and serves the real assets
+through the real host.
+
+### Development preview
+
+`scripts/install-wizard-preview.mjs` starts the **real** host with the real
+assets but a fake `collectPlan` and a fake runner, so it never probes the
+machine, runs a package manager, downloads or installs anything:
+
+```sh
+node scripts/install-wizard-preview.mjs --scenario=terminal --step-ms=600
+```
+
+Scenarios: `ready`, `terminal` (`terminal-action-required` with runtime
+persistence), `blocked` (`existing-stack`), `failed` (`install-global`),
+`plan-changed` (the first plan goes stale, so the host answers 409),
+`recovery` (an installed stack whose setup did not finish; ends in
+`terminal-action-required`) and `preflight` (blockers, no plan). It prints the one-time session URL; Ctrl+C
+or **Close installer** stops it. The preview is a development tool: it is not
+listed in `installerPaths` (the entry never imports it), although it ships
+with the rest of `scripts/` in the package.
+
+### Remaining T6/T7 checks
+
+- T6 (done): `wizard.js` and `wizard.css` are in `installerPaths`; the preview
+ scenarios ran end to end in headless Chromium 1148 at 1440 and 390 px wide,
+ including a keyboard-only consent and install, with no CSP violations.
+ Screen readers and other browsers were not tested.
+- T7: the opener on each desktop (including a missing `xdg-open`); that the
+ redirect file opens in the browser rather than another `.html` handler, and
+ that sandboxed browsers with a private `/tmp` (for example snap or flatpak
+ packages on Linux) can read it; that a meta refresh from a `file:` page
+ reaches the session and the session page's same-origin refresh to `/`
+ carries the `SameSite=Strict` cookie in each browser; browsers honouring
+ `SameSite=Strict` and the cookie on `127.0.0.1`; DNS-rebinding and
+ cross-site request rejection in real browsers; the bootstrap's handling of
+ exit 1 end to end, and exit 0 outside Linux; SIGTERM during a running
+ installation; Windows `rundll32` behavior; real probe and runner execution
+ through the host outside Linux.
+
+## POSIX bootstrap: bundle-local tooling only
+
+Run `sh scripts/bootstrap.sh` from a trusted extracted installation bundle or
+checkout. There is **no published bundle URL or remote-pipe installer contract**.
+The bundle must include `package.json`, both bootstrap modules and the wizard
+entry `bin/gentle-shell-install.mjs`. When that entry is missing, the script
+reports it before downloads or home writes. T7 owns packaging and distribution
+proof.
+
+With that entry available, the fixed sequence is:
+
+1. Probe existing Node against the bundle's repository requirement. Unknown,
+ prerelease or incompatible versions block; they are never replaced.
+2. If missing, select a fixed native Node archive, download with TLS and bounded
+ size/time, verify its hardcoded SHA256 using stock shell utilities, extract
+ only its regular `bin/node`, and check the exact executable version before
+ publishing it. Neither npm nor Corepack is acquired or invoked.
+3. Reuse pnpm only after stable version, package engine and read-only global
+ `add`/`bin` help-capability evidence. Engines support only simple `>=x.y` or
+ `>=x.y.z` lower bounds; comparison fills an omitted patch with zero. Actual
+ Node versions must remain exact stable versions; other ranges block rather
+ than guess.
+ Missing pnpm is acquired from a fixed registry tarball, SHA512-SRI verified,
+ checked for unsafe paths/links, extracted and probed before publication.
+4. Start the fixed bundle entry with the refreshed child environment. A mandatory
+ acquisition/probe/child failure is an error, never installation success.
+
+### Ownership and failure boundaries
+
+Acquisition uses a mode-0700, uniquely created
+`$HOME/.gentle-shell-bootstrap-tools.` directory, with an explicit
+`.bootstrap-owned` marker. This is **prerequisite tooling, not a product home**.
+HOME must be absolute, owned by the current user, not group/world writable and
+free of symlink ancestors. Staging and destinations reject conflicts and
+symlinks. Unrelated similarly named directories are not scanned, reused or
+removed. Failed attempts clean only their own private directories; successful
+acquisition removes its temporary archives/staging and retains verified tools
+while the wizard runs. After the wizard exits 0, the script removes the tools
+directory it claimed, and the Node helper removes the one it created when only
+pnpm was missing. Both removals require the exact
+`$HOME/.gentle-shell-bootstrap-tools.*` name, a real directory (not a symlink)
+and the `.bootstrap-owned` marker with its exact content; `rm -rf` and
+`rmSync` remove links inside without following them. When removal is refused or
+fails, the bootstrap still exits 0 and prints
+`Bootstrap: installation finished, but temporary tools could not be removed:
+` on stderr.
+A new attempt reuses tools only if already visible and proven on its PATH; it
+does not discover or garbage-collect previous private attempts.
+
+No profile, global PATH, existing executable installation, product agent home,
+Engram state or companion installation is changed. Added paths affect only the
+bootstrap and its child. T4 must implement standard global installation and the
+persistent **ordinary terminal** handoff; these private wrappers are not that
+handoff. Existing unverifiable pnpm wrappers/binaries block instead of being
+silently replaced. No sudo or security exclusions are requested.
+
+Stock utilities are prerequisites, not silently installed: POSIX sh, awk,
+dirname, uname, mkdir, mktemp, chmod, mv, rm, sleep, wc, id and ls; missing Node
+also needs curl, tar and sha256sum or shasum. Linux acquisition additionally
+requires getconf evidence of glibc >=2.28. Missing pnpm requires tar. Missing
+utilities are reported. Shell executable-version probes have a 10-second
+watchdog, hash/archive probes 30 seconds; curl has a 10-second connection and
+120-second total limit with a 100-MiB artifact cap. A subprocess-only `ulimit -f`
+adds a hard disk ceiling (at most 200 MiB depending on shell block units) for
+older curl implementations; failure to establish it blocks acquisition.
+The JavaScript transport rejects
+redirects, caps pnpm at 32 MiB and aborts after 60 seconds; process checks have
+15-second/1-MiB bounds. At a prerequisite-process deadline, the shell watchdog
+and Node process adapter send SIGKILL to their directly spawned child, rather
+than catchable SIGTERM. A killed probe is a failure even if it printed valid
+output before hanging. Shell watchdog cancellation also kills and waits for its
+owned sleeper. The interactive wizard child intentionally has no total runtime
+timeout. Raw downloader/process error text is not logged.
+
+### Artifact trust and shared helper API
+
+| Artifact | Acquisition pin and provenance |
+| --- | --- |
+| Node native darwin/linux x64/arm64 | 24.21.0; parent-verified SHA256 entries from [official SHASUMS256](https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt), applied to fixed [v24.21.0 archive URLs](https://nodejs.org/dist/v24.21.0/). |
+| pnpm JavaScript CLI | 11.1.1; parent-verified raw engine `>=22.13` (comparison minimum `22.13.0`), tarball and SHA512 SRI from [registry version metadata](https://registry.npmjs.org/pnpm/11.1.1); [fixed tarball](https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz). |
+
+POSIX/pnpm integrity values live in `installer-downloads.mjs`; Windows Node
+values live in `installer-windows-artifacts.json`. The shell copies
+only Node's acquisition pin/hashes because first acquisition cannot depend on
+Node. Tests cross-check shell selection against the shared descriptors. Changing
+these pins requires renewed primary-source integrity evidence and updating both
+Node representations together. There is no live latest resolution. The pnpm pin retains the exact raw upstream
+engine string for metadata identity checks; only version comparisons normalize
+its supported partial minimum. Product versions/minima remain repository-derived;
+the pnpm pin is not a compatibility
+minimum. Registry-hosted artifacts do not imply using the npm executable.
+
+`scripts/installer-downloads.mjs` exports `artifactFor(name, platform, arch)`
+(frozen allowlisted descriptors), `verifiedDownload(name, adapters, platform,
+arch)` (verified bytes), `compatibleEngine(range, version)`,
+`ensurePnpm({ tools, env, nodeVersion, adapters })` and
+`launchWizard({ bundle, env })`. Only known artifact names are accepted, not
+caller URLs/checksums/commands. Trusted local test adapters inject byte download,
+digest and process checks; they are not exposed through any browser interface.
+The caller owns a private `tools` directory. `ensurePnpm` returns `{ env,
+acquired }`, leaving the supplied environment unchanged. The Windows adapter reuses descriptors/integrity verification without changing
+POSIX process semantics, defaults or pins.
+
+### Evidence, not platform certification
+
+Native shell execution was exercised on this Linux host with disposable Unicode
+and whitespace paths, fake OS/download/hash/archive utilities and injected
+JavaScript acquisition/process adapters. No real network or host prerequisite
+installation was performed. Darwin selections are simulated, **not native
+macOS execution**. Native macOS, Linux clean-machine/loader and minimum macOS
+version acceptance remain T7 work. Linux acquisition is native glibc-only:
+musl/Alpine and emulation/Rosetta compatibility are not claimed. Node's
+[upstream build/platform requirements](https://github.com/nodejs/node/blob/v24.21.0/BUILDING.md)
+remain the platform reference; successful descriptor planning alone proves no
+OS/kernel/libc support.
+
+### Subprocess deadline evidence
+
+The regression fixtures exercise production process paths without shortened
+production timeouts or a mocked process adapter:
+
+| Path | Observed Linux fixture behavior |
+| --- | --- |
+| Shell acquired-Node version probe | A single Node process prints the expected version, ignores TERM and waits without busy-looping. The real 10-second watchdog kills/reaps it, rejects acquisition and removes owned tooling while preserving an unrelated HOME file. |
+| Node helper tar probe | Fake verified bytes feed `ensurePnpm`, but its default process adapter runs a real single-process TERM-ignoring tar stand-in. The real 15-second deadline kills/reaps it, rejects acquisition and removes owned staging while preserving an unrelated file. Ordinary nonzero tar exit also rejects and cleans staging. |
+
+Before correction, both paths exceeded their deadlines and needed the test
+harness's independent outer SIGKILL guard (13 seconds for shell, 18 for Node).
+The guard kills only each freshly created detached fixture group and also
+cleans residual fixture processes on exit. Production does **not** kill groups,
+match process names or use an external timeout utility. After correction, both
+probe PIDs are reaped before their parents return; neither outer guard fires.
+Ordinary success remains covered by the existing reuse/acquisition tests.
+
+These are **direct-child, non-forking probe** guarantees, not process-tree
+cancellation evidence. Unknown programs that fork descendants retaining stdio
+may keep pipes open; this unit does not claim bounded return or descendant
+cleanup for those programs. The fixtures prove neither native macOS behavior
+nor live artifact acquisition. Independent verification and native review remain
+separate parent-owned gates.
+
+## Windows foundation: fixed commands, no policy repair
+
+Run `scripts\bootstrap.cmd` from a trusted extracted bundle or checkout. Like
+POSIX, it stops before acquisition or home writes when the wizard entry
+`bin/gentle-shell-install.mjs` or any Windows-dependent helper/metadata file is
+missing. There is no published bundle URL or remote-pipe contract, and the
+Windows bootstrap end to end still lacks native acceptance evidence.
+
+| Step | Windows contract |
+| --- | --- |
+| Entry | Small CMD entry invokes fixed stock Windows PowerShell commands with no profile. Paths are environment data, not interpolated PowerShell source. Delayed CMD expansion is disabled. |
+| Storage | Claim a new random-named prerequisite directory below LOCALAPPDATA, never reuse an existing destination. Verify each path component's reparse attributes, owner and role-specific ACL rights. Protect the claimed directory's DACL for the invoking SID, SYSTEM and Administrators, and read it back. |
+| Node | Reuse a proven stable existing Node ≥24.3.0 and the repository minimum. Otherwise acquire only the fixed official Node 24.21.0 Windows x64/arm64 ZIP, with no redirects and bounded transport, verify SHA256 before opening the archive, validate the whole namespace and extract only regular `node.exe`. |
+| pnpm | Reuse only a fully recognized npm CMD shim with package identity, bin target, stable CLI version, compatible engine and global add/bin help evidence. Preserve its sibling-Node preference or prove its inherited cwd/PATH/PATHEXT Node selection. Never execute the shim via cmd.exe. Unknown wrappers block without replacement. |
+| Missing pnpm | Shared pnpm 11.1.1 URL/SRI and raw `>=22.13` engine identity are unchanged. Parse bounded gzip/USTAR bytes, reject unsupported extensions, links and unsafe Windows namespaces before no-clobber publication. Return a direct Node + JS-entry invocation; do not fabricate a wrapper. |
+| Handoff | Existing Node helper starts the fixed wizard entry `bin/gentle-shell-install.mjs`. Only child PATH is refreshed. No persistent PATH, global installation, product root or companion installation is created here. |
+
+Windows Node SHA256 provenance is the parent's fresh primary-source read of
+[24.21.0 SHASUMS256](https://nodejs.org/dist/v24.21.0/SHASUMS256.txt). A narrow
+JSON metadata file lets pre-Node PowerShell and the shared Node helper consume
+the same Windows pins without mixing the POSIX archive format or copying pnpm
+integrity into another runtime. Pin changes still require fresh primary evidence.
+
+### T4/T5 integration contract
+
+`scripts/installer-windows.mjs` exports:
+
+- `ensureWindowsPnpm({ tools, env, node?, adapters? })` → `{ acquired, env,
+ command, prefix }`. Invoke `[command, ...prefix, ...args]` with `shell:false`;
+ keep the returned environment. `tools` must be a private attempt-owned root.
+ Test adapters are trusted local code only, never a browser API.
+- `bootstrapWindows({ bundle, tools, env })` validates fixed bundle files and
+ requirements, acquires/reuses pnpm, then delegates to shared `launchWizard`.
+ CLI entry is `installer-downloads.mjs --bootstrap-windows BUNDLE TOOLS`.
+- T5 receives `GENTLE_INSTALL_PNPM_NODE` and `GENTLE_INSTALL_PNPM_ENTRY` as data
+ for that proven direct invocation. T4 must re-inventory before installation;
+ these values are not an arbitrary command API or persistent terminal repair.
+- Storage, namespace, wrapper, tar and bounded process functions are exported
+ for focused verification. Production storage verification requires native
+ Windows and fixed stock PowerShell ACL commands, never POSIX mode/UID evidence.
+
+Go preparation is deliberately **not implemented or invoked** in this unit.
+The standard installation runner blocks on Windows unless preflight reports a
+reusable Go ≥1.25.10, because gentle-pi's postinstall may build Gentle AI from
+source; it never acquires Go or treats an explicit override as native-package
+evidence.
+
+### Policy, bounds and evidence limits
+
+No unsigned `.ps1` file, script-file evaluation, execution-policy relaxation,
+file unblocking, certificate/TLS bypass, elevation, security exclusion or profile
+change is used. Restricted permits individual commands; it is not every Windows
+client's default. [Execution policies](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies)
+do not override [AppLocker CMD/BAT rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/script-rules-in-applocker)
+or [managed App Control constraints](https://learn.microsoft.com/en-us/powershell/scripting/security/app-control/how-app-control-works).
+ConstrainedLanguage and any download/execution denial stop with an error. There
+is no fallback intended to evade those controls; if CMD itself is denied, the
+OS owns the denial diagnostic before our entry can run.
+
+Node transport is capped at 100 MiB with a 60-second request/total elapsed check
+and 10-second blocking-read timeout. A blocking read can consume up to that last
+read timeout beyond the elapsed check. ZIP metadata caps expanded size at 512 MiB
+and the selected executable at 150 MiB. Namespace checks reject traversal,
+backslashes, links/reparse attributes, duplicate/case-aliased names, conflicting
+file/directory parents, devices, ADS and trailing dots/spaces. Shared pnpm
+transport remains 32 MiB/60 seconds; tar expansion is capped at 128 MiB.
+Unsupported archive extensions fail closed. **Actual pnpm 11.1.1 TGZ acceptance
+is unproved:** it requires an integrity-backed offline copy of the pinned
+artifact, not a synthetic USTAR fixture or an injected digest. No such evidence
+was supplied and no acquisition is authorized in this correction.
+
+The pre-Node probe drains both pipes with a combined 1-MiB cap and kills only
+its direct child at 10 seconds, with a bounded one-second final wait. Node helper
+probes use the shared 15-second/1-MiB-style bounds and an unignorable direct-child
+kill. Valid output followed by a hang is a failure. Production never kills
+process groups or unrelated processes. Forked descendants retaining stdio are
+outside the guarantee; the interactive wizard has no total deadline.
+
+### Target-versus-ancestor ACL boundary
+
+For untrusted SIDs, the fixed predicates allow ReadAndExecute plus Synchronize
+(`0x1200a9`), with one narrow directory-specific distinction:
+
+| Role | Mutation boundary |
+| --- | --- |
+| Actual file/executable or owned tooling root (depth 0) | No write, deletion, child-deletion, ACL or ownership rights. |
+| Immediate parent (depth 1) | Same strict protection, including no CreateDirectories/AppendData. A new tooling claim starts checking its parent at this depth. |
+| Distant **existing directory** (depth ≥2) | May additionally allow CreateDirectories/AppendData (`4`) for sibling-only creation. WriteData/CreateFiles, WriteExtendedAttributes, WriteAttributes, Delete, DeleteSubdirectoriesAndFiles, WriteDACL, TakeOwnership, generic/unknown rights still reject. |
+
+Directory CreateDirectories and file AppendData share an enum bit; a distant
+ancestor here must actually be an existing directory. This is not permission to
+append to an executable. Reparse checks, trusted ownership and every actual path
+component remain mandatory. An inheritable ACE is checked again where it becomes
+effective on the descendant; InheritOnly does not grant rights on its current
+object. Effective unsafe allow ACEs block even when deny ACEs might otherwise
+limit them: this is a conservative mutation boundary, not a general Windows
+ACL/access-check framework. Trusted SIDs and protected-DACL readback are unchanged.
+The portable fixture model checks parity with all three fixed production
+predicates; it does not execute Windows ACL APIs or claim prevalence on Windows.
+
+Failure cleanup targets only the directory claimed by the actual attempt;
+collisions and unrelated storage are not removed. Prerequisite tools remain
+available to children while the wizard runs. After the helper exits 0, a final
+stage removes the claimed root only when its full path is a direct child of
+`%LOCALAPPDATA%` named `.gentle-shell-bootstrap-tools.*`, neither it nor its
+`.bootstrap-owned` marker is a reparse point, and the marker holds the exact
+text. It uses `[IO.Directory]::Delete(path, true)`, which does not recurse
+through reparse points, instead of Windows PowerShell 5.1 `Remove-Item`. A
+refusal or failure prints the same notice with the path and still exits 0. ACL/ancestor checks are conservative and may
+reject managed/nonstandard layouts rather than relax security. They do not
+claim protection from a malicious process running as the same principal or an
+administrator, nor eliminate same-principal time-of-check/time-of-use races.
+
+### Claim reason codes and 8.3 short paths
+
+A rejected storage claim keeps its user-facing message and appends one fixed
+code naming the failed check, for example `... policy denied it. Reason: home-owner`.
+The code never contains a path, SID or exception text. Intentional rejections
+report their own code:
+
+| Code | Rejected check |
+|------|----------------|
+| `policy` | PowerShell is not in FullLanguage mode. |
+| `path-mismatch` | `%LOCALAPPDATA%` is not rooted, the target is UNC, or the target's parent is not exactly `%LOCALAPPDATA%`. |
+| `home-owner` | `%LOCALAPPDATA%` is not owned by the invoking SID. |
+| `ancestor-reparse` | An ancestor is not a directory or is a reparse point. |
+| `ancestor-owner` | An ancestor owner is not the invoking SID, SYSTEM, Administrators or TrustedInstaller. |
+| `acl-mask` | An untrusted effective allow ACE exceeds the depth's allowed rights mask. |
+| `collision` | The random destination already exists; it is never reused or changed. |
+| `protected-dacl` | The readback DACL is not protected from inheritance. |
+| `private-owner` | The readback owner is not the invoking SID. |
+| `private-ace` | The readback holds a deny ACE or a SID other than the invoking SID, SYSTEM or Administrators. |
+
+Any other exception, such as an unreadable ACL, a denied write or a failed
+cmdlet, reports `unexpected-` for the step that was running, so it can
+never pose as an intentional rejection:
+
+| Step | Work in progress |
+|------|------------------|
+| `unexpected-policy` | Language mode and identity lookup. |
+| `unexpected-path-mismatch` | Path normalization. |
+| `unexpected-home-owner` | Reading the `%LOCALAPPDATA%` owner. |
+| `unexpected-ancestor-walk` | Reading an ancestor's attributes or ACL. |
+| `unexpected-create` | Creating the new directory, including a destination that appeared after the collision check. |
+| `unexpected-private-acl` | Writing or reading back the private DACL. |
+| `unexpected-marker` | Writing the ownership marker. |
+
+The Node probe stage follows the same contract. Its message is unchanged and
+ends with `Reason: `. Walk codes are prefixed with the role of the path
+component that failed: `target` (`node.exe` itself, depth 0), `parent` (its
+directory, depth 1) or `ancestor` (depth 2 and above). The role is fixed text,
+never the path.
+
+| Code | Rejected check |
+|------|----------------|
+| `policy` | PowerShell is not in FullLanguage mode. |
+| `missing-target` | The `.node-target` record is absent, not a file, or empty. |
+| `unsafe-target` | The recorded Node target is a directory or a reparse point. |
+| `unsafe-path` | The recorded Node target is not rooted, or is UNC. |
+| `-reparse` | A walked component is a reparse point. |
+| `-owner` | A walked component's owner is not the invoking SID, SYSTEM, Administrators or TrustedInstaller. |
+| `-acl-mask` | An untrusted effective allow ACE on a walked component exceeds its depth's allowed rights mask. |
+| `no-start` | The process did not start. |
+| `deadline` | Node did not exit and close both pipes within 10 seconds. |
+| `output-limit` | Combined stdout and stderr exceeded 1 MiB. |
+| `exit-code` | Node exited with a nonzero code. |
+| `version-format` | `node --version` did not print exactly `vMAJOR.MINOR.PATCH`. |
+| `engine` | The version is below 24.3.0 or the repository `engines.node` floor. |
+| `acquired-version` | A Node acquired by this bootstrap is not exactly v24.21.0. |
+
+Any other exception reports `unexpected-`: `unexpected-policy`,
+`unexpected-target` (reading the recorded target), `unexpected-acl-walk`
+(reading a component's attributes or ACL), `unexpected-start` (for example, an
+application-control denial while starting the process), `unexpected-drain`
+(reading the pipes) or `unexpected-version` (reading the repository metadata).
+A child that cannot be confirmed terminated keeps its separate
+`direct-child termination could not be confirmed` message.
+
+The `.node-target` record holds a full path, which may contain non-ASCII text,
+for example a user profile such as `C:\Users\José` or the Unicode native
+fixture root. Windows PowerShell 5.1 writes `New-Item -Value` content as
+BOM-less UTF-8 but reads `Get-Content` without a BOM as the ANSI code page, so
+such a path came back corrupted and the probe failed with `unexpected-target`.
+The record is now created no-clobber with `[IO.File]::Open(..., CreateNew)` and
+explicit BOM-less UTF-8 bytes, and the probe and launch stages read it with
+`[IO.File]::ReadAllText(path, [Text.Encoding]::UTF8)`. The other bootstrap
+records (`.bootstrap-owned`, `.node-stem`) hold fixed ASCII text and keep their
+cmdlets.
+
+The Node helper (`installer-downloads.mjs --bootstrap-windows`) follows the same
+contract. Its failure line is unchanged and ends with `Reason: ()`
+for an intentional rejection, or `Reason: unexpected-` for any other
+error, such as a file-system, JSON or spawn error. A value without that fixed
+shape, including a failed module import, prints `Reason: unexpected-helper`.
+
+| Step | Phase |
+|------|-------|
+| `bundle` | Native platform, bundle files and repository Node/pnpm metadata. |
+| `tools-storage` | ACL walk of the claimed tools directory. |
+| `pnpm-discovery` | CMD-equivalent search of the current directory, `Path` and `PATHEXT` for `pnpm`. |
+| `wrapper-storage`, `wrapper` | ACL walk and exact npm cmd-shim proof of the found `pnpm.cmd`. |
+| `node-discovery` | The wrapper's sibling `node.exe`, or `node` on `Path`. |
+| `node-storage`, `entry-storage`, `metadata-storage` | ACL walk of the selected Node, the pnpm entry and its `package.json`. |
+| `package` | The pnpm `package.json` binary target. |
+| `cli-proof` | `node --version`, `pnpm --version` and the `add`/`bin` global capability. |
+| `tools-check`, `download`, `archive`, `publish` | Pinned pnpm acquisition when no `pnpm` was found. |
+| `launch` | The bundle wizard entry and its exit code. |
+
+Codes: `native-unavailable`, `bundle-missing`, `prerequisite`, `unsafe-path`,
+`acl-evidence`, the storage walk codes `policy` and
+`-`, `cwd-search`,
+`path-missing`, `pathext`, `path-entry`, `extensionless`, `wrapper-unknown`,
+`wrapper-unproven`, `wrapper-node`, `wrapper-target`, `package-target`,
+`pnpm-engine`, `pnpm-version`, `pnpm-capability`, `process-failed`,
+`interpreter`, `unsafe-tools`, `pnpm-conflict`, `archive`, `pnpm-pin`,
+`pnpm-entry`, `acquisition`, `wizard-missing`, `wizard-start` and
+`wizard-exit`. For example, `parent-owner (wrapper-storage)` means the
+directory holding `pnpm.cmd` has an untrusted owner. The helper's storage check
+prints `unsafe:` for a walk rejection and still accepts only exact `safe`;
+any other PowerShell exception exits nonzero and reports `unexpected-`.
+
+`.CPL` deserves a note. Windows PowerShell 5.1 appends `.CPL` to `PATHEXT` in
+its own environment, so its children see
+`.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL` even when CMD did
+not. The bootstrap always starts the helper from Windows PowerShell, and native
+CI showed the old discovery allowlist rejecting every Windows run with
+`pathext (pnpm-discovery)`. `.cpl` is now a known extension, but not an
+accepted candidate. Discovery keeps the CMD order (current directory, then each
+`Path` entry, then `PATHEXT` order within a directory). A `pnpm.cpl` found
+before the real `pnpm.cmd` stops with `wrapper-unknown (wrapper)`, and a
+`node.cpl` found before `node.exe` stops with `wrapper-node (node-discovery)`;
+neither is skipped or executed. Any other unknown, duplicate or empty
+`PATHEXT` extension still fails with `pathext`.
+
+The wizard inherits the same environment. The runner's `lookPath` (Go
+`exec.LookPath` order) and the host probes accept any `PATHEXT` extension and
+already refuse a `.cpl` result: npm reports `npm-shadowed`, and Node or Go
+resolving to a non-`.exe`/`.com` file reports unknown without running it.
+
+ACLs are read and written with .NET Framework APIs
+(`[IO.Directory]::GetAccessControl`, `[IO.File]::GetAccessControl`,
+`[IO.Directory]::SetAccessControl`) instead of `Get-Acl`/`Set-Acl`. Those
+cmdlets live in Microsoft.PowerShell.Security, which Windows PowerShell 5.1
+autoloads from `PSModulePath`. Started from a PowerShell 7 terminal (or the
+GitHub Actions `pwsh` step shell), the inherited `PSModulePath` lists
+PowerShell 7's Core-only copy first, and 5.1 fails with
+`CouldNotAutoloadMatchingModule`. The checks themselves are unchanged: the
+same owner, mask, protected-DACL, readback and reparse predicates in the same
+order. The bootstrap still uses cmdlets from Microsoft.PowerShell.Management
+(`Get-Item`, `Get-Content`, `New-Item`, `Remove-Item`, `Test-Path`,
+`Join-Path`) and Microsoft.PowerShell.Utility (`New-Object`, `ConvertFrom-Json`,
+`Get-FileHash`, `Add-Type`, `Select-Object`, `Start-Sleep`). The native fixtures
+observed both modules loading under the same `pwsh` parent, and a module loads
+as a unit. `Get-Command` is built into the engine.
+
+The path comparison is insensitive to 8.3 short names by construction: both
+sides derive from the same `%LOCALAPPDATA%` string and pass through
+`[IO.Path]::GetFullPath`, so a short `%LOCALAPPDATA%` or `%TEMP%` is never
+compared with a long canonical form. The host probe adapter's `realpath` is the
+native call and returns the long form, for example `C:\Users\runneradmin\...`
+for a `%TEMP%` exposed as `C:\Users\RUNNER~1\...`; tests compare it with
+`realpathSync.native`, not the JavaScript `realpathSync`, which keeps short
+names.
+
+An earlier CI run reported `home-owner` for every native claim. The actual
+cause was this module-load failure, reported as the step that was running;
+`unexpected-` now separates the two. The `home-owner` check requires
+`%LOCALAPPDATA%` to be owned by the invoking SID itself, not by a trusted
+group. A real `%LOCALAPPDATA%` created by the User Profile Service is owned by
+the user. Elevated members of Administrators on Windows Server may create new
+directories owned by BUILTIN\Administrators, depending on the default-owner
+policy. The native fixtures therefore set the invoking SID as owner of the
+fixture root and of every fixture directory used as `LOCALAPPDATA`, read the
+owner back, and fail loudly before any production stage runs. Production is
+unchanged: a real user whose `%LOCALAPPDATA%` is owned by Administrators fails
+closed with `home-owner` and no storage is claimed.
+
+### Implemented fixtures versus missing execution evidence
+
+The native gates now contain runnable assertions, not empty or always-skipped
+callbacks. They run on Windows without a pending-fixture override:
+
+- Actual production claim/check predicates: protected-root creation, collision
+ preservation, harmless distant sibling creation, dangerous-right refusal,
+ strict target/immediate-parent checks and junction rejection. Junction creation
+ alone can be capability-skipped after an actual permission/unsupported error.
+ DACL changes are limited to new disposable fixture-owned objects.
+- Actual fixed PowerShell ZIP namespace/publication stage: local ZIPs with the
+ fixed archive/member identity; valid publication and traversal, aliases,
+ symlink/reparse attributes, ADS, devices, trailing names and parent conflicts.
+ The valid member contains the available fixture Node's unchanged bytes and is
+ never executed. Collision tests preserve unrelated published storage.
+- Actual pre-Node process primitive: approved Node executes local fixture JS;
+ nonzero exit, both output limits, quiet/stdout/stderr/both-pipe hangs and valid
+ output followed by a hang are rejected. Production drain/deadline/validation/
+ cleanup lines are unchanged. An independent hard guard checks recorded process
+ creation ticks and only kills fresh fixture-owned handles. A guard firing or
+ finding a residual recorded child is a test failure, not successful deadline
+ evidence; fixture cleanup cannot mask a missing production reap.
+- Complete local sentinel composition exercises CMD continuation/quoting,
+ bundle checks, claim, existing-Node resolution/probe, real helper pnpm handoff
+ and late cleanup with spaces, Unicode and CMD metacharacters. Only the approved
+ available Node's unchanged bytes are cloned into the disposable fixture PATH,
+ excluding operator npm/Corepack/pnpm shims. A local JS pnpm fixture provides
+ read-only identity/help responses. The trusted fixture helper copy has a fixed,
+ fail-only fetch guard: accidental acquisition throws before any network call;
+ it never supplies bytes/digests or adds a production integrity-bypass switch.
+ No network/install occurs.
+
+The fixtures preserve the exact production command lines and insert only
+fixture-owned process observations. They compose primitives in a disposable
+`.cmd`, not a loaded/evaluated PowerShell script file. Native AppLocker and
+ConstrainedLanguage denials still apply, with no policy relaxation. This is
+**stage/composition evidence, not uninstrumented whole-entry certification**:
+transport/integrity gates and the real pinned artifacts require separate proof.
+The original missing-entry test also exercises the unchanged whole entry.
+
+All these native fixtures are **implemented but unrun on this Linux host**:
+Windows PowerShell and a Windows runner are unavailable. Portable descriptor,
+ACL-predicate model, tar, wrapper and process fixtures are not native Windows
+proof. Native OS/ACL/CMD/loader acceptance remains T7 evidence work; the actual
+pinned pnpm TGZ offline acceptance remains an explicitly unfinished evidence
+item. No live artifact was downloaded/executed, operator-home installation
+performed, operator/global ACL or policy changed, or Windows OS minimum/support
+claimed. No same-principal-adversary or forked-descendant guarantee is made.
+T7 must establish those facts before advertising clean-machine support.
+
+## Clean-machine acceptance (T7)
+
+### Linux clean container
+
+A disposable `debian:bookworm-slim` container without Node, npm, pnpm, Pi or
+Gentle Shell, driven as a non-root user, passed end to end:
+`sh scripts/bootstrap.sh` exited 0 and the wizard, driven over HTTP exactly as
+the page does, reached `terminal-action-required` with all 13 runner steps
+`done` in about 27 seconds. A fresh `bash -ic` resolved `node` v24.21.0, `npm`
+11.19.0 and `pnpm` 11.1.1 from `$PNPM_HOME/bin`, and `gentle-shell --version`
+reported 4.0.0 with Pi 1.0.0 and the isolated home `~/.gentle-shell/agent`.
+That run predates the removal of bootstrap tools after success.
+
+The first run of `scripts/test-installer-acceptance.sh --worktree` (with the
+tools removal, not yet committed) did **not** pass: the bootstrap acquired its
+tools and printed the session URL after 113 seconds, the plan had no blockers,
+and 11 runner steps finished `done` before `shell-setup` (`gentle-shell setup`)
+failed; the outcome was `failed`, the bootstrap exited 1 and removed its tools
+through the failure path, after 146 seconds in total. A separate diagnostic
+container proved the cause: `gentle-shell setup` failed with `Error: execute
+install pipeline: download engram binary: fetch latest engram version: GitHub
+API returned HTTP 403` because the anonymous GitHub API quota of this network
+was exhausted. After the quota reset, setup in the same container exited 0
+without installing any extra package. It was not caused by the tools removal,
+which only runs after the wizard exits. See
+[GitHub API limit during setup](#github-api-limit-during-setup).
+
+The second run (with the quota precheck: 43 anonymous requests left) got past
+that: `shell-setup` finished `done`, then the last step, `persist-path`
+(`pnpm setup`), failed. The installation part took about 34 seconds, so it was
+not its 30-second deadline alone. The outcome was `failed`, the bootstrap exited
+1 and removed its tools through the failure path; 147 seconds in total. Cause:
+the script started the bootstrap with `docker exec ... sh -c`, which exports no
+`SHELL`, while the passing lab run had `SHELL=/bin/bash`; `pnpm setup` then
+cannot pick a profile (see `persist-path` above). A real terminal exports
+`SHELL`, so the script now passes `SHELL=/bin/bash` to the bootstrap, and a
+failed `persist-path` now reports pnpm's error line and SHELL guidance.
+
+The third run, with `SHELL=/bin/bash`, **passed**: 23 anonymous GitHub
+requests left before it, session URL after 113 seconds, all 13 runner steps
+`done` including `shell-setup` and `persist-path`, outcome
+`terminal-action-required`, bootstrap exit 0; 142 seconds for bootstrap and
+wizard, 147 in total. A fresh `bash -ic` resolved `node` v24.21.0, `npm`
+11.19.0, `pnpm` 11.1.1 and `gentle-shell` from `~/.local/share/pnpm/bin`;
+`gentle-shell --version` reported 4.0.0, Pi 1.0.0 and the isolated home
+`~/.gentle-shell/agent`; no `~/.gentle-shell-bootstrap-tools.*` directory
+remained, so the success-path tools removal is proven in a clean container.
+`bash -lc` did not resolve `gentle-shell`, as documented below.
+
+To reproduce it, run `sh scripts/test-installer-acceptance.sh` from the
+repository. It refuses to start without a working Docker daemon and never runs
+the installer on the host or touches the host HOME. It starts a container
+(`--pull missing`), installs only `ca-certificates` and `curl`, creates the
+user `tester`, then queries `https://api.github.com/rate_limit` from inside the
+container (this endpoint does not consume quota) and stops before the
+bootstrap, printing the reset time, when fewer than 5 anonymous core requests
+remain. It then copies the bundle to `/opt/bundle` (`git archive HEAD`, or the
+tracked working-tree files including uncommitted changes with `--worktree`) and
+runs the bootstrap. A headless driver inside the container redeems
+`/session?code=` with a cookie jar, sends `X-Gentle-Install: 1` on every
+`/api/*` request and the same Origin plus `Content-Type: application/json` on
+every POST, then requests the plan, consents, polls progress and shuts the host
+down. It then checks, in a fresh `bash -ic`, that `node`, `npm`, `pnpm` and
+`gentle-shell --version` resolve and that no `~/.gentle-shell-bootstrap-tools.*`
+directory remains, and reports whether `bash -lc` resolves `gentle-shell`. The
+container is removed on every exit unless `GENTLE_ACCEPTANCE_KEEP=1` keeps a
+failed one for inspection; the pulled image stays cached. Network
+access happens only inside the container. The installation is bounded by
+`GENTLE_ACCEPTANCE_INSTALL_SECONDS` (default 1500). CI does not run it.
+
+### GitHub API limit during setup
+
+`gentle-shell setup` runs upstream Gentle AI's install pipeline, which looks up
+the latest Engram version through the GitHub REST API without authentication:
+2 anonymous requests per setup. GitHub allows 60 anonymous requests per hour
+per public IP address, so machines sharing one address (offices, NAT, VPNs, CI
+runners, repeated test runs) can exhaust it; setup then fails with
+`GitHub API returned HTTP 403` and the wizard shows the rate-limit guidance and
+that error line. Waiting for the hourly reset is enough; nothing has to be
+uninstalled first: rerunning the installer finds the installed stack and runs
+only its [setup recovery](#setup-recovery).
+
+A missing `git` only produces a warning during setup; it was not the cause.
+
+Upstream advisory for Gentle AI (not changed here): pin the Engram version or
+fall back to it when the lookup fails, accept an optional `GITHUB_TOKEN` for
+the lookup, and replace the misleading "installed via go install" message that
+accompanies this failure.
+
+### Shell profiles after `pnpm setup`
+
+`pnpm setup` writes `PNPM_HOME` and its PATH entry into the interactive shell's
+profile only: `~/.bashrc` for bash on Debian. Debian's `~/.bashrc` returns
+early for non-interactive shells, and login shells read `~/.profile`, so these
+do not find `gentle-shell`, `node`, `npm` or `pnpm`:
+
+- non-interactive login shells such as `bash -lc '...'`, and tools or IDE tasks
+ that start one;
+- `ssh host command`: bash reads `~/.bashrc` but stops at its non-interactive
+ guard, so the lines must sit above that guard (or the command must use an
+ absolute path);
+- cron jobs, which read neither file: set PATH in the crontab or use absolute
+ paths.
+
+For login shells, add the same lines to `~/.profile` manually (the wizard does
+not edit it), using the `PNPM_HOME` that `pnpm setup` wrote, for example:
+
+```sh
+export PNPM_HOME="$HOME/.local/share/pnpm"
+case ":$PATH:" in *":$PNPM_HOME/bin:"*) ;; *) export PATH="$PNPM_HOME/bin:$PATH" ;; esac
+```
+
+### Upstream `~/.pi/gentle-ai`
+
+Gentle AI's setup creates an empty `~/.pi/gentle-ai` directory outside the
+isolated Gentle Shell home `~/.gentle-shell/agent`. This is upstream behavior;
+the installer neither relies on nor removes it.
+
+### CI matrix
+
+The `installer` job in `.github/workflows/ci.yml` runs the installer suites with
+Node 24 on `ubuntu-latest`, `macos-latest` and `windows-latest`. The native
+Windows tests in `tests/installer-windows-bootstrap.test.ts` are gated on
+`process.platform === "win32"`, so they run on the Windows runner and skip
+elsewhere; the POSIX shell fixtures in `tests/installer-posix-bootstrap.test.ts`
+skip on Windows. CI only runs after a push, which remains a user decision.
+
+### Remaining T7 checks
+
+- real browsers and screen readers (only headless Chromium was used);
+- native macOS and Windows clean machines, including the Windows bootstrap
+ end to end and its tools removal;
+- `pnpm setup` and fresh-terminal resolution in zsh and fish;
+- SIGTERM during a running installation;
+- reinstalling over an existing stack;
+- download-time integrity of the Node runtime and the npm and pnpm packages
+ fetched during persistence;
+- the earlier T4/T5/T6 items listed above that the Linux run does not cover.
diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md
new file mode 100644
index 000000000..994c3d7e0
--- /dev/null
+++ b/odd/tasks/browser-install-wizard.md
@@ -0,0 +1,107 @@
+# Browser installation wizard
+
+## Objective
+Install all prerequisites and the normal Gentle Shell stack through a local-browser wizard on Windows, macOS and Linux. The final behavior must match global `gentle-pi` installation and running `gentle-shell` in a terminal.
+
+## Problem and rationale
+Current setup requires a usable Node/Pi runtime and Windows native provisioning requires Go. A browser wizard should acquire missing prerequisites, explain installation progress and verify readiness without duplicating the existing provider/companion installation logic.
+
+## Authorized scope and constraints
+- User authorized implementation after approving the corrected design.
+- User later granted standing permission for in-scope labs, research, implementation, verification and work-unit commits without per-step prompts ("te permito todo, tiene que quedar genial"). Destructive actions and outward publishing (push, PR, release) still require explicit confirmation.
+- Visual reference for the wizard UI: https://gentlemanprogramming.com (verify in a browser during T6).
+- Preserve the default `~/.gentle-shell/agent`, existing Engram data/server semantics, shared integrations and existing setup/update ownership.
+- Reuse compatible prerequisites and install missing ones, including Pi.
+- Prefer pnpm without assuming npm, pnpm, Corepack, Node or Go is already installed.
+- `npm:` registry references are allowed; they are not npm executable invocations. Pi's `npmCommand` supports pnpm, but upstream independent `npm exec` remains unverified.
+- Do not invent package-manager flags, an npm-impersonating wrapper or a duplicate companion installer.
+- No custom product root, separate Engram database/server, credential copying/migration, security exclusions or silent changes to existing installations.
+- Use a dependency-free Node local HTTP host and bundled static browser UI. The wizard is temporary; ordinary terminal `gentle-shell` remains the runtime.
+- Keep source writes single-threaded. No push, PR, release, remote execution or authenticated remote access is authorized.
+- Initial target matrix: Windows/macOS/Linux desktop x64 and arm64; exact supported OS/libc/runtime versions must be validated rather than claimed.
+
+## Delivery and routing
+- Branch: `feat/browser-install-wizard`; verified default reference: `origin/main`.
+- Development workspace: user-authorized isolated worktree `../gentle-pi-browser-install-wizard` beside the main worktree. This does not change product or Engram installation roots.
+- Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`.
+- Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit.
+- Running committed authored lines through T4 closure (ledger `f7553801` adds about 30): 3,926 (T1 417 + T2 1,041 + T2 ledger 21 + T3 source 1,337 + T3 ledger 15 + T4 source 1,095). This T4 closure ledger is a separate passive commit.
+- Last reviewed source boundary: `0d9dac2cc90db922283f40cffa03958ba827fd42`; committed tree exactly matched approved frozen T4 tree `2aba557ebaa015241c998fe6f707f0e8715e30a3`.
+- Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate.
+- No publishing is implied by a delivery-strategy choice.
+- Each task uses delegated direct: unfamiliar preparation and two or more non-trivial implementation files trigger a bounded writer.
+- Task size around 400 lines is advisory, not a cap. Never compress code or omit checks to meet it.
+- Tests first where deterministic expected behavior exists: observed RED, GREEN, then checked refactoring. Report unavailable or non-applicable checks honestly.
+- RDD currently on globally; review candidates are work-unit commits or review slices, never checkboxes. Native review does not replace functional verification.
+
+## Tasks
+- [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning.
+- [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7.
+- [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI.
+- [x] T4 — Standard installation driver. Status: bounded implementation closed; commit `0d9dac2cc90db922283f40cffa03958ba827fd42`. Writer, independent verifier and parent passed (final 134 tests/121 pass/0 fail/13 native Windows skips); native review approved and exactly acknowledged. Real-machine behavior remains T7. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots.
+- [x] T5 — Secure local wizard host and packaged entry. Status: closed in two work units: T5a `e4cac184` (probes and runtime persistence) and T5b `00360106` (secure host, entry, placeholder, package verification); both independently verified and natively reviewed. Real-browser and native-platform behavior remain T7. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API.
+- [x] T6 — Accessible browser wizard and terminal handoff. Status: closed in `76b4d8a5`; writer, independent verifier, one scoped polish correction, parent visual review and native review. Real browsers, screen readers and native platforms remain T7. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/install-wizard-preview.mjs` (dev preview with fake scenarios on the real server), `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `README.md`, `docs/install-wizard.md`. Visual reference: gentlemanprogramming.com tokens; visual checks use the local Playwright Chromium headless build with screenshots at 1440px and 390px. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff.
+- [ ] T7 — Cross-platform acceptance and parity evidence. Status: Linux clean-machine acceptance PASSED and T7a committed in `ae011a12` (independent verifier + high four-lens native review approved). Native Windows/macOS evidence comes from the new CI `installer` matrix after the user-authorized push; real browsers, screen readers, zsh/fish and other distros remain open. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch.
+
+## Acceptance criteria
+- A supported clean machine can acquire all prerequisites including Pi without manual dependency installation.
+- Normal global package ownership and default terminal-launch isolation remain unchanged.
+- Engram/shared companion behavior is reused, not duplicated or migrated.
+- Mandatory readiness failures never produce a successful result.
+- Missing provider authentication is distinguished from installation failure; no paid model request is required for basic readiness.
+- Downloads are integrity-verified; installation actions are fixed and explicitly consented to.
+- Interrupted/failing steps are recoverable without deleting existing data or exposing secrets.
+- Native OS support is claimed only after corresponding execution evidence, not syntax/mock tests alone.
+
+## Verification policy
+Risk: high (installers, executable acquisition, shared configuration, local mutation API). Functional focused checks are mandatory; applicable independent verification and native review follow the runtime/controller evidence. Use disposable homes and fake download/process adapters for deterministic tests. No real installs into the operator's home.
+
+Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runtime-modules`, `node scripts/verify-package-files.mjs`, and `pnpm run test:packed-package`. The last uses real installation/network and requires a side-effect forecast/disposable environment. Typecheck is an existing ratchet, not a zero-diagnostic guarantee.
+
+## Open evidence and boundaries
+- Upstream independent `npm exec` cannot be redirected by Pi's `npmCommand`; inspect the exact pinned public contract before asserting an npm-free setup.
+- Bootstrap publication URLs, artifact trust/integrity, OS support and elevation behavior need validated implementation contracts; do not invent published artifacts.
+- Existing launcher setup dry-run is not a whole-launcher no-write guarantee; preflight must not execute setup or postinstall.
+- Browser functional verification remains pending implementation.
+- T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending.
+
+## Progress and next step
+T1–T6 and T7a are closed; PR #1703 is open (Closes #1700). T7b committed in `81b420c7` (native review `review-81da8731c385acb8` approved; advisories: native guard `exit 0` unproved, install chain lacks terminal catch). User chose to push T7b and T7c together. T7c committed in `29ae9946` (native review `review-e810447b245e09b9` approved; advisories R3-001..R3-004). T7b+T7c pushed together over HTTPS with the gh session (no workflow files changed) at `29ae9946`; CI rerunning on PR #1703.
+
+## Work-unit evidence (condensed)
+Full per-unit evidence lives in Engram topics `installer/*` (t4-*, t5a-*, t5b-*, t6-*, t7-*, t7a-*, t7b-*) and in git history.
+
+| Unit | Commit | Verification | Native review |
+|---|---|---|---|
+| T1 preflight | `09860c05` | 21/21 writer, independent, parent | `review-eda68029d737a4f8` approved |
+| T2 POSIX bootstrap | `03e770f7` | 76/76 writer, independent, parent | `review-5cbf4ce0e04bf1de` high, approved |
+| T3 Windows bootstrap | `ffdc7f74` | 98 pass/13 native skips; real pnpm archive proof | `review-2aa665d2b4350300` approved |
+| T4 runner | `0d9dac2c` | 121 pass; independent PASS + one correction | `review-7d456e98582a8344` approved |
+| T5a probes + persistence | `e4cac184` | 156 pass; independent PASS + one correction; two labs proved pnpm-managed Node/npm/pnpm | `review-5ad487c03b1146fc` approved |
+| T5b secure host + entry | `00360106` | 195 pass; offensive loopback verification PASS; hardening | `review-0f4c1883b3f3d950` approved |
+| T6 wizard UI | `76b4d8a5` | 219 pass; CDP Chromium screenshots, 0 CSP violations | `review-d22416227f5c4e67` approved |
+| T7a acceptance + CI | `ae011a12` | 232 pass; Linux clean-container acceptance PASS end to end | `review-2fead884599842f6` high, approved |
+
+Key decisions: existing stacks are blocked (upgrades stay with `gentle-shell update`); persist only what is missing (never shadow a user's Node); `--allow-build=gentle-pi` only; stderr captured only for `shell-setup` and `persist-path` (bounded, sanitized); bootstrap removes only its owned tools after success.
+
+Known upstream/environment facts: Gentle AI resolves the latest Engram through the anonymous GitHub API (60/h per IP); `pnpm setup` needs `SHELL`, prints errors on stdout and installs `@pnpm/exe` first; it writes only the interactive shell profile; Gentle AI creates an empty `~/.pi/gentle-ai`.
+
+Open debt: close/signals do not wait for the runner; deadlines kill only the direct child; real browsers/screen readers, macOS and Windows native acceptance, zsh/fish; nonblocking review advisories recorded per unit in Engram.
+
+### T7b PR feedback and CI fixes (committed `81b420c7`)
+- Mapping `mus5u9pr-e-ly36` against CI run 37109363955 and reviews by egdev6 and CodeRabbit.
+- Writer `mus61gr6-f-3emj`: macOS fixture canonicalization (RED reproduced with a symlinked TMPDIR), Windows probe chmod skip, native guard `exit 0` + stderr in the assertion, typecheck fixes (+2 hidden TS2345), empty Windows PATH entries skipped (P1), `pnpm setup` uses `deadlines.setup`, server resets `installing` in `.finally`, stale docs and recorded debt. 10 files, +108/-24.
+- Parent spot check: 249 tests/235 pass/0 fail/14 native skips; `pnpm run typecheck` no regressions (187 recorded); diff check clean; temp files removed. Windows native items need CI after push.
+- T7c next: partial-install recovery (exact-version stack in one pnpm global project under `$PNPM_HOME` → only `setup-shell` (+`setup-global-bin`) and `verify-readiness`, no `add -g`).
+
+### T7d Windows native CI (in progress)
+- CI on `29ae9946`: all jobs green except `installer (windows-latest)`: 258 tests, 201 pass, 6 fail. The guard `exit 0` fix worked (8 native process tests + native entry pass). Remaining: probes fs test expects the 8.3 short temp path (`RUNNER~1`) while realpath is long; 5 native bootstrap tests fail with the production message "private storage ACL/reparse/ownership claim failed or policy denied it" (suspected short-path comparison in the claim, which would also affect real users, or runner ACL entries). User authorized T7d including a push.
+- Writer `musfws5g-h-gaps`: fixed allowlisted claim reason codes in `bootstrap.cmd` (message unchanged + `Reason: `), stderr in native assertions, probes fixture `realpathSync.native`, docs. Static analysis rules out 8.3 paths in the claim; prime hypothesis `home-owner` (elevated runner creates directories owned by Administrators). Parent decision: push diagnostics first and read the CI reason before any owner decision; never relax production owner/ACL checks. Parent check: 259 tests/245 pass/0 fail/14 skips; typecheck no regressions.
+- Diagnostics committed and pushed as `d091a25d` (native review `review-9db0f7f783c32122` approved; advisory R3-001 on the junction test). CI run 37127518418: Windows 259 tests/203 pass/5 fail, all five `Reason: home-owner`; probes short-path fix passed. Cause confirmed: the elevated Windows Server runner creates Administrators-owned fixture directories. Production stays strict; fixture owner fix delegated to `musgay6p-i-h1n1`.
+- Fixture owner fix by `musgay6p-i-h1n1` (production unchanged; expected-rejection tests pinned to their codes). Parent check: 259 tests/245 pass/0 fail/14 skips; typecheck no regressions.
+- Owner fixture committed/pushed as `61dab6b3` (review `review-c45f2ffbaf679d32` approved). CI run 37127968511 then showed the real cause: Windows PowerShell 5.1 cannot autoload `Microsoft.PowerShell.Security` (`Get-Acl`) under an inherited pwsh `PSModulePath`; the earlier `home-owner` code was this failure disguised because unexpected exceptions reported the current step. This is a real product bug (bootstrap launched from a PowerShell 7 terminal). Correction `musglgb1-j-764p`: .NET `GetAccessControl`/`SetAccessControl` in production and fixtures with identical semantics, and `unexpected-` for non-allowlisted errors.
+- `musglgb1-j-764p`: all ACL reads/writes use .NET APIs (production + fixtures), `unexpected-` for non-allowlisted errors, `collision` precheck, static guard against Security-module cmdlets. Parent check: 260 tests/246 pass/0 fail/14 skips; typecheck no regressions.
+- Committed/pushed `6c593f24` (review `review-2bfc4998183c53c4` approved). CI run 37128661267: Windows 260 tests/208 pass/1 fail — only the complete local sentinel fails in the Node probe stage (generic message). Diagnostics for that stage delegated to `mush1ifv-k-ii91`.
+- `33234953` (probe reason codes, review `review-0c7366903313046b` approved). CI run 37129229874: Windows 261/209 pass/1 fail; the sentinel reports `Reason: unexpected-target` (exception reading `.node-target` or its item). Fix delegated to `mushf2hx-l-xpr1`.
+- `615df6a7` (`.node-target` UTF-8 round-trip + `missing-target`, review `review-86076bd7c93f564e` approved). CI run 37129736644: Windows 262/210 pass/1 fail; sentinel now passes the probe and fails at the helper launch with a generic message. Helper diagnostics delegated to `mushq8bo-m-t4v4`.
+- `f749a02c` (helper reason codes; native review `review-af7c8b65432f2cc9` required one correction: a platform-ungated test, fixed by the parent and validated). CI run 37130718018: Windows 265/213 pass/1 fail, `Reason: pathext (pnpm-discovery)` — Windows PowerShell appends `.CPL` to PATHEXT (affects every Windows user). Fix delegated to `musibg0f-n-lrkp`: recognize `.cpl` without accepting it as a candidate; audit all PATHEXT sites.
diff --git a/scripts/bootstrap.cmd b/scripts/bootstrap.cmd
new file mode 100644
index 000000000..e77ab611f
--- /dev/null
+++ b/scripts/bootstrap.cmd
@@ -0,0 +1,225 @@
+@echo off
+setlocal DisableDelayedExpansion
+rem Trusted extracted/checkout bundle only. Never pipe a remote script here.
+rem Fixed stock PowerShell commands; paths are environment DATA, not PS source.
+rem Each invocation is below CMD's logical command length limit.
+set "GENTLE_BOOTSTRAP_BUNDLE=%~dp0.."
+set "GENTLE_BOOTSTRAP_TOOLS=%LOCALAPPDATA%\.gentle-shell-bootstrap-tools.%RANDOM%.%RANDOM%.%RANDOM%"
+set "GENTLE_BOOTSTRAP_OWNED="
+set "GENTLE_BOOTSTRAP_PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
+if not exist "%GENTLE_BOOTSTRAP_PS%" goto unavailable
+
+rem Bundle/dependency checks precede ALL acquisition and per-user writes.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$bundle = [IO.Path]::GetFullPath($env:GENTLE_BOOTSTRAP_BUNDLE);" ^
+ "foreach ($file in @('bin/gentle-shell-install.mjs','package.json','scripts/installer-downloads.mjs','scripts/installer-windows.mjs','scripts/installer-windows-artifacts.json')) {" ^
+ " $item = Get-Item -LiteralPath (Join-Path $bundle $file) -Force;" ^
+ " if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unsafe bundle file' };" ^
+ "};" ^
+ "$metadata = Get-Content -LiteralPath (Join-Path $bundle 'package.json') -Raw | ConvertFrom-Json;" ^
+ "if ($metadata.engines.node -notmatch '^>=[0-9]+\.[0-9]+\.[0-9]+$' -or $metadata.packageManager -ne 'pnpm@11.1.1') { throw 'Repository prerequisite unknown' };" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: future wizard/dependent bundle file missing, unsupported metadata, or managed policy denial. No acquisition attempted.'); exit 1 } }"
+if errorlevel 1 goto failed
+
+rem Claim an absent unique directory beneath a verified local per-user parent.
+rem No security changes outside this new prerequisite directory.
+rem Strict target/parent rights: ReadAndExecute + Synchronize (0x1200a9).
+rem Distant existing ancestors additionally permit sibling CreateDirectories (4).
+rem WriteData/reparse-affecting writes, deletion and ACL/owner changes still fail.
+rem Failures append one fixed reason code (the failed check), never path/SID/error text;
+rem other exceptions report unexpected-. ACLs use .NET, not Security-module
+rem cmdlets: PowerShell 7's inherited PSModulePath breaks their 5.1 autoload.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { $claimed = $false; $step = 'policy'; try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'policy' };" ^
+ "$me = [Security.Principal.WindowsIdentity]::GetCurrent().User;" ^
+ "$trusted = @($me.Value,'S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464');" ^
+ "$step = 'path-mismatch'; $tools = [IO.Path]::GetFullPath($env:GENTLE_BOOTSTRAP_TOOLS); $path = [IO.Directory]::GetParent($tools).FullName;" ^
+ "if (-not [IO.Path]::IsPathRooted($env:LOCALAPPDATA) -or $tools.StartsWith('\\') -or $path -ne [IO.Path]::GetFullPath($env:LOCALAPPDATA)) { throw 'path-mismatch' };" ^
+ "$depth = 1;" ^
+ "$step = 'home-owner'; $homeAcl = [IO.Directory]::GetAccessControl($path); if ($homeAcl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'home-owner' };" ^
+ "$step = 'ancestor-walk'; while ($path) {" ^
+ " $item = Get-Item -LiteralPath $path -Force; if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'ancestor-reparse' };" ^
+ " $acl = [IO.Directory]::GetAccessControl($path); if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw 'ancestor-owner' };" ^
+ " $allowedRights = 0x1200a9; if ($depth -ge 2) { $allowedRights = 0x1200ad };" ^
+ " foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) {" ^
+ " if ($rule.AccessControlType -eq 'Allow' -and -not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -and ([long]$rule.FileSystemRights -band (-bnot [long]$allowedRights)) -and $trusted -notcontains $rule.IdentityReference.Value) { throw 'acl-mask' };" ^
+ " }; $parent = [IO.Directory]::GetParent($path); if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++;" ^
+ "};" ^
+ "$step = 'create'; if (Test-Path -LiteralPath $tools) { throw 'collision' }; $null = New-Item -ItemType Directory -Path $tools; $claimed = $true;" ^
+ "$step = 'private-acl'; $acl = New-Object Security.AccessControl.DirectorySecurity; $acl.SetOwner($me); $acl.SetAccessRuleProtection($true,$false);" ^
+ "foreach ($sid in @($me.Value,'S-1-5-18','S-1-5-32-544')) {" ^
+ " $identity = New-Object Security.Principal.SecurityIdentifier($sid);" ^
+ " $rule = New-Object Security.AccessControl.FileSystemAccessRule($identity,'FullControl','ContainerInherit,ObjectInherit','None','Allow'); $acl.AddAccessRule($rule);" ^
+ "}; [IO.Directory]::SetAccessControl($tools,$acl);" ^
+ "$verified = [IO.Directory]::GetAccessControl($tools); if (-not $verified.AreAccessRulesProtected) { throw 'protected-dacl' };" ^
+ "if ($verified.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'private-owner' };" ^
+ "foreach ($rule in $verified.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -ne 'Allow' -or @($me.Value,'S-1-5-18','S-1-5-32-544') -notcontains $rule.IdentityReference.Value) { throw 'private-ace' } };" ^
+ "$step = 'marker'; $null = New-Item -ItemType File -Path (Join-Path $tools '.bootstrap-owned') -Value 'gentle-pi prerequisite tooling only';" ^
+ "} catch { $reason = 'unexpected-' + $step; if ($_.Exception.Message -cmatch '^(policy|path-mismatch|home-owner|ancestor-reparse|ancestor-owner|acl-mask|collision|protected-dacl|private-owner|private-ace)$') { $reason = $_.Exception.Message };" ^
+ "if ($claimed) { Remove-Item -LiteralPath $env:GENTLE_BOOTSTRAP_TOOLS -Recurse -Force -ErrorAction SilentlyContinue }; [Console]::Error.WriteLine('Bootstrap: private storage ACL/reparse/ownership claim failed or policy denied it. Reason: ' + $reason); exit 1 } }"
+if errorlevel 1 goto failed
+set "GENTLE_BOOTSTRAP_OWNED=1"
+
+rem Select an existing native Node without invoking any command interpreter shim.
+rem Absence alone authorizes pinned acquisition; incompatibility never replaces.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$command = $null; try { $command = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1 } catch [Management.Automation.CommandNotFoundException] { $command = $null };" ^
+ "if ($null -ne $command) { $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($command.Source); $record = [IO.File]::Open((Join-Path $env:GENTLE_BOOTSTRAP_TOOLS '.node-target'),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); try { $record.Write($bytes,0,$bytes.Length) } finally { $record.Dispose() } };" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: existing Node resolution failed or policy denied it.'); exit 1 } }"
+if errorlevel 1 goto failed
+
+rem Fixed official ZIP transport: no redirects, bounded bytes/time, no TLS changes.
+rem Integrity is verified before ZIP processing or executable publication.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; if (Test-Path -LiteralPath (Join-Path $tools '.node-target')) { exit 0 };" ^
+ "$architecture = $env:PROCESSOR_ARCHITECTURE; if ($env:PROCESSOR_ARCHITEW6432) { $architecture = $env:PROCESSOR_ARCHITEW6432 };" ^
+ "switch ($architecture) { 'AMD64' { $arch = 'x64' } 'ARM64' { $arch = 'arm64' } default { throw 'Unsupported architecture' } };" ^
+ "$pins = Get-Content -LiteralPath (Join-Path $env:GENTLE_BOOTSTRAP_BUNDLE 'scripts/installer-windows-artifacts.json') -Raw | ConvertFrom-Json; $pin = $pins.node.$arch;" ^
+ "$stem = 'node-v24.21.0-win-' + $arch;" ^
+ "if ($pins.node.version -ne '24.21.0' -or $pin.url -ne ('https://nodejs.org/dist/v24.21.0/' + $stem + '.zip') -or $pin.sha256 -notmatch '^[a-f0-9]{64}$' -or $pins.node.maxBytes -ne 104857600) { throw 'Artifact pin rejected' };" ^
+ "$request = [Net.HttpWebRequest]::Create($pin.url); $request.AllowAutoRedirect = $false; $request.Timeout = 60000; $request.ReadWriteTimeout = 10000;" ^
+ "$watch = [Diagnostics.Stopwatch]::StartNew(); $response = $null; $output = $null; $input = $null;" ^
+ "try {" ^
+ " $response = $request.GetResponse(); if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt 104857600) { throw 'Download rejected' };" ^
+ " $input = $response.GetResponseStream(); $output = [IO.File]::Open((Join-Path $tools 'node.zip'),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);" ^
+ " $buffer = New-Object byte[] 65536; $total = 0;" ^
+ " while (($count = $input.Read($buffer,0,$buffer.Length)) -gt 0) { $total += $count; if ($total -gt 104857600 -or $watch.ElapsedMilliseconds -gt 60000) { throw 'Download bound exceeded' }; $output.Write($buffer,0,$count) };" ^
+ " if ($total -eq 0 -or ($response.ContentLength -ge 0 -and $response.ContentLength -ne $total) -or $watch.ElapsedMilliseconds -gt 60000) { throw 'Download truncated or timed out' };" ^
+ "} finally { if ($output) { $output.Dispose() }; if ($input) { $input.Dispose() }; if ($response) { $response.Dispose() } };" ^
+ "$actual = (Get-FileHash -LiteralPath (Join-Path $tools 'node.zip') -Algorithm SHA256).Hash; if ($actual -ne $pin.sha256) { throw 'Integrity mismatch' };" ^
+ "$null = New-Item -ItemType File -Path (Join-Path $tools '.node-stem') -Value $stem;" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: Node download, bounds, integrity or policy check failed.'); exit 1 } }"
+if errorlevel 1 goto failed
+
+rem Inspect the entire ZIP namespace before extracting ONLY its regular node.exe.
+rem Reject links, Windows device/ADS names, traversal and case aliases.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; if (Test-Path -LiteralPath (Join-Path $tools '.node-target')) { exit 0 };" ^
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem;" ^
+ "$stem = (Get-Content -LiteralPath (Join-Path $tools '.node-stem') -Raw).Trim();" ^
+ "$zip = [IO.Compression.ZipFile]::OpenRead((Join-Path $tools 'node.zip'));" ^
+ "try {" ^
+ " if ($zip.Entries.Count -eq 0 -or $zip.Entries.Count -gt 50000) { throw 'ZIP count rejected' };" ^
+ " $explicit = @{}; $names = @{}; $types = @{}; $selected = $null; $expanded = 0;" ^
+ " foreach ($entry in $zip.Entries) {" ^
+ " $name = $entry.FullName; $directory = $name.EndsWith('/'); if ($directory) { $name = $name.Substring(0,$name.Length - 1) };" ^
+ " $unixType = ($entry.ExternalAttributes -shr 16) -band 61440;" ^
+ " if (($entry.ExternalAttributes -band 1024) -or ($unixType -ne 0 -and $unixType -ne 32768 -and $unixType -ne 16384) -or ($unixType -eq 16384 -and -not $directory) -or ($directory -and $entry.Length -ne 0)) { throw 'ZIP links/types rejected' };" ^
+ " if ($entry.FullName.Contains('\') -or $explicit.ContainsKey($name)) { throw 'ZIP duplicate/path rejected' }; $explicit[$name] = $true;" ^
+ " $parts = $name.Split('/'); $path = '';" ^
+ " for ($index = 0; $index -lt $parts.Length; $index++) {" ^
+ " $part = $parts[$index];" ^
+ " if (-not $part -or $part -eq '.' -or $part -eq '..' -or $part -match '[<>:\x22|?*\x00-\x1f\x7f]' -or $part -match '[. ]$' -or $part -match '^(CON|PRN|AUX|NUL|COM[1-9\u00b9\u00b2\u00b3]|LPT[1-9\u00b9\u00b2\u00b3])(\.|$)') { throw 'ZIP Windows name rejected' };" ^
+ " if ($path) { $path += '/' }; $path += $part; $isDirectory = $directory -or $index -lt ($parts.Length - 1);" ^
+ " if ($names.ContainsKey($path) -and ($names[$path] -cne $path -or $types[$path] -ne $isDirectory)) { throw 'ZIP case/type alias rejected' }; $names[$path] = $path; $types[$path] = $isDirectory;" ^
+ " };" ^
+ " $expanded += $entry.Length; if ($expanded -gt 536870912 -or ($name -ne $stem -and -not $name.StartsWith($stem + '/',[StringComparison]::Ordinal))) { throw 'ZIP root/size rejected' };" ^
+ " if ($name -ceq ($stem + '/node.exe')) { if ($directory -or $entry.Length -eq 0 -or $entry.Length -gt 157286400) { throw 'Node member rejected' }; $selected = $entry };" ^
+ " }; if ($null -eq $selected) { throw 'Node member missing' };" ^
+ " $destination = Join-Path $tools 'node'; $null = New-Item -ItemType Directory -Path $destination;" ^
+ " $target = Join-Path $destination 'node.exe'; $output = [IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); $input = $selected.Open();" ^
+ " try { $input.CopyTo($output) } finally { $input.Dispose(); $output.Dispose() };" ^
+ " $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($target); $record = [IO.File]::Open((Join-Path $tools '.node-target'),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); try { $record.Write($bytes,0,$bytes.Length) } finally { $record.Dispose() };" ^
+ "} finally { $zip.Dispose() };" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: Node ZIP namespace, extraction or policy check failed.'); exit 1 } }"
+if errorlevel 1 goto failed
+
+rem Production direct non-forking Node probe: 10s deadline and combined 1-MiB cap.
+rem Drain both pipes asynchronously; valid output followed by a hang still fails.
+rem Failures append one fixed reason code; walk codes name the component role only.
+rem .node-target holds a possibly non-ASCII path: written and read as explicit UTF-8.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { $child = $null; $started = $false; $step = 'policy'; try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'policy' };" ^
+ "$step = 'target'; $tools = $env:GENTLE_BOOTSTRAP_TOOLS; $record = Join-Path $tools '.node-target'; if (-not [IO.File]::Exists($record)) { throw 'missing-target' };" ^
+ "$node = [IO.File]::ReadAllText($record,[Text.Encoding]::UTF8).TrimEnd([char]13,[char]10); if (-not $node) { throw 'missing-target' };" ^
+ "$item = Get-Item -LiteralPath $node -Force; if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'unsafe-target' };" ^
+ "if (-not [IO.Path]::IsPathRooted($node) -or $node.StartsWith('\\')) { throw 'unsafe-path' };" ^
+ "$me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; $trusted = @($me,'S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'); $path = $node;" ^
+ "$depth = 0;" ^
+ "$step = 'acl-walk'; while ($path) {" ^
+ " $role = 'ancestor'; if ($depth -eq 0) { $role = 'target' } elseif ($depth -eq 1) { $role = 'parent' };" ^
+ " $item = Get-Item -LiteralPath $path -Force; if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw ($role + '-reparse') };" ^
+ " if ($item.PSIsContainer) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) };" ^
+ " if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw ($role + '-owner') };" ^
+ " $allowedRights = 0x1200a9; if ($depth -ge 2) { $allowedRights = 0x1200ad };" ^
+ " foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and -not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -and ([long]$rule.FileSystemRights -band (-bnot [long]$allowedRights)) -and $trusted -notcontains $rule.IdentityReference.Value) { throw ($role + '-acl-mask') } };" ^
+ " $parent = [IO.Directory]::GetParent($path); if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++;" ^
+ "};" ^
+ "$start = New-Object Diagnostics.ProcessStartInfo; $start.FileName = $node; $start.Arguments = '--version'; $start.UseShellExecute = $false; $start.CreateNoWindow = $true; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true;" ^
+ "$step = 'start'; $child = New-Object Diagnostics.Process; $child.StartInfo = $start; if (-not $child.Start()) { throw 'no-start' }; $started = $true;" ^
+ "$step = 'drain'; $streams = @($child.StandardOutput.BaseStream,$child.StandardError.BaseStream); $buffers = @((New-Object byte[] 4096),(New-Object byte[] 4096));" ^
+ "$tasks = @($streams[0].ReadAsync($buffers[0],0,4096),$streams[1].ReadAsync($buffers[1],0,4096)); $closed = @($false,$false); $text = ''; $total = 0; $watch = [Diagnostics.Stopwatch]::StartNew();" ^
+ "while (-not ($closed[0] -and $closed[1] -and $child.HasExited)) {" ^
+ " if ($watch.ElapsedMilliseconds -ge 10000) { throw 'deadline' };" ^
+ " for ($index = 0; $index -lt 2; $index++) { if (-not $closed[$index] -and $tasks[$index].IsCompleted) {" ^
+ " $count = $tasks[$index].GetAwaiter().GetResult(); if ($count -eq 0) { $closed[$index] = $true } else {" ^
+ " $total += $count; if ($total -gt 1048576) { throw 'output-limit' };" ^
+ " if ($index -eq 0) { $text += [Text.Encoding]::UTF8.GetString($buffers[$index],0,$count) };" ^
+ " $tasks[$index] = $streams[$index].ReadAsync($buffers[$index],0,4096);" ^
+ " }" ^
+ " } }; Start-Sleep -Milliseconds 10;" ^
+ "}; if ($child.ExitCode -ne 0) { throw 'exit-code' };" ^
+ "$step = 'version'; $version = $text.Trim(); if ($version -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { throw 'version-format' };" ^
+ "$actual = [Version]$version.Substring(1); $metadata = Get-Content -LiteralPath (Join-Path $env:GENTLE_BOOTSTRAP_BUNDLE 'package.json') -Raw | ConvertFrom-Json;" ^
+ "if ($actual -lt [Version]'24.3.0' -or $actual -lt [Version]$metadata.engines.node.Substring(2)) { throw 'engine' };" ^
+ "if ((Test-Path -LiteralPath (Join-Path $tools '.node-stem')) -and $version -ne 'v24.21.0') { throw 'acquired-version' };" ^
+ "} catch { $reason = 'unexpected-' + $step; if ($_.Exception.Message -cmatch '^(policy|missing-target|unsafe-target|unsafe-path|(target|parent|ancestor)-(reparse|owner|acl-mask)|no-start|deadline|output-limit|exit-code|version-format|engine|acquired-version)$') { $reason = $_.Exception.Message };" ^
+ "[Console]::Error.WriteLine('Bootstrap: Node version, execution, deadline, output bound or policy check failed; refusing replacement. Reason: ' + $reason); exit 1 }" ^
+ "finally { try { if ($child) { if ($started -and -not $child.HasExited) { $child.Kill(); if (-not $child.WaitForExit(1000)) { throw 'Child termination unconfirmed' } }; $child.Dispose() } } catch { [Console]::Error.WriteLine('Bootstrap: direct-child termination could not be confirmed.'); exit 1 } } }"
+if errorlevel 1 goto failed
+
+rem Launch the existing Node helper with data arguments; no shell evaluation.
+rem Interactive wizard duration is intentionally unbounded. Only child PATH changes.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; $record = Join-Path $tools '.node-target'; $node = [IO.File]::ReadAllText($record,[Text.Encoding]::UTF8).TrimEnd([char]13,[char]10);" ^
+ "$env:PATH = [IO.Path]::GetDirectoryName($node) + ';' + $env:PATH;" ^
+ "$helper = Join-Path $env:GENTLE_BOOTSTRAP_BUNDLE 'scripts/installer-downloads.mjs';" ^
+ "if (Test-Path -LiteralPath (Join-Path $tools 'node.zip')) { Remove-Item -LiteralPath (Join-Path $tools 'node.zip') };" ^
+ "& $node $helper '--bootstrap-windows' $env:GENTLE_BOOTSTRAP_BUNDLE $tools; if ($LASTEXITCODE -ne 0) { throw 'Helper failed' };" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: prerequisite helper/wizard failed or managed policy denied execution.'); exit 1 } }"
+if errorlevel 1 goto failed
+
+rem Success: node/npm/pnpm now persist under PNPM_HOME or were already the user's.
+rem Remove only our exact claimed, marked root; Directory.Delete does not recurse
+rem through reparse points. A removal problem never fails the installation.
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$tools = [IO.Path]::GetFullPath($env:GENTLE_BOOTSTRAP_TOOLS); $parent = [IO.Directory]::GetParent($tools).FullName;" ^
+ "if ($tools.StartsWith('\\') -or $parent -ne [IO.Path]::GetFullPath($env:LOCALAPPDATA) -or -not [IO.Path]::GetFileName($tools).StartsWith('.gentle-shell-bootstrap-tools.',[StringComparison]::Ordinal)) { throw 'Cleanup target rejected' };" ^
+ "$item = Get-Item -LiteralPath $tools -Force;" ^
+ "if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Cleanup target rejected' };" ^
+ "$marker = Get-Item -LiteralPath (Join-Path $tools '.bootstrap-owned') -Force;" ^
+ "if ($marker.PSIsContainer -or ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -or (Get-Content -LiteralPath $marker.FullName -Raw) -ne 'gentle-pi prerequisite tooling only') { throw 'Cleanup marker rejected' };" ^
+ "[IO.Directory]::Delete($tools, $true);" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: installation finished, but temporary tools could not be removed: ' + $env:GENTLE_BOOTSTRAP_TOOLS); exit 1 } }"
+endlocal & exit /b 0
+
+:unavailable
+>&2 echo Bootstrap: stock Windows PowerShell is unavailable. No acquisition attempted.
+:failed
+rem Never scan/reuse/clean other attempts. Only our successfully claimed root.
+if not defined GENTLE_BOOTSTRAP_OWNED goto finishfailure
+"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^
+ "& { try { $ErrorActionPreference = 'Stop';" ^
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^
+ "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; $item = Get-Item -LiteralPath $tools -Force;" ^
+ "if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Cleanup target rejected' };" ^
+ "$marker = Get-Item -LiteralPath (Join-Path $tools '.bootstrap-owned') -Force;" ^
+ "if ($marker.PSIsContainer -or ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -or (Get-Content -LiteralPath $marker.FullName -Raw) -ne 'gentle-pi prerequisite tooling only') { throw 'Cleanup marker rejected' };" ^
+ "Remove-Item -LiteralPath $tools -Recurse -Force;" ^
+ "} catch { [Console]::Error.WriteLine('Bootstrap: attempt-owned cleanup unavailable; no other storage touched.'); exit 1 } }"
+:finishfailure
+endlocal & exit /b 1
diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh
new file mode 100644
index 000000000..f98d4a447
--- /dev/null
+++ b/scripts/bootstrap.sh
@@ -0,0 +1,161 @@
+#!/bin/sh
+# Run from an extracted/checkout bundle, not from a remote pipe.
+# Only prerequisite tooling is acquired here; standard installation belongs to T4.
+set -eu
+umask 077
+fail() { printf '%s\n' "Bootstrap: $1" >&2; exit 1; }
+need() { command -v "$1" >/dev/null 2>&1 || fail "Required utility missing: $1"; }
+for utility in dirname awk; do need "$utility"; done
+bundle=$(CDPATH= cd -P "$(dirname "$0")/.." && pwd) || fail 'Cannot locate installation bundle'
+entry=$bundle/bin/gentle-shell-install.mjs
+[ -f "$entry" ] && [ ! -L "$entry" ] || fail 'Future wizard entry is missing; this bundle cannot start a live wizard'
+[ -f "$bundle/scripts/installer-downloads.mjs" ] || fail 'Download helper missing from bundle'
+for utility in uname mkdir mktemp chmod mv rm sleep wc id ls; do need "$utility"; done
+case ${HOME:-} in /*) ;; *) fail 'HOME must be an absolute per-user directory';; esac
+# Refuse symlink ancestors and group/world-writable acquisition parents.
+check_home=$HOME
+while [ "$check_home" != / ]; do
+ [ ! -L "$check_home" ] || fail 'Unsafe symlink in HOME path'
+ check_home=$(dirname "$check_home")
+done
+[ -d "$HOME" ] || fail 'HOME directory is missing'
+uid=$(id -u)
+ls -nd "$HOME" | awk -v uid="$uid" '$3 == uid && substr($1,6,1) != "w" && substr($1,9,1) != "w" {ok=1} END {exit !ok}' || fail 'Unsafe HOME ownership or permissions'
+minimum=$(awk -F '"' '{for (i=2; i<=NF-2; i+=2) if ($i == "node" && $(i+2) ~ /^>=[0-9]+\.[0-9]+\.[0-9]+$/) {value=$(i+2); sub(/^>=/, "", value); count++}} END {if (count == 1) print value}' "$bundle/package.json")
+[ -n "$minimum" ] || fail 'Cannot establish repository Node minimum'
+version_ok() {
+ awk -v value="$1" -v minimum="$2" 'BEGIN {
+ sub(/^v/, "", value)
+ if (value !~ /^[0-9]+\.[0-9]+\.[0-9]+$/) exit 1
+ split(value, a, "."); split(minimum, b, ".")
+ for (i=1; i<=3; i++) if (length(a[i]) > 9 || (length(a[i]) > 1 && substr(a[i],1,1) == "0")) exit 1
+ for (i=1; i<=3; i++) {
+ if (a[i]+0 > b[i]+0) exit 0
+ if (a[i]+0 < b[i]+0) exit 1
+ }
+ }'
+}
+# A stock POSIX watchdog avoids assuming GNU timeout on macOS.
+# These fixed prerequisite probes get an unignorable deadline, not graceful TERM.
+# Signal only our direct child PID; do not kill inherited process groups.
+bounded() {
+ seconds=$1
+ shift
+ "$@" &
+ version_pid=$!
+ (
+ sleep "$seconds" &
+ sleeper=$!
+ trap 'kill -s KILL "$sleeper" 2>/dev/null || :; wait "$sleeper" 2>/dev/null || :; exit 0' TERM
+ wait "$sleeper" || :
+ kill -s KILL "$version_pid" 2>/dev/null || :
+ ) >/dev/null 2>&1 &
+ watchdog=$!
+ result=0
+ wait "$version_pid" || result=$?
+ kill "$watchdog" 2>/dev/null || :
+ wait "$watchdog" 2>/dev/null || :
+ return "$result"
+}
+tools=
+owned=0
+# Only the exact directory this attempt claimed; rm -rf never follows links in it.
+owned_tools() {
+ [ "$owned" = 1 ] && [ -n "$tools" ] || return 1
+ case "$tools" in "$HOME"/.gentle-shell-bootstrap-tools.*/*) return 1;; "$HOME"/.gentle-shell-bootstrap-tools.*) ;; *) return 1;; esac
+ [ -d "$tools" ] && [ ! -L "$tools" ] && [ -d "$tools/.claim" ] && [ ! -L "$tools/.claim" ]
+}
+cleanup() {
+ if owned_tools; then
+ rm -rf "$tools"
+ fi
+}
+trap cleanup EXIT
+trap 'exit 1' HUP INT TERM
+make_tools() {
+ tools=$(mktemp -d "$HOME/.gentle-shell-bootstrap-tools.XXXXXXXX") || fail 'Cannot create private tooling directory'
+ case "$tools" in "$HOME"/.gentle-shell-bootstrap-tools.*) ;; *) fail 'Conflicting tooling destination';; esac
+ [ -d "$tools" ] && [ ! -L "$tools" ] || fail 'Unsafe tooling destination'
+ ls -nd "$tools" | awk -v uid="$uid" '$3 == uid && $1 == "drwx------" {ok=1} END {exit !ok}' || fail 'Unsafe tooling ownership'
+ # No existing destination is ever reused or replaced.
+ mkdir "$tools/.claim" || fail 'Conflicting tooling destination'
+ owned=1
+ printf '%s\n' 'gentle-pi prerequisite tooling only' > "$tools/.bootstrap-owned" || fail 'Cannot mark tooling ownership'
+}
+if command -v node >/dev/null 2>&1; then
+ node=$(command -v node)
+ version=$(bounded 10 "$node" --version 2>/dev/null) || fail 'Existing Node version probe failed'
+ version_ok "$version" "$minimum" || fail 'Existing Node is incompatible or unknown; refusing replacement'
+else
+ os=$(uname -s)
+ arch=$(uname -m)
+ case "$os" in Darwin) os=darwin;; Linux) os=linux;; *) fail 'Unsupported POSIX platform';; esac
+ case "$arch" in x86_64|amd64) arch=x64;; aarch64|arm64) arch=arm64;; *) fail 'Unsupported architecture';; esac
+ if [ "$os" = linux ]; then
+ need getconf
+ libc=$(getconf GNU_LIBC_VERSION 2>/dev/null) || fail 'Cannot establish native glibc compatibility; musl is not supported'
+ case "$libc" in 'glibc '*) ;; *) fail 'Unsupported libc; only native glibc acquisition is planned';; esac
+ version_ok "${libc#glibc }.0" '2.28.0' || fail 'Native Node requires glibc >=2.28; musl is not supported'
+ fi
+ # Primary source: https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt
+ # Exact release URLs are fixed, even though the provenance page is moving.
+ case "$os-$arch" in
+ darwin-arm64) hash=bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057;;
+ darwin-x64) hash=1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097;;
+ linux-arm64) hash=724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5;;
+ linux-x64) hash=6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff;;
+ esac
+ need curl
+ need tar
+ if command -v sha256sum >/dev/null 2>&1; then hasher=sha256sum
+ elif command -v shasum >/dev/null 2>&1; then hasher=shasum
+ else fail 'Required SHA256 utility missing: sha256sum or shasum'; fi
+ make_tools
+ stem=node-v24.21.0-$os-$arch
+ archive=$tools/node.tar.gz
+ # Also bound disk use on older curl versions lacking streamed-size enforcement.
+ # POSIX uses 512-byte blocks; shells using KiB still impose a 200-MiB ceiling.
+ (
+ ulimit -f 204800 || exit 1
+ curl --fail --silent --show-error --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 --max-filesize 104857600 --output "$archive" "https://nodejs.org/dist/v24.21.0/$stem.tar.gz"
+ ) 2>/dev/null || fail 'Node download failed, was truncated, or its file-size bound is unavailable'
+ size=$(wc -c < "$archive")
+ [ "$size" -gt 0 ] && [ "$size" -le 104857600 ] || fail 'Node download size is invalid'
+ if [ "$hasher" = sha256sum ]; then actual=$(bounded 30 sha256sum "$archive" 2>/dev/null) || fail 'SHA256 process failed'
+ else actual=$(bounded 30 shasum -a 256 "$archive" 2>/dev/null) || fail 'SHA256 process failed'; fi
+ actual=${actual%% *}
+ # Hash tools prefix escaped filenames (e.g. newline/backslash) with a backslash.
+ actual=${actual#\\}
+ [ "$actual" = "$hash" ] || fail 'Node checksum integrity mismatch'
+ mkdir "$tools/staging" || fail 'Conflicting staging destination'
+ # Extract only the trusted regular executable, not npm/Corepack or archive links.
+ member=$stem/bin/node
+ listing=$(bounded 30 tar -tvzf "$archive" "$member" 2>/dev/null) || fail 'Node archive is invalid'
+ case "$listing" in -*) ;; *) fail 'Node archive executable is not a regular file';; esac
+ bounded 30 tar -xzf "$archive" -C "$tools/staging" "$member" 2>/dev/null || fail 'Node extraction failed'
+ candidate=$tools/staging/$member
+ [ -f "$candidate" ] && [ ! -L "$candidate" ] || fail 'Node executable missing or unsafe'
+ version=$(bounded 10 "$candidate" --version 2>/dev/null) || fail 'Acquired Node cannot execute on this native platform'
+ [ "$version" = v24.21.0 ] && version_ok "$version" "$minimum" || fail 'Acquired Node version rejected'
+ bounded 10 chmod 700 "$candidate" 2>/dev/null || fail 'Cannot secure acquired Node executable'
+ [ ! -e "$tools/node" ] && [ ! -L "$tools/node" ] || fail 'Conflicting Node destination'
+ mv "$tools/staging/$stem" "$tools/node" || fail 'Cannot publish verified Node'
+ rm -f "$archive" || fail 'Cannot clean owned Node archive'
+ rm -rf "$tools/staging" || fail 'Cannot clean owned Node staging'
+ node=$tools/node/bin/node
+ PATH=$tools/node/bin:$PATH
+ export PATH
+fi
+# The helper creates an equally private tooling directory only if pnpm is absent.
+# Do not exec: retain ownership cleanup on any helper/child failure.
+"$node" "$bundle/scripts/installer-downloads.mjs" --bootstrap "$bundle" "$tools" || fail 'Prerequisite acquisition or wizard child failed'
+# Exit 0 means node/npm/pnpm now persist under $PNPM_HOME or were already the
+# user's. A removal problem never turns the completed installation into failure.
+if [ "$owned" = 1 ]; then
+ mark=
+ if owned_tools && [ -f "$tools/.bootstrap-owned" ] && [ ! -L "$tools/.bootstrap-owned" ] &&
+ { IFS= read -r mark < "$tools/.bootstrap-owned"; } 2>/dev/null && [ "$mark" = 'gentle-pi prerequisite tooling only' ] &&
+ rm -rf "$tools" 2>/dev/null; then :
+ else printf '%s\n' "Bootstrap: installation finished, but temporary tools could not be removed: $tools" >&2; fi
+fi
+owned=0
diff --git a/scripts/install-wizard-preview.mjs b/scripts/install-wizard-preview.mjs
new file mode 100644
index 000000000..a0c5b58ab
--- /dev/null
+++ b/scripts/install-wizard-preview.mjs
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+// Development preview for the browser installation wizard.
+//
+// Starts the REAL local host (createInstallerServer) with the real assets, but
+// with fake inventories and a fake runner: it never runs host probes, package
+// managers, downloads or installs. Use it to develop and visually check the UI:
+//
+// node scripts/install-wizard-preview.mjs --scenario=terminal --step-ms=600
+//
+// Then open the printed one-time session URL. Not part of the installer entry.
+
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { planPreflight, requirements } from "./installer-preflight.mjs";
+import { createInstallerServer } from "./installer-server.mjs";
+
+const BIN = "/home/you/.local/share/pnpm/bin";
+const baseInventory = Object.freeze({
+ platform: "linux",
+ arch: "x64",
+ node: { available: true, version: "24.21.0", usable: true, persistent: true, npm: true },
+ pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true },
+ pi: { available: false },
+ shell: { available: false },
+ gentleAi: { available: false },
+ go: { available: false },
+ globalBin: { available: true, path: BIN, writable: true, onPath: true },
+ setup: false,
+});
+// A machine that only has the bootstrap's temporary Node and pnpm, with no pnpm bin on PATH.
+const freshInventory = Object.freeze({
+ ...baseInventory,
+ node: { available: true, version: "24.21.0", usable: true, persistent: false, npm: false },
+ pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false },
+ globalBin: { ...baseInventory.globalBin, onPath: false },
+});
+// An earlier run installed the pinned stack, but its setup and `pnpm setup` did not finish.
+const recoveryInventory = Object.freeze({
+ ...baseInventory,
+ pi: { available: true, version: "1.0.0", usable: true },
+ shell: { available: true, version: requirements.shell, usable: true, global: true },
+ gentleAi: { available: true, version: requirements.gentleAi, usable: true, compatible: true },
+ globalBin: { ...baseInventory.globalBin, onPath: false },
+ setup: { available: true, recoverable: true },
+});
+const checks = ["check-npm", "check-global-bin", "check-existing-stack"];
+const install = ["install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"];
+const persistence = ["persist-node", "persist-package-managers", "verify-persistent-runtime", "check-npm", "configure-npm-prefix"];
+
+// Each scenario mirrors the runner's fixed step order for its plan.
+const scenarios = Object.freeze({
+ ready: { inventories: [baseInventory], steps: [...checks, ...install], result: { outcome: "ready" } },
+ terminal: {
+ inventories: [freshInventory],
+ steps: ["check-global-bin", "check-existing-stack", ...persistence, ...install, "persist-path"],
+ result: { outcome: "terminal-action-required", action: "open-new-terminal", npmPrefix: "configured" },
+ },
+ blocked: {
+ inventories: [baseInventory],
+ steps: ["check-npm", "check-global-bin"],
+ result: { outcome: "blocked", reason: "existing-stack" },
+ },
+ failed: {
+ inventories: [freshInventory],
+ steps: ["check-global-bin", "check-existing-stack", ...persistence],
+ result: { outcome: "failed", failedStep: "install-global" },
+ },
+ // The first plan goes stale before install (the PATH was fixed meanwhile): the host
+ // answers 409 plan-changed and the wizard must show the new plan and ask again.
+ "plan-changed": { inventories: [freshInventory, baseInventory], steps: [...checks, ...install], result: { outcome: "ready" } },
+ recovery: {
+ inventories: [recoveryInventory],
+ steps: ["check-npm", "check-global-bin", "check-recoverable-stack", ...install.slice(1), "persist-path"],
+ result: { outcome: "terminal-action-required", action: "open-new-terminal" },
+ },
+ preflight: {
+ inventories: [{ ...baseInventory, node: { available: true, version: "18.20.0", usable: true, persistent: true, npm: true },
+ gentleAi: { available: true, version: "1.0.0", usable: true, compatible: false } }],
+ steps: [],
+ result: { outcome: "blocked", reason: "preflight-blocked" },
+ },
+});
+export const scenarioNames = Object.freeze(Object.keys(scenarios));
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/** Fake collectPlan/runInstall for a scenario; nothing touches the machine. */
+export function previewScenario(name, { stepMs = 600 } = {}) {
+ const scenario = scenarios[name];
+ if (!scenario) throw new Error(`Unknown scenario "${name}". Use one of: ${scenarioNames.join(", ")}`);
+ let collected = 0;
+ return {
+ collectPlan: async () => {
+ const inventory = structuredClone(scenario.inventories[Math.min(collected, scenario.inventories.length - 1)]);
+ collected += 1;
+ await sleep(Math.min(stepMs, 400));
+ return { inventory, plan: planPreflight(inventory) };
+ },
+ runInstall: async (_request, log) => {
+ const completed = [];
+ for (const step of scenario.steps) {
+ await sleep(stepMs);
+ completed.push(step);
+ log({ step, status: "done" });
+ }
+ await sleep(stepMs);
+ const { result } = scenario;
+ if (result.outcome === "blocked") log({ step: "gate", status: "blocked", reason: result.reason });
+ if (result.outcome === "failed") log({ step: result.failedStep, status: "failed" });
+ return { ...result, completed };
+ },
+ };
+}
+
+/** Starts the real host on 127.0.0.1 with a fake scenario. Returns { host, url }. */
+export async function startPreview({ scenario = "ready", stepMs = 600, log = console.log } = {}) {
+ const fake = previewScenario(scenario, { stepMs });
+ const host = createInstallerServer({
+ assetsDir: fileURLToPath(new URL("../assets/install-wizard/", import.meta.url)),
+ collectPlan: fake.collectPlan,
+ runInstall: fake.runInstall,
+ });
+ const { url } = await host.listen();
+ log(`Gentle Shell wizard preview (scenario: ${scenario}). Fake plan and fake installer: nothing is installed.`);
+ log(`Open this one-time link (expires in 2 minutes):\n ${url}`);
+ return { host, url };
+}
+
+function option(args, name, fallback) {
+ const found = args.find((arg) => arg.startsWith(`--${name}=`));
+ return found === undefined ? fallback : found.slice(name.length + 3);
+}
+
+async function main(args) {
+ if (args.includes("--help")) {
+ console.log(`Usage: node scripts/install-wizard-preview.mjs [--scenario=${scenarioNames.join("|")}] [--step-ms=600]`);
+ return;
+ }
+ const scenario = option(args, "scenario", "ready");
+ const stepMs = Number(option(args, "step-ms", "600"));
+ if (!scenarioNames.includes(scenario) || !Number.isFinite(stepMs) || stepMs < 0) {
+ console.error(`Use --scenario=${scenarioNames.join("|")} and a non-negative --step-ms.`);
+ process.exitCode = 2;
+ return;
+ }
+ const { host } = await startPreview({ scenario, stepMs });
+ process.once("SIGINT", () => host.close("signal"));
+ process.once("SIGTERM", () => host.close("signal"));
+ const { reason, outcome } = await host.closed;
+ console.log(`Preview closed (${reason}); last outcome: ${outcome?.outcome ?? "none"}.`);
+}
+
+if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main(process.argv.slice(2));
+}
diff --git a/scripts/installer-downloads.mjs b/scripts/installer-downloads.mjs
new file mode 100644
index 000000000..c9cb4e3ef
--- /dev/null
+++ b/scripts/installer-downloads.mjs
@@ -0,0 +1,297 @@
+import { createHash, timingSafeEqual } from "node:crypto";
+import { lstatSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, renameSync, rmSync } from "node:fs";
+import { basename, dirname, join, resolve, delimiter } from "node:path";
+import { spawnSync, spawn } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+// Acquisition pins, not compatibility minima. Sources verified before pinning:
+// https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt
+// https://registry.npmjs.org/pnpm/11.1.1 (dist.integrity and engines.node)
+const nodeHashes = Object.freeze({
+ "darwin-arm64": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057",
+ "darwin-x64": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097",
+ "linux-arm64": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5",
+ "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff",
+});
+const pnpm = Object.freeze({
+ name: "pnpm", version: "11.1.1", engine: ">=22.13",
+ url: "https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz",
+ integrity: "sha512-0f319zxhe2T6GlaoHDyN/g6WbjOmAQqiVrUXrne+Idk+Ba/8DeGoOw5PKdVp9otEaujwaM1yR8C7PfD7TXvfmg==",
+ maxBytes: 32 * 1024 * 1024,
+});
+
+/** Fixed allowlist; callers cannot supply download URLs, checksums or commands. */
+export function artifactFor(name, platform, arch) {
+ if (name === "pnpm") return pnpm;
+ if (name === "node" && platform === "win32" && ["x64", "arm64"].includes(arch)) {
+ const pin = JSON.parse(readFileSync(new URL("./installer-windows-artifacts.json", import.meta.url), "utf8")).node;
+ const target = pin[arch];
+ if (pin.version !== "24.21.0" || target.url !== `https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-${arch}.zip` || !/^[a-f0-9]{64}$/.test(target.sha256) || pin.maxBytes !== 104857600) {
+ throw new Error("Windows artifact metadata rejected");
+ }
+ return Object.freeze({ name, version: pin.version, platform, arch, url: target.url, integrity: `sha256-${target.sha256}`, maxBytes: pin.maxBytes });
+ }
+ const hash = nodeHashes[`${platform}-${arch}`];
+ if (name !== "node" || !hash) throw new Error("Unsupported acquisition target");
+ return Object.freeze({ name, version: "24.21.0", platform, arch,
+ url: `https://nodejs.org/dist/v24.21.0/node-v24.21.0-${platform}-${arch}.tar.gz`,
+ integrity: `sha256-${hash}`, maxBytes: 100 * 1024 * 1024 });
+}
+
+async function download(descriptor) {
+ const response = await fetch(descriptor.url, { redirect: "error", signal: AbortSignal.timeout(60000) });
+ if (!response.ok || !response.body) throw new Error("Download failed");
+ const advertised = response.headers.get("content-length");
+ if (advertised && (!/^\d+$/.test(advertised) || Number(advertised) > descriptor.maxBytes)) {
+ await response.body.cancel();
+ throw new Error("Download size rejected");
+ }
+ const reader = response.body.getReader();
+ const chunks = [];
+ let size = 0;
+ try {
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > descriptor.maxBytes) throw new Error("Download size rejected");
+ chunks.push(Buffer.from(value));
+ }
+ if (advertised && Number(advertised) !== size) throw new Error("Download truncated");
+ return Buffer.concat(chunks, size);
+ } finally {
+ await reader.cancel().catch(() => {});
+ }
+}
+
+/** Pure descriptor selection + injected byte transport/digest for deterministic tests.
+ * Production always hashes actual bytes; test adapters are trusted local code only.
+ * No extraction or execution is allowed before this function succeeds.
+ */
+export async function verifiedDownload(name, adapters = {}, platform, arch) {
+ const descriptor = artifactFor(name, platform, arch);
+ let bytes;
+ try { bytes = await (adapters.download ?? download)(descriptor); }
+ catch { throw new Error("Download failed"); }
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > descriptor.maxBytes) throw new Error("Download size rejected");
+ const [algorithm, encoded] = descriptor.integrity.split("-");
+ const expected = Buffer.from(encoded, algorithm === "sha256" ? "hex" : "base64");
+ const actual = (adapters.digest ?? ((data, hash) => createHash(hash).update(data).digest()))(bytes, algorithm);
+ if (!Buffer.isBuffer(actual) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
+ throw new Error("Download integrity mismatch");
+ }
+ return bytes;
+}
+
+function parts(version) {
+ if (typeof version !== "string" || !/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) return null;
+ const values = version.replace(/^v/, "").split(".").map(Number);
+ return values.every(Number.isSafeInteger) ? values : null;
+}
+export function compatibleEngine(range, version) {
+ if (typeof range !== "string") return false;
+ // Preserve raw pin metadata; normalize only simple >=major.minor[.patch] bounds.
+ const bound = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/.exec(range);
+ if (!bound) return false;
+ const required = parts(`${bound[1]}.${bound[2]}.${bound[3] ?? "0"}`);
+ const actual = parts(version);
+ if (!required || !actual) return false;
+ for (let index = 0; index < 3; index += 1) {
+ if (actual[index] !== required[index]) return actual[index] > required[index];
+ }
+ return true;
+}
+function stat(path) {
+ try { return lstatSync(path); }
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
+}
+function regular(path) {
+ const info = stat(path);
+ return info?.isFile() && !info.isSymbolicLink();
+}
+function processCheck(command, args, env) {
+ // TERM can be ignored, leaving spawnSync blocked beyond its timeout. Kill only
+ // the spawned prerequisite child; this is not process-tree cancellation.
+ const result = spawnSync(command, args, { env, encoding: "utf8", timeout: 15000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, windowsHide: true });
+ if (result.error || result.status !== 0) throw new Error("Prerequisite process check failed");
+ return result.stdout.trim();
+}
+function findExecutable(name, env) {
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
+ // Empty/relative PATH entries cannot prove executable ownership.
+ if (!directory.startsWith("/")) throw new Error("Unknown prerequisite PATH compatibility");
+ const path = join(directory, name);
+ if (stat(path)) return path;
+ }
+ return null;
+}
+function packageFor(command) {
+ let directory = dirname(realpathSync(command));
+ for (let depth = 0; depth < 4; depth += 1) {
+ const file = join(directory, "package.json");
+ if (regular(file)) {
+ const metadata = JSON.parse(readFileSync(file, "utf8"));
+ if (metadata.name === "pnpm") return metadata;
+ }
+ const parent = dirname(directory);
+ if (parent === directory) break;
+ directory = parent;
+ }
+ throw new Error("Existing pnpm compatibility is unknown: package engine evidence missing");
+}
+function provePnpm(command, prefix, metadata, nodeVersion, env, processAdapter) {
+ if (!parts(metadata.version) || !compatibleEngine(metadata.engines?.node, nodeVersion)) {
+ throw new Error("pnpm compatibility is unknown or incompatible; refusing replacement");
+ }
+ if (processAdapter(command, [...prefix, "--version"], env) !== metadata.version) throw new Error("pnpm version rejected");
+ // Read-only CLI capability checks; do not execute add/bin or write global config.
+ for (const capability of ["add", "bin"]) {
+ if (!/(?:^|[\s,])--global(?:[\s,=]|$)/.test(processAdapter(command, [...prefix, "help", capability], env))) {
+ throw new Error("pnpm global-install capability evidence missing");
+ }
+ }
+}
+function privateTools(home) {
+ if (!home || !home.startsWith("/") || realpathSync(home) !== resolve(home)) throw new Error("Unsafe tooling HOME");
+ const info = lstatSync(home);
+ if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error("Unsafe tooling HOME ownership");
+ const tools = mkdtempSync(join(home, ".gentle-shell-bootstrap-tools."));
+ try {
+ writeFileSync(join(tools, ".bootstrap-owned"), "gentle-pi prerequisite tooling only\n", { mode: 0o600, flag: "wx" });
+ return tools;
+ } catch {
+ rmSync(tools, { recursive: true, force: true });
+ throw new Error("Unsafe tooling ownership marker could not be created");
+ }
+}
+
+/** Remove only the exact private `$HOME/.gentle-shell-bootstrap-tools.*` directory
+ * carrying this module's marker. rmSync never follows links inside it.
+ * Returns false instead of throwing so success never turns into failure.
+ */
+export function removeOwnedTools(tools, home) {
+ try {
+ if (typeof tools !== "string" || typeof home !== "string" || !home.startsWith("/") || resolve(tools) !== tools) return false;
+ if (dirname(tools) !== resolve(home) || !basename(tools).startsWith(".gentle-shell-bootstrap-tools.")) return false;
+ const info = lstatSync(tools);
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid()) return false;
+ const marker = join(tools, ".bootstrap-owned");
+ if (!regular(marker) || readFileSync(marker, "utf8") !== "gentle-pi prerequisite tooling only\n") return false;
+ rmSync(tools, { recursive: true });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Reuse only proven engines/capabilities; missing pnpm gets verified registry bytes.
+ * Local test adapters cover transport/hash/process. There is no remote command API.
+ * `tools` must be a private directory owned by the invoking bootstrap/controller.
+ */
+export async function ensurePnpm({ tools, env, nodeVersion, adapters = {} }) {
+ const processAdapter = adapters.process ?? processCheck;
+ const existing = findExecutable("pnpm", env);
+ if (existing) {
+ provePnpm(existing, [], packageFor(existing), nodeVersion, env, processAdapter);
+ return { env, acquired: false };
+ }
+ const info = stat(tools);
+ if (!info?.isDirectory() || info.isSymbolicLink() || realpathSync(tools) !== resolve(tools) || info.uid !== process.getuid() || (info.mode & 0o077)) {
+ throw new Error("Unsafe private tooling destination");
+ }
+ const destination = join(tools, "pnpm");
+ if (stat(destination)) throw new Error("Conflicting pnpm destination");
+ const tar = findExecutable("tar", env);
+ if (!tar) throw new Error("Required utility missing: tar");
+ const stage = mkdtempSync(join(tools, "pnpm-stage-"));
+ let published = false;
+ try {
+ const archive = join(stage, "pnpm.tgz");
+ writeFileSync(archive, await verifiedDownload("pnpm", adapters), { flag: "wx", mode: 0o600 });
+ const names = processAdapter(tar, ["-tzf", archive], env).split("\n");
+ if (!names.length || names.some((name) => !name.startsWith("package/") || name.split("/").includes("..") || name.includes("\\"))) {
+ throw new Error("Unsafe pnpm archive paths");
+ }
+ const types = processAdapter(tar, ["-tvzf", archive], env).split("\n");
+ if (types.some((line) => !/^[d-]/.test(line))) throw new Error("Unsafe pnpm archive links");
+ processAdapter(tar, ["-xzf", archive, "-C", stage], env);
+ const pkg = join(stage, "package");
+ const entry = join(pkg, "bin/pnpm.mjs");
+ if (!regular(entry) || !regular(join(pkg, "package.json"))) throw new Error("pnpm archive entry missing");
+ const metadata = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8"));
+ if (metadata.name !== "pnpm" || metadata.version !== pnpm.version || metadata.engines?.node !== pnpm.engine) throw new Error("pnpm pin metadata rejected");
+ provePnpm(process.execPath, [entry], metadata, nodeVersion, env, processAdapter);
+ // mkdir is the atomic no-clobber claim (rename alone can replace directories).
+ mkdirSync(destination, { mode: 0o700 });
+ published = true;
+ renameSync(pkg, join(destination, "package"));
+ mkdirSync(join(destination, "bin"), { mode: 0o700 });
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
+ writeFileSync(join(destination, "bin/pnpm"), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(join(destination, "package/bin/pnpm.mjs"))} "$@"\n`, { flag: "wx", mode: 0o700 });
+ return { acquired: true, env: { ...env, PATH: `${join(destination, "bin")}${delimiter}${env.PATH}` } };
+ } catch {
+ if (published) rmSync(destination, { recursive: true, force: true });
+ throw new Error("pnpm verified acquisition failed; no installation completed");
+ } finally {
+ rmSync(stage, { recursive: true, force: true });
+ }
+}
+
+/** No shell command or URL from the caller is executed: entry is fixed in bundle. */
+export async function launchWizard({ bundle, env }) {
+ const entry = join(bundle, "bin/gentle-shell-install.mjs");
+ if (!regular(entry)) throw new Error("Future wizard entry is missing; no live wizard is available");
+ await new Promise((resolveChild, reject) => {
+ const child = spawn(process.execPath, [entry], { env, stdio: "inherit", shell: false });
+ child.once("error", () => reject(new Error("Wizard child could not start")));
+ child.once("exit", (code) => code === 0 ? resolveChild() : reject(new Error("Wizard child failed")));
+ });
+}
+
+/** `env` and transport/process adapters are injectable for local fixtures only. */
+export async function bootstrap(bundle, suppliedTools, { env = process.env, adapters = {} } = {}) {
+ const entry = join(bundle, "bin/gentle-shell-install.mjs");
+ if (!regular(entry)) throw new Error("Future wizard entry is missing");
+ const metadata = JSON.parse(readFileSync(join(bundle, "package.json"), "utf8"));
+ if (!compatibleEngine(metadata.engines?.node, process.versions.node)) throw new Error("Node requirement rejected");
+ if (metadata.packageManager !== `pnpm@${pnpm.version}`) throw new Error("pnpm acquisition pin differs from repository; update verified descriptors first");
+ let tools = suppliedTools;
+ let created = false;
+ try {
+ if (!findExecutable("pnpm", env) && !tools) { tools = privateTools(env.HOME); created = true; }
+ const result = await ensurePnpm({ tools, env, nodeVersion: process.versions.node, adapters });
+ await launchWizard({ bundle, env: result.env });
+ } catch (error) {
+ if (created) rmSync(tools, { recursive: true, force: true });
+ throw error;
+ }
+ // Exit 0 means pnpm now persists under $PNPM_HOME or was already the user's.
+ if (created && !removeOwnedTools(tools, env.HOME)) {
+ console.error(`Bootstrap: installation finished, but temporary tools could not be removed: ${tools}`);
+ }
+}
+/** The Windows helper failure line appends only a fixed-shape reason code. Any
+ * other value, including a failed module import, reports `unexpected-helper`.
+ */
+export function windowsBootstrapMessage(error) {
+ const reason = typeof error?.reason === "string" && /^(?:[a-z][a-z-]* \([a-z][a-z-]*\)|unexpected-[a-z][a-z-]*)$/.test(error.reason) ? error.reason : "unexpected-helper";
+ return `Windows bootstrap failed; policy, prerequisite or bundle evidence rejected. No installation completed. Reason: ${reason}`;
+}
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ if (process.argv.length === 5 && process.argv[2] === "--bootstrap-windows") {
+ import("./installer-windows.mjs").then(({ bootstrapWindows }) => bootstrapWindows({ bundle: process.argv[3], tools: process.argv[4], env: process.env })).catch((error) => {
+ console.error(windowsBootstrapMessage(error));
+ process.exitCode = 1;
+ });
+ } else if (process.argv.length !== 5 || process.argv[2] !== "--bootstrap") {
+ console.error("Use the bundle's POSIX bootstrap script");
+ process.exitCode = 1;
+ } else {
+ bootstrap(process.argv[3], process.argv[4]).catch((error) => {
+ // Only controlled errors are emitted; never print URL/process adapter errors.
+ const safe = /^(Future wizard|Node requirement|pnpm |Existing pnpm|Unknown prerequisite|Unsafe tooling|Wizard child|Prerequisite process|Required utility)/.test(error.message);
+ console.error(safe ? error.message : "Prerequisite bootstrap failed");
+ process.exitCode = 1;
+ });
+ }
+}
diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs
new file mode 100644
index 000000000..7e4e8d4b2
--- /dev/null
+++ b/scripts/installer-preflight.mjs
@@ -0,0 +1,193 @@
+import { readFileSync } from "node:fs";
+import { posix, win32 } from "node:path";
+import {
+ INSTALLER_VERSION,
+ GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION,
+} from "./gentle-ai-installer.mjs";
+
+// Read package metadata only: never import the launcher or execute postinstall.
+const metadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+function minimum(range) {
+ if (!/^>=\d+\.\d+\.\d+$/.test(range)) throw new Error("Unsupported preflight requirement range");
+ return range.slice(2);
+}
+export const requirements = Object.freeze({
+ node: minimum(metadata.engines.node),
+ pi: minimum(metadata.peerDependencies["@earendil-works/pi-coding-agent"]),
+ pnpm: metadata.packageManager.replace(/^pnpm@/, ""),
+ shell: metadata.version,
+ gentleAi: INSTALLER_VERSION,
+ go: GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION,
+});
+
+/** Runtime persisted under PNPM_HOME. Bootstrap-only Node: `pnpm runtime set
+ * node -g`, then `pnpm add -g npm@ pnpm@`. Persistent Node:
+ * one `pnpm add -g` of only the missing npm and/or bootstrap-only pnpm.
+ * npm 11.19.0 is the npm bundled with Node 24.21.0.
+ */
+export const persistencePins = Object.freeze({ node: "24.21.0", npm: "11.19.0", pnpm: requirements.pnpm });
+
+/** pnpm 11 global bin directory: `$PNPM_HOME/bin`, not `$PNPM_HOME` itself.
+ * PNPM_HOME is the user's existing absolute value, otherwise pnpm's documented
+ * platform default. onPath compares PATH entries with that bin directory, so a
+ * globalBin probe must report this path. Returns null when it is unknowable.
+ */
+export function pnpmGlobalBin({ platform, env }) {
+ const path = platform === "win32" ? win32 : posix;
+ const absolute = (value) => typeof value === "string" && value.length > 0 && path.isAbsolute(value);
+ let pnpmHome = env.PNPM_HOME;
+ if (pnpmHome !== undefined) {
+ if (!absolute(pnpmHome)) return null;
+ } else if (platform === "win32") {
+ if (!absolute(env.LOCALAPPDATA)) return null;
+ pnpmHome = path.join(env.LOCALAPPDATA, "pnpm");
+ } else {
+ if (!absolute(env.HOME)) return null;
+ if (platform === "darwin") pnpmHome = path.join(env.HOME, "Library", "pnpm");
+ else pnpmHome = path.join(absolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(env.HOME, ".local", "share"), "pnpm");
+ }
+ pnpmHome = path.normalize(pnpmHome).replace(/(.)[\\/]+$/, "$1");
+ const bin = path.join(pnpmHome, "bin");
+ const comparable = (value) => {
+ const normal = path.normalize(value).replace(/(.)[\\/]+$/, "$1");
+ return platform === "win32" ? normal.toLowerCase() : normal;
+ };
+ const pathKey = platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") : "PATH";
+ const entries = String((pathKey && env[pathKey]) ?? "").split(path.delimiter).filter(absolute);
+ return { pnpmHome, path: bin, onPath: entries.some((entry) => comparable(entry) === comparable(bin)) };
+}
+
+const probeNames = ["node", "pnpm", "pi", "shell", "gentleAi", "go", "globalBin", "setup"];
+
+/** Collect with caller-owned read-only probes; no default process/home adapters.
+ * A tool probe returns { available, version, usable }; missing is available:false.
+ * pnpm also needs compatible:true (runtime/capability evidence), shell global:true,
+ * and Gentle AI compatible:true (normal binary resolver/integrity evidence).
+ * Node may add persistent (resolvable from the user's PATH without bootstrap
+ * tool directories) and npm (a genuine npm resolves there) booleans; pnpm may
+ * add persistent (resolvable from the user's PATH). globalBin returns { available, path, writable, onPath } for
+ * pnpmGlobalBin's `$PNPM_HOME/bin` directory; setup returns boolean, or
+ * { available: true, recoverable: true } for the pinned stack this pnpm installed
+ * whose setup did not finish (only its setup is then planned).
+ * Version values are exact stable versions, not raw arbitrary command output.
+ */
+export async function collectInventory({ platform, arch, probes = {} }) {
+ const inventory = { platform, arch };
+ for (const name of probeNames) {
+ try {
+ inventory[name] = probes[name] ? await probes[name]() : { available: null };
+ } catch {
+ // Do not retain probe errors: they can contain private paths or credentials.
+ inventory[name] = { available: null };
+ }
+ }
+ return inventory;
+}
+
+function versionParts(version) {
+ if (typeof version !== "string" || !/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) return null;
+ const parts = version.replace(/^v/, "").split(".").map(Number);
+ return parts.every(Number.isSafeInteger) ? parts : null;
+}
+function compareVersions(left, right) {
+ for (let i = 0; i < 3; i += 1) {
+ if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1;
+ }
+ return 0;
+}
+if (compareVersions(versionParts(persistencePins.node), versionParts(requirements.node)) < 0) {
+ throw new Error("Persistent Node pin is below the repository minimum");
+}
+function classify(observation, required, extraCheck = () => true, exact = false) {
+ if (observation?.available === false) return "unavailable";
+ if (observation?.available !== true) return "unknown";
+ const version = versionParts(observation.version);
+ if (!version || observation.usable !== true) return "unknown";
+ const order = compareVersions(version, versionParts(required));
+ if (order < 0 || (exact && order !== 0)) return "incompatible";
+ const extra = extraCheck(observation);
+ if (extra === false) return "incompatible";
+ return extra === true ? "reusable" : "unknown";
+}
+
+/** Pure ordered intent plan, not executable commands or a readiness certificate.
+ * Blocked plans contain no actions: never overwrite an incompatible/unknown tool.
+ * Compatible newer Node/Pi/Shell versions remain owned by their existing install.
+ */
+export function planPreflight(inventory) {
+ const tools = {};
+ const blockers = [];
+ const actions = [];
+ const { platform, arch } = inventory;
+ const supportedTarget = ["linux", "darwin", "win32"].includes(platform) && ["x64", "arm64"].includes(arch);
+ if (!supportedTarget) {
+ return { tools, blockers: [{ code: "unsupported-target", tool: "target" }], actions, ready: false };
+ }
+ function record(name, status, required) {
+ tools[name] = { status, ...(required ? { required } : {}) };
+ if (status === "unknown" || status === "incompatible") {
+ blockers.push({ code: `${status}-tool`, tool: name });
+ }
+ }
+ for (const name of ["node", "pi", "shell"]) {
+ record(name, classify(inventory[name], requirements[name], name === "shell" ? (o) => o.global : undefined), requirements[name]);
+ }
+ // packageManager pins the acquisition choice, not a minimum supported pnpm.
+ // Existing versions require explicit engine/capability evidence from the probe.
+ record("pnpm", classify(inventory.pnpm, "0.0.0", (o) => o.compatible), requirements.pnpm);
+ record("gentleAi", classify(inventory.gentleAi, requirements.gentleAi, (o) => o.compatible, true), requirements.gentleAi);
+ const needsNative = tools.gentleAi.status === "unavailable";
+ record("go", platform === "win32" && needsNative ? classify(inventory.go, requirements.go) : "not-required", requirements.go);
+ const bin = inventory.globalBin;
+ const binKnown = bin?.available === true && typeof bin.path === "string" && bin.path.trim().length > 0 &&
+ bin.writable === true && typeof bin.onPath === "boolean";
+ record("globalBin", bin?.available === false ? "unavailable" : binKnown ? (bin.onPath ? "reusable" : "needs-setup") : "unknown");
+ const missingShell = tools.shell.status === "unavailable";
+ // Setup recovery: the setup probe proved the pinned stack this pnpm installed
+ // (an earlier run stopped in setup), so only the public setup is rerun.
+ const recovering = inventory.setup?.available === true && inventory.setup.recoverable === true &&
+ ["pi", "shell", "gentleAi"].every((name) => tools[name].status === "reusable");
+ let setupStatus = "unknown";
+ if (missingShell || needsNative || inventory.setup === false || recovering) setupStatus = "needs-setup";
+ else if (inventory.setup === true) setupStatus = "reusable";
+ record("setup", setupStatus);
+ if (blockers.length) return { tools, blockers, actions, ready: false };
+
+ function action(id, kind, target, version) {
+ actions.push({ id, kind, target, ...(version ? { version } : {}) });
+ }
+ function acquire(name) {
+ if (tools[name].status !== "unavailable") return;
+ action(`acquire-${name}`, "acquire", name, requirements[name]);
+ action(`verify-${name}`, "verify", name);
+ }
+ acquire("node");
+ acquire("pnpm");
+ if (tools.globalBin.status !== "reusable") action("setup-global-bin", "setup", "globalBin");
+ // A bootstrap Node lives in a temporary tools directory, and Gentle AI's Engram
+ // step needs a genuine npm: persist what is missing under PNPM_HOME through
+ // pnpm itself. A persistent Node is never replaced or shadowed. A recovery
+ // never persists: the earlier run did that before installing the stack.
+ const node = inventory.node;
+ const persistable = tools.node.status === "reusable" && !recovering;
+ if (persistable && node.persistent === false) {
+ action("persist-node", "persist-runtime", "node", persistencePins.node);
+ action("persist-package-managers", "install-global", "package-managers");
+ action("configure-npm-prefix", "configure", "npm-prefix");
+ } else if (persistable) {
+ const npm = node.npm === false;
+ const pnpm = inventory.pnpm?.persistent === false;
+ if (npm && pnpm) action("persist-package-managers", "install-global", "package-managers");
+ else if (npm) action("persist-npm", "install-global", "npm", persistencePins.npm);
+ else if (pnpm) action("persist-pnpm", "install-global", "pnpm", persistencePins.pnpm);
+ }
+ acquire("go");
+ if (tools.pi.status === "unavailable") action("install-pi", "install-global", "pi", requirements.pi);
+ if (missingShell) action("install-shell", "install-global", "shell", requirements.shell);
+ // Global gentle-pi postinstall owns native provisioning when Shell is missing.
+ // Otherwise the later runner must reuse that existing installer, not duplicate it.
+ if (needsNative && !missingShell) action("provision-native", "existing-installer", "gentleAi", requirements.gentleAi);
+ if (tools.setup.status !== "reusable") action("setup-shell", "normal-setup", "shell");
+ action("verify-readiness", "verify", "stack");
+ return { tools, blockers, actions, ready: actions.length === 1 };
+}
diff --git a/scripts/installer-probes.mjs b/scripts/installer-probes.mjs
new file mode 100644
index 000000000..2f9b92a68
--- /dev/null
+++ b/scripts/installer-probes.mjs
@@ -0,0 +1,323 @@
+import { spawn as spawnProcess } from "node:child_process";
+import { constants } from "node:fs";
+import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
+import { posix, win32 } from "node:path";
+import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs";
+import { pnpmGlobalBin, requirements } from "./installer-preflight.mjs";
+import {
+ PI_PACKAGE,
+ SHELL_PACKAGE,
+ childEnvironment,
+ contains,
+ genuineNpm,
+ lookPath,
+ packageNativeGentleAi,
+ pnpmInvocation,
+ recoverableStackRoot,
+ spawnable,
+ succeeded,
+} from "./installer-runner.mjs";
+
+// Real host probes for collectInventory. Every effect goes through injected
+// adapters; probes only run fixed read-only argv (`--version`, `go version`,
+// `pnpm list -g`) and never write, create directories or run setup/postinstall.
+
+const SECOND = 1000;
+const deadlines = Object.freeze({ version: 10 * SECOND, list: 30 * SECOND });
+const BOOTSTRAP_TOOLS = /^\.gentle-shell-bootstrap-tools\./;
+const PNPM_MAJOR = Number(requirements.pnpm.split(".")[0]);
+
+const unknown = () => ({ available: null });
+const absent = () => ({ available: false });
+
+function pathOf(platform) {
+ return platform === "win32" ? win32 : posix;
+}
+function pathKey(env, platform) {
+ return platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "Path" : "PATH";
+}
+function envValue(env, name, platform) {
+ const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name;
+ return key === undefined ? undefined : env[key];
+}
+function plainObject(value) {
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
+}
+function exactVersion(text, pattern) {
+ const match = pattern.exec(String(text ?? "").trim());
+ return match ? `${match[1]}.${match[2]}.${match[3] ?? "0"}` : null;
+}
+const NUMBER = "(0|[1-9]\\d*)";
+const STABLE = new RegExp(`^${NUMBER}\\.${NUMBER}\\.${NUMBER}$`);
+const NODE_VERSION = new RegExp(`^v${NUMBER}\\.${NUMBER}\\.${NUMBER}$`);
+const GO_VERSION = new RegExp(`^go version go${NUMBER}\\.${NUMBER}(?:\\.${NUMBER})? \\S+$`);
+function atLeast(version, minimum) {
+ const [left, right] = [version, minimum].map((value) => value.split(".").map(Number));
+ for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] > right[i];
+ return true;
+}
+
+function normalDirectory(path, value) {
+ return path.normalize(value).replace(/(.)[\\/]+$/, "$1");
+}
+function sameDirectory(platform, left, right) {
+ const path = pathOf(platform);
+ const [a, b] = [normalDirectory(path, left), normalDirectory(path, right)];
+ return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
+}
+
+/** Bootstrap tool roots: `GENTLE_BOOTSTRAP_TOOLS` and every PATH entry segment
+ * named `.gentle-shell-bootstrap-tools.*` (the bootstrap's temporary directory).
+ */
+export function bootstrapRoots({ platform, env }) {
+ const path = pathOf(platform);
+ const roots = [];
+ const add = (root) => {
+ const normal = normalDirectory(path, root);
+ if (!roots.some((other) => sameDirectory(platform, other, normal))) roots.push(normal);
+ };
+ const tools = envValue(env, "GENTLE_BOOTSTRAP_TOOLS", platform);
+ if (typeof tools === "string" && path.isAbsolute(tools)) add(tools);
+ const marker = platform === "win32" ? new RegExp(BOOTSTRAP_TOOLS.source, "i") : BOOTSTRAP_TOOLS;
+ for (const entry of String(env[pathKey(env, platform)] ?? "").split(path.delimiter)) {
+ if (!path.isAbsolute(entry)) continue;
+ const parts = path.normalize(entry).split(path.sep);
+ const index = parts.findIndex((part) => marker.test(part));
+ if (index > 0) add(parts.slice(0, index + 1).join(path.sep) || path.sep);
+ }
+ return roots;
+}
+
+/** The user's own environment: PATH without bootstrap tool directories. */
+export function userEnvironment({ platform, env }) {
+ const path = pathOf(platform);
+ const roots = bootstrapRoots({ platform, env });
+ const key = pathKey(env, platform);
+ if (env[key] === undefined) return { ...env };
+ const own = (entry) => !roots.some((root) => sameDirectory(platform, root, entry) || contains(root, entry, platform));
+ return { ...env, [key]: String(env[key]).split(path.delimiter).filter((entry) => entry.length > 0 && own(entry)).join(path.delimiter) };
+}
+
+/** Real adapters: argv spawn without a shell, SIGKILL at the deadline, bounded
+ * stdout (stderr discarded: it can hold private paths) and read-only fs checks.
+ * Only a caller that passes `stderrTail: ` (a positive integer, clamped
+ * to 4 KiB) gets stderr piped; the result then carries just its last bytes as
+ * `stderrTail`. Callers must sanitize that text before showing it anywhere.
+ * The deadline signals the direct child only, not its descendants, and settles
+ * the result as timed out without waiting for the child's pipes to close.
+ * `spawn` is injectable only for trusted tests.
+ */
+export function hostAdapters({ maxOutputBytes = 1024 * 1024, maxTextBytes = 1024 * 1024, spawn = spawnProcess } = {}) {
+ const run = (command, argv, { env, deadlineMs, stderrTail }) => new Promise((resolve) => {
+ let size = 0;
+ let truncated = false;
+ let timedOut = false;
+ const chunks = [];
+ const tailBytes = Number.isSafeInteger(stderrTail) && stderrTail > 0 ? Math.min(stderrTail, 4096) : 0;
+ let tail = Buffer.alloc(0);
+ const withTail = (result) => (tailBytes > 0 ? { ...result, stderrTail: tail.toString("utf8") } : result);
+ let child;
+ try {
+ child = spawn(command, argv, { env, shell: false, stdio: ["ignore", "pipe", tailBytes > 0 ? "pipe" : "ignore"], windowsHide: true });
+ } catch {
+ resolve(withTail({ code: null, signal: null, timedOut: false, truncated: false, stdout: "" }));
+ return;
+ }
+ let settled = false;
+ const settle = (code, signal) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(withTail({ code, signal, timedOut, truncated, stdout: Buffer.concat(chunks).toString("utf8") }));
+ };
+ // Keep only the last bytes: a long setup log never accumulates in memory.
+ if (tailBytes > 0) child.stderr?.on("data", (chunk) => {
+ const joined = Buffer.concat([tail, chunk]);
+ tail = joined.subarray(Math.max(0, joined.length - tailBytes));
+ });
+ // A descendant can keep stdout open after the kill, so `close` may never come:
+ // settle at the deadline and release the pipes instead of waiting for it.
+ const timer = setTimeout(() => {
+ timedOut = true;
+ child.kill("SIGKILL");
+ for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy();
+ settle(null, "SIGKILL");
+ }, deadlineMs);
+ child.stdout.on("data", (chunk) => {
+ const room = maxOutputBytes - size;
+ if (chunk.length > room) truncated = true;
+ if (room > 0) {
+ chunks.push(chunk.subarray(0, room));
+ size += Math.min(room, chunk.length);
+ }
+ });
+ child.once("error", () => settle(null, null));
+ child.once("close", settle);
+ });
+ const info = (path, follow = true) => (follow ? stat(path) : lstat(path)).catch((error) => error);
+ const fs = {
+ isFile: async (path) => (await stat(path).catch(() => null))?.isFile() === true,
+ isDirectory: async (path) => (await stat(path).catch(() => null))?.isDirectory() === true,
+ // Only a missing entry is absent; an unreadable one exists (and is then not a writable directory).
+ exists: async (path) => {
+ const result = await info(path, false);
+ return !(result instanceof Error) || !["ENOENT", "ENOTDIR"].includes(result.code);
+ },
+ realpath: (path) => realpath(path),
+ readText: async (path) => {
+ const result = await stat(path);
+ if (!result.isFile() || result.size > maxTextBytes) throw new Error("Unreadable probe file");
+ return readFile(path, "utf8");
+ },
+ writable: (path) => access(path, constants.W_OK).then(() => true, () => false),
+ };
+ return { run, fs };
+}
+
+/**
+ * createProbes({ platform, env, run, fs, home?, verifyGentleAi? }) -> the eight
+ * named collectInventory probes. `env` is the wizard's environment (bootstrap
+ * tools first on PATH); `run` and `fs` follow hostAdapters. Each probe returns
+ * the shape collectInventory documents, or { available: null } when unknown or
+ * failed; errors are never thrown or retained.
+ */
+export function createProbes({ platform, env, run, fs, home, verifyGentleAi = packageNativeGentleAi }) {
+ const path = pathOf(platform);
+ const user = userEnvironment({ platform, env });
+ const globalBin = pnpmGlobalBin({ platform, env: user });
+ // pnpm 11 global commands need `$PNPM_HOME/bin` on PATH, as in the runner.
+ const child = globalBin ? childEnvironment(env, platform, globalBin) : env;
+ const userChild = globalBin ? childEnvironment(user, platform, globalBin) : user;
+ const userHome = home ?? (platform === "win32" ? env.USERPROFILE : env.HOME);
+ const output = async (command, argv, runEnv, deadlineMs) => {
+ const result = await run(command, argv, { env: runEnv, deadlineMs });
+ return succeeded(result) && result.truncated !== true ? String(result.stdout ?? "").trim() : null;
+ };
+ const persistentOn = async (name) => (await lookPath(name, user, platform, fs)) !== null;
+
+ let listing;
+ /** Output of the single `list -g` call, or null when it is unavailable. */
+ const globalListing = () => (listing ??= (async () => {
+ const pnpm = globalBin ? await pnpmInvocation(env, platform, fs) : null;
+ if (!pnpm) return null;
+ return output(pnpm.command, [...pnpm.prefix, "list", "-g", "--depth", "0", "--json"], child, deadlines.list);
+ })());
+ /** pnpm-global packages by name from that listing; null entries are ambiguous. */
+ const globalPackages = async () => {
+ const stdout = await globalListing();
+ if (stdout === null) return null;
+ const projects = JSON.parse(stdout);
+ if (!Array.isArray(projects)) return null;
+ const packages = new Map();
+ for (const project of projects) {
+ if (!plainObject(project)) return null;
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
+ const dependencies = project[field];
+ if (dependencies === undefined) continue;
+ if (!plainObject(dependencies)) return null;
+ for (const [name, entry] of Object.entries(dependencies)) {
+ packages.set(name, packages.has(name) || !plainObject(entry) ? null : entry);
+ }
+ }
+ }
+ return packages;
+ };
+ /** A package that is not pnpm-global but whose command resolves elsewhere is unknown, never absent. */
+ const globalPackage = async (name, command) => {
+ const packages = await globalPackages();
+ if (!packages) return { state: "unknown" };
+ if (!packages.has(name)) return { state: (await persistentOn(command)) ? "unknown" : "absent" };
+ const entry = packages.get(name);
+ const version = exactVersion(entry?.version, STABLE);
+ return entry && version ? { state: "present", entry, version } : { state: "unknown" };
+ };
+ const shellBin = () => path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell");
+
+ const probes = {
+ async node() {
+ const persistent = await lookPath("node", user, platform, fs);
+ const node = persistent ?? await lookPath("node", env, platform, fs);
+ if (!node) return absent();
+ if (!spawnable(node, platform)) return unknown();
+ const version = exactVersion(await output(node, ["--version"], env, deadlines.version), NODE_VERSION);
+ if (!version) return unknown();
+ // A genuine npm must resolve without bootstrap tools, as in a fresh terminal.
+ let npm = null;
+ try {
+ npm = typeof (await genuineNpm(userChild, platform, node, { run, fs }, globalBin)) === "object";
+ } catch {
+ npm = null;
+ }
+ return { available: true, version, usable: true, persistent: persistent !== null, npm };
+ },
+ async pnpm() {
+ const pnpm = await pnpmInvocation(env, platform, fs);
+ // Without the direct handoff a Windows .cmd shim cannot run with shell:false.
+ if (!pnpm) return (await lookPath("pnpm", env, platform, fs)) ? unknown() : absent();
+ const version = exactVersion(await output(pnpm.command, [...pnpm.prefix, "--version"], child, deadlines.version), STABLE);
+ if (!version) return unknown();
+ // pnpm checks its Node engine at startup; the runner's argv is verified for pnpm 11 only.
+ const compatible = Number(version.split(".")[0]) === PNPM_MAJOR && atLeast(version, requirements.pnpm);
+ return { available: true, version, usable: true, compatible, persistent: await persistentOn("pnpm") };
+ },
+ async pi() {
+ const pi = await globalPackage(PI_PACKAGE, "pi");
+ if (pi.state !== "present") return pi.state === "absent" ? absent() : unknown();
+ return { available: true, version: pi.version, usable: true };
+ },
+ async shell() {
+ const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell");
+ if (shell.state !== "present") return shell.state === "absent" ? absent() : unknown();
+ return { available: true, version: shell.version, usable: await fs.isFile(shellBin()), global: true };
+ },
+ async gentleAi() {
+ const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell");
+ if (shell.state === "absent") return absent();
+ // Only this package version's native pin is the requirement being checked.
+ if (shell.state !== "present" || shell.version !== requirements.shell) return unknown();
+ if (typeof shell.entry.path !== "string" || !path.isAbsolute(shell.entry.path)) return unknown();
+ const [root, pnpmHome] = [await fs.realpath(shell.entry.path), await fs.realpath(globalBin.pnpmHome)];
+ if (!contains(pnpmHome, root, platform)) return unknown();
+ if (!(await fs.isFile(gentleAiBinaryPath(root, platform)))) return absent();
+ const verdict = await verifyGentleAi({ packageRoot: root, platform, env, home: userHome });
+ return verdict?.ok === true ? { available: true, version: requirements.gentleAi, usable: true, compatible: true } : unknown();
+ },
+ async go() {
+ const go = await lookPath("go", user, platform, fs);
+ if (!go) return absent();
+ if (!spawnable(go, platform)) return unknown();
+ const version = exactVersion(await output(go, ["version"], user, deadlines.version), GO_VERSION);
+ return version ? { available: true, version, usable: true } : unknown();
+ },
+ async globalBin() {
+ if (!globalBin) return unknown();
+ // Nearest existing ancestor (or the directory itself); nothing is created.
+ let current = globalBin.path;
+ while (!(await fs.exists(current))) {
+ const parent = path.dirname(current);
+ if (parent === current) return unknown();
+ current = parent;
+ }
+ const writable = (await fs.isDirectory(current)) && (await fs.writable(current));
+ return { available: true, path: globalBin.path, writable, onPath: globalBin.onPath };
+ },
+ async setup() {
+ // Setup readiness of an existing Shell has no read-only evidence. Only the
+ // pinned stack this pnpm installed (an earlier run whose setup did not
+ // finish) is recoverable: its public `gentle-shell setup` may be rerun.
+ const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell");
+ if (shell.state === "absent") return false;
+ if (shell.state !== "present") return unknown();
+ const root = await recoverableStackRoot(await globalListing(), globalBin.pnpmHome, platform, fs);
+ return root === null ? unknown() : { available: true, recoverable: true };
+ },
+ };
+ return Object.fromEntries(Object.entries(probes).map(([name, probe]) => [name, async () => {
+ try {
+ return await probe();
+ } catch {
+ // Do not retain probe errors: they can contain private paths or credentials.
+ return unknown();
+ }
+ }]));
+}
diff --git a/scripts/installer-runner.mjs b/scripts/installer-runner.mjs
new file mode 100644
index 000000000..9d60a09bc
--- /dev/null
+++ b/scripts/installer-runner.mjs
@@ -0,0 +1,636 @@
+import { readFileSync } from "node:fs";
+import { posix, win32 } from "node:path";
+import {
+ gentleAiBinaryPath,
+ gentleAiDevBinaryOverrideConfigured,
+ resolveGentleAiBinary,
+} from "../runtime/gentle-ai-binary.mjs";
+import { persistencePins, pnpmGlobalBin, requirements } from "./installer-preflight.mjs";
+
+// Standard installation runner: one fixed, consented global pnpm installation
+// of Pi plus gentle-pi, then the public `gentle-shell setup`. Every adapter is
+// injected by trusted local code; requests carry no commands, URLs, roots or env.
+// When Node is bootstrap-only or npm is not genuine, fixed steps first persist
+// node, npm and pnpm under PNPM_HOME through pnpm itself. When an earlier run
+// installed the pinned stack but stopped in setup, a fixed recovery re-verifies
+// that stack and reruns only `gentle-shell setup` (and `pnpm setup`), never `add -g`.
+
+/** Pi version installed next to gentle-pi (optional peer, resolved in one add). */
+export const PI_INSTALL_VERSION = "1.0.0";
+export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
+export const SHELL_PACKAGE = "gentle-pi";
+
+/** Every `reason` a `blocked` outcome can carry (for host guidance; no behavior). */
+export const blockedReasons = Object.freeze([
+ "invalid-request",
+ "consent-required",
+ "preflight-blocked",
+ "go-required",
+ "unsupported-plan",
+ "pnpm-home-unknown",
+ "node-unavailable",
+ "pnpm-unavailable",
+ "npm-unavailable",
+ "npm-shadowed",
+ "global-bin-mismatch",
+ "global-list-unavailable",
+ "existing-stack",
+ "existing-stack-unverified",
+]);
+/** Every `failedStep` a `failed` outcome can carry (for host guidance; no behavior). */
+export const failedSteps = Object.freeze([
+ "persist-node",
+ "persist-package-managers",
+ "persist-npm",
+ "persist-pnpm",
+ "verify-persistent-runtime",
+ "verify-persistent-pnpm",
+ "check-npm",
+ "configure-npm-prefix",
+ "install-global",
+ "verify-global-list",
+ "verify-shell-bin",
+ "verify-gentle-ai",
+ "shell-setup",
+ "persist-path",
+]);
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const deadlines = Object.freeze({ probe: 30 * SECOND, install: 20 * MINUTE, setup: 20 * MINUTE });
+
+// Exact descriptors planPreflight can emit; anything else is a foreign request.
+const knownActions = Object.freeze({
+ "acquire-node": { kind: "acquire", target: "node", version: requirements.node },
+ "verify-node": { kind: "verify", target: "node" },
+ "acquire-pnpm": { kind: "acquire", target: "pnpm", version: requirements.pnpm },
+ "verify-pnpm": { kind: "verify", target: "pnpm" },
+ "setup-global-bin": { kind: "setup", target: "globalBin" },
+ "persist-node": { kind: "persist-runtime", target: "node", version: persistencePins.node },
+ "persist-package-managers": { kind: "install-global", target: "package-managers" },
+ "configure-npm-prefix": { kind: "configure", target: "npm-prefix" },
+ "persist-npm": { kind: "install-global", target: "npm", version: persistencePins.npm },
+ "persist-pnpm": { kind: "install-global", target: "pnpm", version: persistencePins.pnpm },
+ "acquire-go": { kind: "acquire", target: "go", version: requirements.go },
+ "verify-go": { kind: "verify", target: "go" },
+ "install-pi": { kind: "install-global", target: "pi", version: requirements.pi },
+ "install-shell": { kind: "install-global", target: "shell", version: requirements.shell },
+ "provision-native": { kind: "existing-installer", target: "gentleAi", version: requirements.gentleAi },
+ "setup-shell": { kind: "normal-setup", target: "shell" },
+ "verify-readiness": { kind: "verify", target: "stack" },
+});
+// The clean-stack path: both global packages are missing.
+const requiredActions = ["install-pi", "install-shell", "setup-shell", "verify-readiness"];
+const optionalActions = ["setup-global-bin"];
+// Setup recovery: the pinned stack this pnpm installed is present (planPreflight
+// saw a recoverable setup), so only setup and the optional PATH step remain.
+const recoveryActions = ["setup-shell", "verify-readiness"];
+// Runtime persistence is one of these exact sets planPreflight emits, or nothing:
+// bootstrap-only Node, or a persistent Node missing npm and/or pnpm (one add -g).
+const persistenceVariants = Object.freeze([
+ ["persist-node", "persist-package-managers", "configure-npm-prefix"],
+ ["persist-package-managers"],
+ ["persist-npm"],
+ ["persist-pnpm"],
+]);
+const persistenceActions = [...new Set(persistenceVariants.flat())];
+
+function stable(version) {
+ const match = typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
+ return match ? match.slice(1).map(Number) : null;
+}
+function atLeast(version, minimum) {
+ const [left, right] = [stable(version), stable(minimum)];
+ if (!left || !right) return false;
+ for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] > right[i];
+ return true;
+}
+if (!atLeast(PI_INSTALL_VERSION, requirements.pi)) throw new Error("Pi install pin is below the peer minimum");
+
+function plainObject(value) {
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
+}
+function onlyKeys(value, allowed) {
+ return plainObject(value) && Object.keys(value).every((key) => allowed.includes(key));
+}
+
+/** Accept only `{ plan, consent }` where plan is an unmodified planPreflight result. */
+function validRequest(request) {
+ if (!onlyKeys(request, ["plan", "consent"]) || !onlyKeys(request.plan, ["tools", "blockers", "actions", "ready"])) return false;
+ const { tools, blockers, actions, ready } = request.plan;
+ if (!plainObject(tools) || !Array.isArray(blockers) || !Array.isArray(actions) || typeof ready !== "boolean") return false;
+ const ids = new Set();
+ for (const action of actions) {
+ const expected = knownActions[action?.id];
+ if (!expected || ids.has(action.id) || !onlyKeys(action, ["id", "kind", "target", "version"])) return false;
+ if (Object.keys(action).length !== Object.keys(expected).length + 1) return false;
+ if (Object.entries(expected).some(([key, value]) => action[key] !== value)) return false;
+ ids.add(action.id);
+ }
+ return true;
+}
+
+/** Gates that need no process: returns a blocked reason or null. */
+function planGate(plan, platform) {
+ if (plan.blockers.length > 0) return "preflight-blocked";
+ const ids = plan.actions.map((action) => action.id);
+ const only = (allowed) => ids.every((id) => allowed.includes(id));
+ // All or nothing, like the persistence variants: never part of an installation.
+ const recovery = recoveryActions.every((id) => ids.includes(id)) && only([...recoveryActions, ...optionalActions]);
+ // gentle-pi's postinstall may build Gentle AI from source on Windows; the runner
+ // never acquires Go. A recovery runs no postinstall: its binary is verified.
+ if (platform === "win32" && (ids.includes("acquire-go") || (!recovery && plan.tools.go?.status !== "reusable"))) return "go-required";
+ const persisting = persistenceActions.filter((id) => ids.includes(id));
+ if (persisting.length > 0 && !persistenceVariants.some((variant) =>
+ variant.length === persisting.length && variant.every((id) => persisting.includes(id)))) return "unsupported-plan";
+ const clean = requiredActions.every((id) => ids.includes(id)) && only([...requiredActions, ...optionalActions, ...persistenceActions]);
+ return clean || recovery ? null : "unsupported-plan";
+}
+
+function pathKeyOf(env, platform) {
+ return platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "Path" : "PATH";
+}
+function envValue(env, name, platform) {
+ const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name;
+ return key === undefined ? undefined : env[key];
+}
+
+/** Child env: the user's env plus PNPM_HOME and `$PNPM_HOME/bin` first on PATH. */
+export function childEnvironment(env, platform, globalBin) {
+ const path = platform === "win32" ? win32 : posix;
+ const key = pathKeyOf(env, platform);
+ const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
+ return { ...env, PNPM_HOME: globalBin.pnpmHome, [key]: [globalBin.path, ...rest].join(path.delimiter) };
+}
+
+/** gentle-shell setup gets no bootstrap handoff keys (case-insensitive on Windows). */
+function setupEnvironment(child, platform) {
+ const bootstrap = platform === "win32" ? /^GENTLE_(BOOTSTRAP|INSTALL)_/i : /^GENTLE_(BOOTSTRAP|INSTALL)_/;
+ return Object.fromEntries(Object.entries(child).filter(([key]) => !bootstrap.test(key)));
+}
+
+/** pnpm comes from the bootstrap handoff, or a PATH executable on POSIX only. */
+export async function pnpmInvocation(env, platform, fs) {
+ const path = platform === "win32" ? win32 : posix;
+ const node = env.GENTLE_INSTALL_PNPM_NODE;
+ const entry = env.GENTLE_INSTALL_PNPM_ENTRY;
+ if (node && entry) {
+ return path.isAbsolute(node) && path.isAbsolute(entry) ? { command: node, prefix: [entry] } : null;
+ }
+ // A Windows .cmd shim cannot be spawned with shell:false; require the direct handoff.
+ if (platform === "win32") return null;
+ for (const directory of String(env.PATH ?? "").split(path.delimiter)) {
+ if (!path.isAbsolute(directory)) continue;
+ const candidate = path.join(directory, "pnpm");
+ if (await fs.isFile(candidate)) return { command: candidate, prefix: [] };
+ }
+ return null;
+}
+
+/** First `name` the way Go's exec.LookPath (used by Gentle AI) finds it: each
+ * absolute PATH directory in order and, on Windows, every PATHEXT extension in
+ * PATHEXT order (lowercased, dot-prefixed, default .com/.exe/.bat/.cmd).
+ */
+export async function lookPath(name, env, platform, fs) {
+ const path = platform === "win32" ? win32 : posix;
+ const extensions = platform === "win32"
+ ? String(envValue(env, "PATHEXT", platform) || ".com;.exe;.bat;.cmd").toLowerCase().split(";")
+ .filter((extension) => extension.length > 0).map((extension) => extension.startsWith(".") ? extension : `.${extension}`)
+ : [""];
+ for (const directory of String(env[pathKeyOf(env, platform)] ?? "").split(path.delimiter)) {
+ if (!path.isAbsolute(directory)) continue;
+ for (const extension of extensions) {
+ const candidate = path.join(directory, `${name}${extension}`);
+ if (await fs.isFile(candidate)) return candidate;
+ }
+ }
+ return null;
+}
+
+/** A Windows `.cmd`/`.bat` shim cannot run with shell:false; executables can. */
+export function spawnable(file, platform) {
+ return platform !== "win32" || [".exe", ".com"].includes(win32.extname(file).toLowerCase());
+}
+
+/** `.../node_modules//bin/`: a package's own bin entry. */
+function entryShape(entry, path, name, file) {
+ const packageDir = path.dirname(path.dirname(entry));
+ return path.basename(entry) === file && path.basename(path.dirname(entry)) === "bin" &&
+ path.basename(packageDir) === name && path.basename(path.dirname(packageDir)) === "node_modules";
+}
+
+function escapeRegExp(text) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/** The single `node_modules//bin/` target a pnpm shim names, absolute
+ * or relative to the shim directory (`$basedir/` on POSIX, `%~dp0\` or `%dp0%\`
+ * on Windows).
+ */
+function shimTarget(text, shim, platform, name, file) {
+ const path = platform === "win32" ? win32 : posix;
+ const tail = `node_modules${path.sep}${name}${path.sep}bin${path.sep}${file}`;
+ const pattern = platform === "win32"
+ ? new RegExp(`"(%~?dp0%?\\\\)?([^"%]*\\\\${escapeRegExp(tail)})"`, "gi")
+ : new RegExp(`"(\\$basedir\\/)?([^"$]*\\/${escapeRegExp(tail)})"`, "g");
+ const targets = new Set();
+ for (const [, relative, target] of String(text).matchAll(pattern)) {
+ const resolved = relative ? path.join(path.dirname(shim), target) : target;
+ if (!path.isAbsolute(resolved)) return null;
+ targets.add(path.normalize(resolved));
+ }
+ return targets.size === 1 ? [...targets][0] : null;
+}
+
+/** The package entry a pnpm global shim in `$PNPM_HOME/bin` runs: a POSIX
+ * symlink to it, or the single target named in the shim text. Either way its
+ * realpath must be a `` bin entry inside PNPM_HOME. Returns it or null.
+ */
+async function globalShimEntry(shim, name, file, platform, globalBin, fs) {
+ const path = platform === "win32" ? win32 : posix;
+ let entry = platform === "win32" ? null : await fs.realpath(shim);
+ if (entry === null || !entryShape(entry, path, name, file)) {
+ const target = shimTarget(await fs.readText(shim), shim, platform, name, file);
+ if (!target) return null;
+ entry = await fs.realpath(target);
+ }
+ if (!entryShape(entry, path, name, file)) return null;
+ return contains(await fs.realpath(globalBin.pnpmHome), entry, platform) ? entry : null;
+}
+
+/** The entry's package.json names `name` at `version` (any stable version when
+ * null), and `node --version` prints that version.
+ */
+async function runsAsPackage(entry, name, version, nodePath, child, platform, adapters) {
+ const path = platform === "win32" ? win32 : posix;
+ if (!(await adapters.fs.isFile(entry))) return false;
+ const metadata = JSON.parse(await adapters.fs.readText(path.join(path.dirname(path.dirname(entry)), "package.json")));
+ if (metadata?.name !== name || !stable(metadata.version) || (version !== null && metadata.version !== version)) return false;
+ const result = await adapters.run(nodePath, [entry, "--version"], { env: child, deadlineMs: deadlines.probe });
+ return succeeded(result) && String(result.stdout ?? "").trim() === metadata.version;
+}
+
+function inGlobalBin(file, platform, globalBin) {
+ const path = platform === "win32" ? win32 : posix;
+ return globalBin !== null && samePath(path.dirname(file), globalBin.path, platform);
+}
+
+/** Genuine npm: the first resolved npm is the npm package's own CLI, which then runs.
+ * Accepted: a Node-bundled npm (POSIX symlink target or Windows `npm.cmd` with
+ * `node_modules/npm` beside it), or, for any npm in `$PNPM_HOME/bin` (shim or
+ * symlink), pnpm's global npm resolving inside PNPM_HOME at the persistence pin.
+ * Returns { cli }, "npm-shadowed" (Windows: an earlier non-.cmd npm wins) or false.
+ */
+export async function genuineNpm(child, platform, nodePath, adapters, globalBin = null) {
+ const path = platform === "win32" ? win32 : posix;
+ const { fs } = adapters;
+ const first = await lookPath("npm", child, platform, fs);
+ if (!first) return false;
+ if (platform === "win32" && path.extname(first).toLowerCase() !== ".cmd") return "npm-shadowed";
+ let cli;
+ let pinned = null;
+ if (inGlobalBin(first, platform, globalBin)) {
+ cli = await globalShimEntry(first, "npm", "npm-cli.js", platform, globalBin, fs);
+ if (!cli) return false;
+ pinned = persistencePins.npm;
+ } else if (platform === "win32") {
+ cli = path.join(path.dirname(first), "node_modules", "npm", "bin", "npm-cli.js");
+ } else {
+ cli = await fs.realpath(first);
+ }
+ if (!entryShape(cli, path, "npm", "npm-cli.js")) return false;
+ return (await runsAsPackage(cli, "npm", pinned, nodePath, child, platform, adapters)) ? { cli } : false;
+}
+
+/** After adding pnpm, the first pnpm in the child env is pnpm's global shim in
+ * `$PNPM_HOME/bin` for pnpm at the persistence pin, which then runs.
+ */
+async function persistentPnpm(child, platform, nodePath, globalBin, adapters) {
+ const path = platform === "win32" ? win32 : posix;
+ const first = await lookPath("pnpm", child, platform, adapters.fs);
+ if (!first || !inGlobalBin(first, platform, globalBin)) return false;
+ if (platform === "win32" && path.extname(first).toLowerCase() !== ".cmd") return false;
+ const entry = await globalShimEntry(first, "pnpm", "pnpm.mjs", platform, globalBin, adapters.fs);
+ return entry !== null && runsAsPackage(entry, "pnpm", persistencePins.pnpm, nodePath, child, platform, adapters);
+}
+
+export function succeeded(result) {
+ return result?.code === 0 && !result.signal && result.timedOut !== true;
+}
+
+export function samePath(left, right, platform) {
+ const path = platform === "win32" ? win32 : posix;
+ const normal = (value) => path.normalize(value).replace(/(.)[\\/]+$/, "$1");
+ const [a, b] = [normal(left), normal(right)];
+ return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
+}
+
+/** True when `child` is strictly inside `parent` (case-insensitive on Windows). */
+export function contains(parent, child, platform) {
+ const path = platform === "win32" ? win32 : posix;
+ const inside = path.relative(parent, child);
+ return inside.length > 0 && !inside.startsWith("..") && !path.isAbsolute(inside);
+}
+
+/** How many times Pi and gentle-pi are listed across every project and
+ * dependency field of a `list -g --json` output, or null when the shape is unknown.
+ */
+function stackListings(stdout) {
+ const projects = JSON.parse(stdout);
+ if (!Array.isArray(projects)) return null;
+ let listed = 0;
+ for (const project of projects) {
+ if (!plainObject(project)) return null;
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
+ const dependencies = project[field];
+ if (dependencies === undefined) continue;
+ if (!plainObject(dependencies)) return null;
+ listed += [PI_PACKAGE, SHELL_PACKAGE].filter((name) => Object.hasOwn(dependencies, name)).length;
+ }
+ }
+ return listed;
+}
+
+/** Pre-install `list -g --json`: true when neither package is listed in any
+ * project, "existing-stack" when one is, false when the shape is unknown.
+ */
+function noExistingStack(stdout) {
+ const listed = stackListings(stdout);
+ if (listed === null) return false;
+ return listed === 0 ? true : "existing-stack";
+}
+
+/** Installed gentle-pi root from `list -g --json`, confined under PNPM_HOME.
+ * Exactly one listed project may own gentle-pi, and Pi must resolve beside it;
+ * other projects (such as the persisted npm and pnpm) are ignored.
+ */
+async function verifiedPackageRoot(stdout, pnpmHome, platform, fs) {
+ const path = platform === "win32" ? win32 : posix;
+ const projects = JSON.parse(stdout);
+ if (!Array.isArray(projects)) return null;
+ const owners = projects.filter((project) => plainObject(project?.dependencies) && Object.hasOwn(project.dependencies, SHELL_PACKAGE));
+ const dependencies = owners.length === 1 ? owners[0].dependencies : null;
+ if (!plainObject(dependencies)) return null;
+ const pi = dependencies[PI_PACKAGE];
+ const shell = dependencies[SHELL_PACKAGE];
+ if (pi?.version !== PI_INSTALL_VERSION || shell?.version !== requirements.shell) return null;
+ if (typeof shell.path !== "string" || !path.isAbsolute(shell.path)) return null;
+ const [root, home] = [await fs.realpath(shell.path), await fs.realpath(pnpmHome)];
+ return contains(home, root, platform) ? root : null;
+}
+
+/** A stack this pnpm installed whose setup may only be rerun (setup recovery):
+ * Pi and gentle-pi are each listed exactly once, in the single project that
+ * verifiedPackageRoot accepts (Pi at PI_INSTALL_VERSION, gentle-pi at this
+ * package version, realpath confined under PNPM_HOME). Returns the root or null.
+ */
+export async function recoverableStackRoot(stdout, pnpmHome, platform, fs) {
+ return stackListings(stdout) === 2 ? verifiedPackageRoot(stdout, pnpmHome, platform, fs) : null;
+}
+
+/** After persistence, node and npm must resolve from `$PNPM_HOME/bin` in the
+ * child env and that node must report the pinned version. Returns its path.
+ */
+async function persistentRuntime(child, platform, globalBin, adapters) {
+ const path = platform === "win32" ? win32 : posix;
+ const node = await lookPath("node", child, platform, adapters.fs);
+ const npm = await lookPath("npm", child, platform, adapters.fs);
+ const fromBin = (file) => file !== null && samePath(path.dirname(file), globalBin.path, platform);
+ if (!fromBin(node) || !fromBin(npm) || !spawnable(node, platform)) return null;
+ const result = await adapters.run(node, ["--version"], { env: child, deadlineMs: deadlines.probe });
+ return succeeded(result) && String(result.stdout ?? "").trim() === `v${persistencePins.node}` ? node : null;
+}
+
+/** npm's prefix setting from the environment, which npm reads case-insensitively. */
+function explicitNpmPrefix(env, platform) {
+ return Object.keys(env).some((key) => key.toLowerCase() === "npm_config_prefix" ||
+ (platform === "win32" ? key.toUpperCase() === "PREFIX" : key === "PREFIX"));
+}
+
+/** With a pnpm-managed node, npm's default prefix is derived from a path inside
+ * pnpm's store, so `npm install -g` would write there. Only then, and only when
+ * the user has no explicit prefix (otherwise npm would report it), set the
+ * user-level prefix to PNPM_HOME. The store is the one `pnpm store path`
+ * reports; when it is unknown the step fails. Returns "configured",
+ * "unchanged" or null.
+ */
+async function configureNpmPrefix({ node, cli, child, platform, globalBin, adapters, storePath }) {
+ const path = platform === "win32" ? win32 : posix;
+ const npm = (args) => adapters.run(node, [cli, ...args], { env: child, deadlineMs: deadlines.probe });
+ const effective = async () => {
+ const result = await npm(["config", "get", "prefix"]);
+ const value = String(result.stdout ?? "").trim();
+ return succeeded(result) && path.isAbsolute(value) ? value : null;
+ };
+ const current = await effective();
+ if (current === null) return null;
+ if (explicitNpmPrefix(child, platform)) return "unchanged";
+ const reported = await storePath();
+ const value = String(reported?.stdout ?? "").trim();
+ if (!succeeded(reported) || !path.isAbsolute(value) || /[\r\n]/.test(value)) return null;
+ const store = await adapters.fs.realpath(value).catch(() => null);
+ if (store === null) return null;
+ const resolved = await adapters.fs.realpath(current).catch(() => current);
+ if (!contains(store, resolved, platform)) return "unchanged";
+ if (!succeeded(await npm(["config", "set", "prefix", globalBin.pnpmHome, "--location=user"]))) return null;
+ const after = await effective();
+ return after !== null && samePath(after, globalBin.pnpmHome, platform) ? "configured" : null;
+}
+
+/** Default integrity adapter: the package-local resolver, never a dev override.
+ * `resolve` is injectable only so trusted tests can stand in for a pinned binary.
+ */
+export async function packageNativeGentleAi({ packageRoot, platform, env, home }, resolve = resolveGentleAiBinary) {
+ const environment = { env, home };
+ if (gentleAiDevBinaryOverrideConfigured(environment)) return { ok: false, reason: "development-override" };
+ try {
+ const binary = resolve(packageRoot, platform, readFileSync, environment);
+ if (binary === gentleAiBinaryPath(packageRoot, platform)) return { ok: true };
+ } catch {
+ // Resolver errors name local paths; report only the classification.
+ }
+ return { ok: false, reason: "package-native-unverified" };
+}
+
+/** One displayable line from the bounded output of a failed `gentle-shell
+ * setup` or `pnpm setup`: the last error line (`Error:` or a pnpm `ERR_` code),
+ * else the last non-empty line.
+ * Terminal escapes, control and bidi characters are removed, the user's home
+ * becomes `~`, and the result has at most 300 characters. Null when nothing
+ * remains. Untrusted output: the host still shows it only as text.
+ */
+export function setupErrorDetail(text, home, platform) {
+ if (typeof text !== "string") return null;
+ const lines = text.split(/\r\n|\r|\n/).map((line) => line
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
+ .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
+ .replace(/\t/g, " ")
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "")
+ .trim()).filter((line) => line.length > 0);
+ let line = lines.findLast((candidate) => /Error:|\bERR_[A-Z0-9_]+/.test(candidate)) ?? lines.at(-1);
+ if (line === undefined) return null;
+ const path = platform === "win32" ? win32 : posix;
+ const root = typeof home === "string" && path.isAbsolute(home) ? home.replace(/[\\/]+$/, "") : "";
+ if (root.length > 1) {
+ // Only the whole home path, not a longer sibling such as /home/user for /home/u.
+ line = line.replace(new RegExp(`${escapeRegExp(root)}(?=$|[\\\\/\\s"'():,;\\]])`, platform === "win32" ? "gi" : "g"), "~");
+ }
+ return Array.from(line).slice(0, 300).join("");
+}
+
+/**
+ * runStandardInstall({ plan, consent }, adapters) -> { outcome, ... }
+ * Outcomes: blocked (nothing installed), failed (stopped after `completed`),
+ * terminal-action-required (installed; user PATH persisted, open a new
+ * terminal) or ready. A failed `shell-setup` or `persist-path` may add `detail`,
+ * one sanitized output line (setupErrorDetail); no other output is kept. When the plan persists the Node runtime, successful
+ * outcomes also report npmPrefix: "configured" or "unchanged". Adapters: platform, nodePath, env (user env), home?,
+ * run(command, argv, { env, deadlineMs, stderrTail? }) with shell:false semantics returning
+ * { code, signal, timedOut, stdout, stderrTail? }, fs { isFile, realpath, readText },
+ * verifyGentleAi({ packageRoot, platform, env, home }) and log({ step, status }).
+ * Nothing is ever deleted; no provisioning marker is written. A setup-recovery
+ * plan replaces check-existing-stack with check-recoverable-stack and skips
+ * install-global; every later step and outcome rule is the same.
+ */
+export async function runStandardInstall(request, adapters) {
+ const { platform, env, log = () => {} } = adapters;
+ const completed = [];
+ const blocked = (reason) => {
+ log({ step: "gate", status: "blocked", reason });
+ return { outcome: "blocked", reason, completed };
+ };
+ if (!validRequest(request)) return blocked("invalid-request");
+ if (request.consent !== true) return blocked("consent-required");
+ const gate = planGate(request.plan, platform);
+ if (gate) return blocked(gate);
+
+ const path = platform === "win32" ? win32 : posix;
+ const globalBin = pnpmGlobalBin({ platform, env });
+ if (!globalBin) return blocked("pnpm-home-unknown");
+ if (!path.isAbsolute(adapters.nodePath ?? "")) return blocked("node-unavailable");
+ const child = childEnvironment(env, platform, globalBin);
+ const pnpm = await pnpmInvocation(env, platform, adapters.fs).catch(() => null);
+ if (!pnpm) return blocked("pnpm-unavailable");
+ const runPnpm = (args, deadlineMs) => adapters.run(pnpm.command, [...pnpm.prefix, ...args], { env: child, deadlineMs });
+ const home = adapters.home ?? (platform === "win32" ? env.USERPROFILE : env.HOME);
+
+ const list = () => runPnpm(["list", "-g", "--depth", "0", "--json"], deadlines.probe);
+ const ids = request.plan.actions.map((action) => action.id);
+ // planGate accepted either the clean-stack path or the fixed setup recovery.
+ const recovering = !ids.includes("install-shell");
+ // Fixed variants (planGate): runtime + both managers, or one add of the missing ones.
+ const persistRuntime = ids.includes("persist-node");
+ const addNpm = ids.includes("persist-package-managers") || ids.includes("persist-npm");
+ const addPnpm = ids.includes("persist-package-managers") || ids.includes("persist-pnpm");
+ let npmCli = null;
+ const npmCheck = async () => {
+ const verdict = await genuineNpm(child, platform, adapters.nodePath, adapters, globalBin);
+ if (typeof verdict !== "object" || verdict === null) return verdict;
+ npmCli = verdict.cli;
+ return true;
+ };
+
+ // Pre-install gates: a false check or adapter exception blocks before mutation;
+ // a check may return a more specific blocked reason instead of false.
+ // An npm that is about to be persisted is checked only after it has been added.
+ const checks = [
+ ...(addNpm ? [] : [["check-npm", "npm-unavailable", npmCheck]]),
+ ["check-global-bin", "global-bin-mismatch", async () => {
+ const result = await runPnpm(["bin", "-g"], deadlines.probe);
+ const reported = String(result.stdout ?? "").trim();
+ return succeeded(result) && path.isAbsolute(reported) && samePath(reported, globalBin.path, platform);
+ }],
+ // Never rely on the caller's plan alone: an existing Pi or gentle-pi is not overwritten,
+ // and a recovery still finds exactly the pinned stack this pnpm installed.
+ recovering
+ ? ["check-recoverable-stack", "global-list-unavailable", async () => {
+ const result = await list();
+ if (!succeeded(result)) return false;
+ const root = await recoverableStackRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs)
+ .catch(() => null);
+ return root !== null || "existing-stack-unverified";
+ }]
+ : ["check-existing-stack", "global-list-unavailable", async () => {
+ const result = await list();
+ return succeeded(result) && noExistingStack(String(result.stdout ?? ""));
+ }],
+ ];
+ for (const [step, reason, check] of checks) {
+ const verdict = await check().catch(() => false);
+ if (verdict !== true) return blocked(typeof verdict === "string" ? verdict : reason);
+ completed.push(step);
+ log({ step, status: "done" });
+ }
+
+ // Mutating and post-install steps: a false result or exception is a failure.
+ let packageRoot = null;
+ let setupDetail = null;
+ let persistentNode = null;
+ let npmPrefix = null;
+ const persistence = [
+ ["persist-node", async () => succeeded(await runPnpm(["runtime", "set", "node", persistencePins.node, "-g"], deadlines.install))],
+ // Fixed versions only: never a blanket build approval.
+ ["persist-package-managers", async () => succeeded(await runPnpm(["add", "-g", `npm@${persistencePins.npm}`,
+ `pnpm@${persistencePins.pnpm}`], deadlines.install))],
+ ["verify-persistent-runtime", async () => (persistentNode = await persistentRuntime(child, platform, globalBin, adapters)) !== null],
+ ["check-npm", async () => (await npmCheck()) === true],
+ ["configure-npm-prefix", async () => (npmPrefix = await configureNpmPrefix({ node: persistentNode, cli: npmCli, child,
+ platform, globalBin, adapters, storePath: () => runPnpm(["store", "path"], deadlines.probe) })) !== null],
+ ];
+ // A persistent Node is never replaced: one add of only the missing managers.
+ const addOnly = ids.find((id) => ["persist-package-managers", "persist-npm", "persist-pnpm"].includes(id));
+ const packageManagers = [
+ [addOnly, async () => succeeded(await runPnpm(["add", "-g", ...(addNpm ? [`npm@${persistencePins.npm}`] : []),
+ ...(addPnpm ? [`pnpm@${persistencePins.pnpm}`] : [])], deadlines.install))],
+ ...(addNpm ? [["check-npm", async () => (await npmCheck()) === true]] : []),
+ ...(addPnpm ? [["verify-persistent-pnpm", () => persistentPnpm(child, platform, adapters.nodePath, globalBin, adapters)]] : []),
+ ];
+ // A recovery never runs `add -g`: the installed packages are verified as they are.
+ const install = ["install-global", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`,
+ `${SHELL_PACKAGE}@${requirements.shell}`, `--allow-build=${SHELL_PACKAGE}`], deadlines.install))];
+ const steps = [
+ ...(persistRuntime ? persistence : addOnly ? packageManagers : []),
+ ...(recovering ? [] : [install]),
+ ["verify-global-list", async () => {
+ const result = await list();
+ if (!succeeded(result)) return false;
+ packageRoot = await verifiedPackageRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs);
+ return packageRoot !== null;
+ }],
+ ["verify-shell-bin", () => adapters.fs.isFile(path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell"))],
+ ["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true],
+ // The setup commands are the only ones whose stderr tail is requested.
+ ["shell-setup", async () => {
+ const result = await adapters.run(adapters.nodePath, [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"],
+ { env: setupEnvironment(child, platform), deadlineMs: deadlines.setup, stderrTail: 4096 });
+ if (succeeded(result)) return true;
+ setupDetail = setupErrorDetail(result?.stderrTail, home, platform);
+ return false;
+ }],
+ ];
+ // A child PATH never proves a fresh terminal; persist it with pnpm's own setup.
+ // globalBin.onPath was computed from the user's own PATH, not the child env.
+ // pnpm setup installs @pnpm/exe over the network, so it gets the setup deadline.
+ const persistPath = !globalBin.onPath;
+ if (persistPath) {
+ steps.push(["persist-path", async () => {
+ const result = await adapters.run(pnpm.command, [...pnpm.prefix, "setup"], { env: child, deadlineMs: deadlines.setup, stderrTail: 4096 });
+ if (succeeded(result)) return true;
+ // pnpm prints its own errors, such as ERR_PNPM_UNKNOWN_SHELL, on stdout.
+ setupDetail = setupErrorDetail(result?.stderrTail, home, platform) ?? setupErrorDetail(result?.stdout, home, platform);
+ return false;
+ }]);
+ }
+ for (const [step, run] of steps) {
+ if (!(await Promise.resolve().then(run).catch(() => false))) {
+ log({ step, status: "failed" });
+ const detail = ["shell-setup", "persist-path"].includes(step) && setupDetail ? { detail: setupDetail } : {};
+ return { outcome: "failed", failedStep: step, completed, ...detail };
+ }
+ completed.push(step);
+ log({ step, status: "done" });
+ }
+ const prefix = persistRuntime ? { npmPrefix } : {};
+ if (persistPath) return { outcome: "terminal-action-required", action: "open-new-terminal", completed, ...prefix };
+ return { outcome: "ready", completed, ...prefix };
+}
diff --git a/scripts/installer-server.mjs b/scripts/installer-server.mjs
new file mode 100644
index 000000000..0ccad5a34
--- /dev/null
+++ b/scripts/installer-server.mjs
@@ -0,0 +1,533 @@
+import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import { dirname, join } from "node:path";
+
+// Local wizard host: a dependency-free loopback HTTP server with a fixed route
+// allowlist. The browser never sends commands, paths, plans or environment:
+// plans are built here, stored here and handed to the injected runner here.
+
+const HOST = "127.0.0.1";
+const COOKIE = "gentle_install_session";
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const defaultLimits = Object.freeze({
+ codeTtlMs: 2 * MINUTE,
+ idleMs: 30 * MINUTE,
+ idleCheckMs: 15 * SECOND,
+ bodyBytes: 1024,
+ logEntries: 200,
+ headersTimeoutMs: 10 * SECOND,
+ requestTimeoutMs: 30 * SECOND,
+});
+const securityHeaders = Object.freeze({
+ "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "no-referrer",
+ "X-Frame-Options": "DENY",
+ "Cross-Origin-Resource-Policy": "same-origin",
+ "Cross-Origin-Opener-Policy": "same-origin",
+});
+// Fixed asset names only: the request path never reaches the filesystem.
+const assets = Object.freeze({
+ "/": { file: "index.html", type: "text/html; charset=utf-8" },
+ "/wizard.js": { file: "wizard.js", type: "text/javascript; charset=utf-8" },
+ "/wizard.css": { file: "wizard.css", type: "text/css; charset=utf-8" },
+});
+const routes = Object.freeze({
+ ...Object.fromEntries(Object.keys(assets).map((path) => [path, "GET"])),
+ "/session": "GET",
+ "/api/plan": "GET",
+ "/api/progress": "GET",
+ "/api/install": "POST",
+ "/api/shutdown": "POST",
+});
+// Served on a valid code instead of a 303: when the session URL is reached from
+// the file: redirect page, a redirect chain counts as cross-site and a
+// SameSite=Strict cookie could be withheld on `/`. This same-origin page starts
+// the navigation, so the cookie is sent. No script or style, as the CSP requires.
+const sessionPage = [
+ "",
+ ' ',
+ ' ',
+ "Gentle Shell installation wizard ",
+ 'Continue to the Gentle Shell installation wizard
',
+ "",
+].join("\n");
+
+/** Fixed English descriptions for every action planPreflight can emit. */
+export const actionDescriptions = Object.freeze({
+ "acquire-node": "Download a verified Node.js runtime.",
+ "verify-node": "Check that the downloaded Node.js runtime runs.",
+ "acquire-pnpm": "Download a verified pnpm package manager.",
+ "verify-pnpm": "Check that the downloaded pnpm runs.",
+ "setup-global-bin": "Run `pnpm setup` so new terminals find globally installed commands.",
+ "persist-node": "Install Node.js under PNPM_HOME with `pnpm runtime set node -g`.",
+ "persist-package-managers": "Install npm and pnpm under PNPM_HOME with one `pnpm add -g`.",
+ "configure-npm-prefix": "Point npm's user-level global prefix at PNPM_HOME when its default would be inside pnpm's store.",
+ "persist-npm": "Install npm under PNPM_HOME with `pnpm add -g`.",
+ "persist-pnpm": "Install pnpm under PNPM_HOME with `pnpm add -g`.",
+ "acquire-go": "Download a verified Go toolchain.",
+ "verify-go": "Check that the Go toolchain runs.",
+ "install-pi": "Install the Pi coding agent globally with pnpm.",
+ "install-shell": "Install Gentle Shell (gentle-pi) globally with pnpm.",
+ "provision-native": "Provision the package-native Gentle AI binary with the existing installer.",
+ "setup-shell": "Run the normal `gentle-shell setup`.",
+ "verify-readiness": "Verify that the installed stack is ready.",
+});
+
+const tryAgain = "Fix the cause, then run the installer again.";
+/** Fixed English guidance for every runner blocked reason, failed step and preflight blocker. */
+export const guidance = Object.freeze({
+ blocked: Object.freeze({
+ "invalid-request": "The installation request was not an unmodified plan. Reload the wizard to get a fresh plan.",
+ "consent-required": "Nothing was installed because consent was not given. Review the plan and confirm to continue.",
+ "preflight-blocked": "Preflight found a blocker, so nothing was installed. Resolve the listed blockers and run the installer again.",
+ "go-required": "Windows needs Go 1.25.10 or newer on PATH before Gentle AI can be provisioned. Install Go, then run the installer again.",
+ "unsupported-plan": "This machine needs steps the wizard does not run yet, such as updating an existing installation. Use `gentle-shell update` or follow the README.",
+ "pnpm-home-unknown": "The pnpm home directory could not be determined. Set PNPM_HOME to an absolute directory, then run the installer again.",
+ "node-unavailable": "The installer could not find its own Node.js executable. Run the installer again from the bootstrap script.",
+ "pnpm-unavailable": "pnpm could not be started. Run the installer again from the bootstrap script so it can provide pnpm.",
+ "npm-unavailable": "A genuine npm was not found on PATH. Gentle AI needs it; reinstall Node.js with npm, then run the installer again.",
+ "npm-shadowed": "Another `npm` program appears on PATH before Node.js's npm. Remove or reorder it, then run the installer again.",
+ "global-bin-mismatch": "pnpm reported a different global bin directory than expected. Check PNPM_HOME, then run the installer again.",
+ "global-list-unavailable": "pnpm could not list global packages. Check that `pnpm list -g` works, then run the installer again.",
+ "existing-stack": "Pi or Gentle Shell is already installed globally. Nothing was changed; use `gentle-shell update` instead.",
+ "existing-stack-unverified": "The installed Pi and Gentle Shell changed after the plan was made, or are no longer the versions this installer set up. Nothing was changed; run the installer again to check this computer again.",
+ }),
+ failed: Object.freeze({
+ "persist-node": `Installing Node.js under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
+ "persist-package-managers": `Installing npm and pnpm under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
+ "persist-npm": `Installing npm under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
+ "persist-pnpm": `Installing pnpm under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
+ "verify-persistent-runtime": `Node.js and npm were not found in the pnpm global bin directory after installation. ${tryAgain}`,
+ "verify-persistent-pnpm": `pnpm was not found in the pnpm global bin directory after installation. ${tryAgain}`,
+ "check-npm": `The installed npm could not be verified as genuine npm. ${tryAgain}`,
+ "configure-npm-prefix": `npm's global prefix could not be checked or set to PNPM_HOME. Your npm configuration was left as it was. ${tryAgain}`,
+ "install-global": `Installing Pi and Gentle Shell with pnpm failed. Check your network connection. ${tryAgain}`,
+ "verify-global-list": `The installed Pi and Gentle Shell packages could not be verified under PNPM_HOME. ${tryAgain}`,
+ "verify-shell-bin": `The gentle-shell command was not found in the pnpm global bin directory. ${tryAgain}`,
+ "verify-gentle-ai": `The package-native Gentle AI binary could not be verified. ${tryAgain}`,
+ "shell-setup": "`gentle-shell setup` did not finish. Run `gentle-shell setup` in a terminal to see the details.",
+ "persist-path": "`pnpm setup` could not add the global bin directory to your PATH. Run `pnpm setup` in a terminal, then open a new terminal.",
+ }),
+ blockers: Object.freeze({
+ "unsupported-target": "This operating system or CPU is not supported by the wizard. Follow the README for a manual installation.",
+ "unknown-tool": "A required tool could not be checked safely. Make sure it runs from a terminal, or remove the broken installation, then run the installer again.",
+ "incompatible-tool": "A required tool is installed at an incompatible version. Update it, then run the installer again.",
+ }),
+ outcomes: Object.freeze({
+ ready: "Gentle Shell is installed. Run `gentle-shell` in a terminal.",
+ "terminal-action-required": "Gentle Shell is installed. Open a new terminal so it picks up the updated PATH, then run `gentle-shell`.",
+ }),
+ // A failed shell-setup whose detail shows GitHub's anonymous API limit.
+ setupRateLimit: "`gentle-shell setup` could not finish because GitHub's limit for anonymous API requests was reached on this network. Wait up to an hour, then run the installer again.",
+ // A failed persist-path whose detail shows pnpm could not tell the shell (POSIX SHELL missing or unsupported).
+ persistPathShell: "`pnpm setup` could not tell which shell profile to edit because the SHELL environment variable is missing or names an unsupported shell. Open a regular terminal and run the installer again, or add `$PNPM_HOME/bin` to your PATH yourself.",
+ fallback: "The installation stopped for an unexpected reason. Nothing else will run; check the terminal and run the installer again.",
+});
+
+/** The runner's setup detail, bounded again: text only, no control characters,
+ * at most 300 characters. Null when nothing remains.
+ */
+function setupDetail(value) {
+ if (typeof value !== "string") return null;
+ const text = Array.from(value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "").trim()).slice(0, 300).join("");
+ return text.length > 0 ? text : null;
+}
+const rateLimited = (detail) => /rate limit/i.test(detail) || (/GitHub API/i.test(detail) && /\b403\b/.test(detail));
+const unknownShell = (detail) => /ERR_PNPM_(?:UNKNOWN|UNSUPPORTED)_SHELL/.test(detail);
+// Fixed setup commands whose sanitized last error line may reach the browser.
+const detailSteps = Object.freeze({ "shell-setup": rateLimited, "persist-path": unknownShell });
+const detailGuidance = Object.freeze({ "shell-setup": "setupRateLimit", "persist-path": "persistPathShell" });
+
+const ID = /^[a-z][a-z0-9-]{0,63}$/;
+function identifier(value, fallback = "unknown") {
+ return typeof value === "string" && ID.test(value) ? value : fallback;
+}
+// Preflight tool names include camelCase keys such as gentleAi and globalBin.
+const TOOL = /^[a-z][A-Za-z0-9-]{0,63}$/;
+function toolName(value) {
+ return typeof value === "string" && TOOL.test(value) ? value : "unknown";
+}
+function plainObject(value) {
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
+}
+function token(random, bytes) {
+ return random(bytes).toString("base64url");
+}
+function sameSecret(expected, actual) {
+ const [a, b] = [Buffer.from(expected), Buffer.from(String(actual))];
+ return a.length === b.length && timingSafeEqual(a, b);
+}
+function fingerprint(collected) {
+ return createHash("sha256").update(JSON.stringify({ plan: collected.plan, binDir: collected.inventory?.globalBin?.path ?? null }))
+ .digest("hex");
+}
+
+/** Browser view of a server-held plan: fixed descriptions, never commands. */
+function planView(planId, { inventory, plan }) {
+ const ids = plan.actions.map((action) => action.id);
+ const binDir = typeof inventory?.globalBin?.path === "string" ? inventory.globalBin.path : null;
+ const pnpmHome = binDir === null ? null : dirname(binDir);
+ const changesProfile = ids.includes("setup-global-bin");
+ let tools = [];
+ if (ids.includes("persist-node")) tools = ["node", "npm", "pnpm"];
+ else if (ids.includes("persist-package-managers")) tools = ["npm", "pnpm"];
+ else if (ids.includes("persist-npm")) tools = ["npm"];
+ else if (ids.includes("persist-pnpm")) tools = ["pnpm"];
+ return {
+ planId,
+ ready: plan.ready === true,
+ actions: plan.actions.map((action) => ({
+ id: identifier(action.id),
+ description: actionDescriptions[action.id] ?? "Prepare the installation.",
+ })),
+ blockers: plan.blockers.map((blocker) => ({
+ code: identifier(blocker.code),
+ tool: toolName(blocker.tool),
+ guidance: guidance.blockers[blocker.code] ?? guidance.fallback,
+ })),
+ profileChange: {
+ changesProfile,
+ command: "pnpm setup",
+ binDir,
+ description: changesProfile
+ ? `\`pnpm setup\` will add ${binDir ?? "the pnpm global bin directory"} to your PATH: it edits your shell profile on macOS and Linux, or your user PATH on Windows. Open a new terminal afterwards.`
+ : "Your PATH already contains the pnpm global bin directory; no shell profile or PATH change is planned.",
+ },
+ persistence: {
+ tools,
+ pnpmHome,
+ description: tools.length > 0
+ ? `${tools.join(", ")} will be installed under $PNPM_HOME (${pnpmHome ?? "pnpm's home directory"}) so new terminals keep working after the temporary installer tools are removed. Existing installations are not replaced.`
+ : "No runtime needs to be installed under $PNPM_HOME; your existing Node.js, npm and pnpm are reused.",
+ },
+ };
+}
+
+function outcomeView(result) {
+ if (!plainObject(result) || !["ready", "terminal-action-required", "blocked", "failed"].includes(result.outcome)) {
+ return { outcome: "failed", failedStep: null, completed: [], guidance: guidance.fallback };
+ }
+ const view = { outcome: result.outcome, completed: Array.isArray(result.completed) ? result.completed.map((step) => identifier(step)) : [] };
+ if (result.outcome === "blocked") {
+ view.reason = identifier(result.reason);
+ view.guidance = guidance.blocked[result.reason] ?? guidance.fallback;
+ } else if (result.outcome === "failed") {
+ view.failedStep = identifier(result.failedStep);
+ view.guidance = guidance.failed[result.failedStep] ?? guidance.fallback;
+ const step = Object.hasOwn(detailSteps, result.failedStep) ? result.failedStep : null;
+ const detail = step ? setupDetail(result.detail) : null;
+ if (detail) {
+ view.detail = detail;
+ if (detailSteps[step](detail)) view.guidance = guidance[detailGuidance[step]];
+ }
+ } else {
+ view.guidance = guidance.outcomes[result.outcome];
+ if (result.action === "open-new-terminal") view.action = "open-new-terminal";
+ if (["configured", "unchanged"].includes(result.npmPrefix)) view.npmPrefix = result.npmPrefix;
+ }
+ return view;
+}
+
+/**
+ * createInstallerServer({ collectPlan, runInstall, assetsDir, now?, random?, limits?, onRedeemed? })
+ * collectPlan() -> { inventory, plan } (fresh preflight, trusted local code);
+ * runInstall({ plan, consent: true }, log) -> runner result; onRedeemed() runs once
+ * when the one-time code is used (it must not throw). Returns
+ * { listen(), close(reason), closed, checkIdle(), outcome() }.
+ */
+export function createInstallerServer({ collectPlan, runInstall, assetsDir, now = Date.now, random = randomBytes, limits = {},
+ onRedeemed = () => {} }) {
+ const limit = { ...defaultLimits, ...limits };
+ const secret = token(random, 32);
+ let code = token(random, 32);
+ let codeExpires = 0;
+ let port = 0;
+ let origin = "";
+ let lastActivity = now();
+ let stored = null;
+ let planning = null;
+ let installing = false;
+ let lastOutcome = null;
+ let seq = 0;
+ const log = [];
+ let idleTimer = null;
+ let closing = null;
+ let resolveClosed;
+ const closed = new Promise((resolve) => { resolveClosed = resolve; });
+
+ // requireHostHeader:false lets a missing Host get the same 421 as a wrong one.
+ const server = createServer({ maxHeaderSize: 16 * 1024, requireHostHeader: false }, (req, res) => {
+ handle(req, res).catch(() => {
+ if (!res.headersSent) reply(res, 500, { error: "internal" });
+ else res.destroy();
+ });
+ });
+ server.headersTimeout = limit.headersTimeoutMs;
+ server.requestTimeout = limit.requestTimeoutMs;
+ server.keepAliveTimeout = 5 * SECOND;
+
+ function reply(res, status, body, headers = {}) {
+ const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
+ res.writeHead(status, {
+ ...securityHeaders,
+ "Content-Type": "application/json; charset=utf-8",
+ ...headers,
+ "Content-Length": Buffer.byteLength(payload),
+ });
+ res.end(payload);
+ }
+
+ function hasSession(req) {
+ const pairs = String(req.headers.cookie ?? "").split(";").map((part) => part.trim())
+ .filter((part) => part.startsWith(`${COOKIE}=`));
+ return pairs.length === 1 && sameSecret(secret, pairs[0].slice(COOKIE.length + 1));
+ }
+
+ async function readJson(req, res) {
+ const declared = Number(req.headers["content-length"]);
+ if (Number.isFinite(declared) && declared > limit.bodyBytes) {
+ reply(res, 413, { error: "body-too-large" }, { Connection: "close" });
+ return undefined;
+ }
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of req) {
+ size += chunk.length;
+ if (size > limit.bodyBytes) {
+ reply(res, 413, { error: "body-too-large" }, { Connection: "close" });
+ return undefined;
+ }
+ chunks.push(chunk);
+ }
+ try {
+ return { value: JSON.parse(Buffer.concat(chunks).toString("utf8") || "null") };
+ } catch {
+ return { value: undefined };
+ }
+ }
+
+ function record(entry) {
+ const value = plainObject(entry) ? entry : {};
+ seq += 1;
+ log.push({ seq, step: identifier(value.step), status: identifier(value.status),
+ reason: value.reason === undefined || value.reason === null ? null : identifier(value.reason) });
+ while (log.length > limit.logEntries) log.shift();
+ }
+
+ function collect() {
+ planning ??= Promise.resolve().then(collectPlan).finally(() => { planning = null; });
+ return planning;
+ }
+
+ async function getPlan(res) {
+ // Probing while the runner mutates the machine would describe a moving target.
+ if (installing) {
+ reply(res, 409, { error: "install-running" });
+ return;
+ }
+ let collected;
+ try {
+ collected = await collect();
+ } catch {
+ reply(res, 500, { error: "plan-unavailable" });
+ return;
+ }
+ if (installing) {
+ reply(res, 409, { error: "install-running" });
+ return;
+ }
+ stored = { planId: token(random, 18), collected: structuredClone(collected), fingerprint: fingerprint(collected) };
+ reply(res, 200, planView(stored.planId, stored.collected));
+ }
+
+ async function install(req, res) {
+ const body = await readJson(req, res);
+ if (body === undefined) return;
+ const { value } = body;
+ const keys = plainObject(value) ? Object.keys(value).sort() : [];
+ if (keys.length !== 2 || keys[0] !== "consent" || keys[1] !== "planId" || value.consent !== true ||
+ typeof value.planId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value.planId)) {
+ reply(res, 400, { error: "invalid-request" });
+ return;
+ }
+ if (installing) {
+ reply(res, 409, { error: "install-running" });
+ return;
+ }
+ // One installation per wizard run: a final outcome is never replaced.
+ if (lastOutcome !== null) {
+ reply(res, 409, { error: "already-completed" });
+ return;
+ }
+ if (stored === null || !sameSecret(stored.planId, value.planId)) {
+ reply(res, 409, { error: "plan-changed" });
+ return;
+ }
+ installing = true;
+ const current = stored;
+ let fresh;
+ try {
+ // Re-inventory: install only when the machine still matches the consented plan.
+ fresh = await collect();
+ } catch {
+ fresh = null;
+ }
+ if (fresh === null || stored !== current || fingerprint(fresh) !== current.fingerprint) {
+ installing = false;
+ stored = null;
+ reply(res, 409, { error: "plan-changed" });
+ return;
+ }
+ stored = null;
+ const request = { plan: structuredClone(current.collected.plan), consent: true };
+ // Viewing an untrusted result can throw; the installation must still end
+ // with a failed outcome and release the single-flight and idle locks.
+ Promise.resolve()
+ .then(() => runInstall(request, record))
+ .then((result) => {
+ try {
+ return outcomeView(result);
+ } catch {
+ return outcomeView(null);
+ }
+ }, () => outcomeView(null))
+ .then((view) => {
+ lastOutcome = view;
+ })
+ .finally(() => {
+ installing = false;
+ lastActivity = now();
+ });
+ reply(res, 202, { started: true });
+ }
+
+ function progress(query, res) {
+ const params = new URLSearchParams(query);
+ const keys = [...params.keys()];
+ const after = params.get("after") ?? "0";
+ if (keys.some((key) => key !== "after") || keys.length > 1 || !/^(0|[1-9]\d{0,15})$/.test(after)) {
+ reply(res, 400, { error: "invalid-request" });
+ return;
+ }
+ const from = Number(after);
+ reply(res, 200, { entries: log.filter((entry) => entry.seq > from), running: installing, outcome: lastOutcome });
+ }
+
+ async function session(query, res) {
+ const params = new URLSearchParams(query);
+ const supplied = params.get("code");
+ const valid = code !== null && now() <= codeExpires && [...params.keys()].length === 1 && supplied !== null && sameSecret(code, supplied);
+ if (!valid) {
+ reply(res, 401, { error: "unauthorized" });
+ return;
+ }
+ code = null;
+ lastActivity = now();
+ try {
+ onRedeemed();
+ } catch {
+ // Cleanup hooks are best effort and never break the session.
+ }
+ reply(res, 200, sessionPage, {
+ "Content-Type": "text/html; charset=utf-8",
+ "Set-Cookie": `${COOKIE}=${secret}; HttpOnly; SameSite=Strict; Path=/`,
+ });
+ }
+
+ async function asset(path, res) {
+ let content;
+ try {
+ content = await readFile(join(assetsDir, assets[path].file));
+ } catch {
+ reply(res, 404, { error: "not-found" });
+ return;
+ }
+ reply(res, 200, content, { "Content-Type": assets[path].type });
+ }
+
+ async function handle(req, res) {
+ // Only authenticated requests (and a code redemption) count as activity.
+ if (req.headers.host !== `${HOST}:${port}`) return reply(res, 421, { error: "misdirected" });
+ const raw = String(req.url ?? "");
+ const mark = raw.indexOf("?");
+ const path = mark === -1 ? raw : raw.slice(0, mark);
+ const query = mark === -1 ? "" : raw.slice(mark + 1);
+ const method = routes[Object.hasOwn(routes, path) ? path : ""];
+ if (method === undefined) return reply(res, 404, { error: "not-found" });
+ if (req.method !== method) return reply(res, 405, { error: "method-not-allowed" }, { Allow: method });
+ // Browsers send Origin on cross-origin GETs; only our own origin may.
+ if (req.headers.origin !== undefined && req.headers.origin !== origin) return reply(res, 403, { error: "forbidden" });
+ if (path === "/session") return session(query, res);
+ if (!hasSession(req)) return reply(res, 401, { error: "unauthorized" });
+ lastActivity = now();
+ // A custom header makes every cross-origin API fetch need a CORS preflight, which never succeeds.
+ if (path.startsWith("/api/") && req.headers["x-gentle-install"] !== "1") return reply(res, 403, { error: "forbidden" });
+ if (method === "POST") {
+ if (req.headers.origin !== origin) return reply(res, 403, { error: "forbidden" });
+ const type = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
+ if (type !== "application/json") return reply(res, 415, { error: "unsupported-media-type" });
+ }
+ if (query !== "" && path !== "/api/progress") return reply(res, 400, { error: "invalid-request" });
+ if (Object.hasOwn(assets, path)) return asset(path, res);
+ if (path === "/api/plan") return getPlan(res);
+ if (path === "/api/progress") return progress(query, res);
+ if (path === "/api/install") return install(req, res);
+ // /api/shutdown
+ const body = await readJson(req, res);
+ if (body === undefined) return undefined;
+ if (body.value !== null && !(plainObject(body.value) && Object.keys(body.value).length === 0)) {
+ return reply(res, 400, { error: "invalid-request" });
+ }
+ if (installing) return reply(res, 409, { error: "install-running" });
+ res.once("finish", () => close("shutdown"));
+ reply(res, 200, { closing: true });
+ return undefined;
+ }
+
+ function checkIdle() {
+ if (closing || installing || now() - lastActivity < limit.idleMs) return false;
+ close("idle");
+ return true;
+ }
+
+ function close(reason) {
+ closing ??= new Promise((resolve) => {
+ if (idleTimer) clearInterval(idleTimer);
+ code = null;
+ server.close(() => resolve());
+ server.closeIdleConnections();
+ setImmediate(() => server.closeAllConnections());
+ }).then(() => {
+ resolveClosed({ reason, outcome: lastOutcome });
+ });
+ return closing;
+ }
+
+ async function listen() {
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen({ host: HOST, port: 0, exclusive: true }, () => {
+ server.off("error", reject);
+ resolve();
+ });
+ });
+ const address = server.address();
+ if (typeof address !== "object" || address === null || address.address !== HOST || address.family !== "IPv4") {
+ await close("bind-mismatch");
+ throw new Error("Wizard host is not bound to 127.0.0.1");
+ }
+ port = address.port;
+ origin = `http://${HOST}:${port}`;
+ codeExpires = now() + limit.codeTtlMs;
+ lastActivity = now();
+ idleTimer = setInterval(checkIdle, limit.idleCheckMs);
+ idleTimer.unref();
+ return { port, address: address.address, origin, url: `${origin}/session?code=${code}` };
+ }
+
+ return { listen, close, closed, checkIdle, outcome: () => lastOutcome };
+}
diff --git a/scripts/installer-windows-artifacts.json b/scripts/installer-windows-artifacts.json
new file mode 100644
index 000000000..2f844af2c
--- /dev/null
+++ b/scripts/installer-windows-artifacts.json
@@ -0,0 +1,15 @@
+{
+ "node": {
+ "version": "24.21.0",
+ "source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt",
+ "maxBytes": 104857600,
+ "x64": {
+ "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip",
+ "sha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
+ },
+ "arm64": {
+ "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-arm64.zip",
+ "sha256": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921"
+ }
+ }
+}
diff --git a/scripts/installer-windows.mjs b/scripts/installer-windows.mjs
new file mode 100644
index 000000000..2357219d0
--- /dev/null
+++ b/scripts/installer-windows.mjs
@@ -0,0 +1,362 @@
+import { lstatSync, readFileSync, realpathSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
+import { dirname, join, resolve, win32 } from "node:path";
+import { spawnSync } from "node:child_process";
+import { gunzipSync } from "node:zlib";
+import { artifactFor, verifiedDownload, compatibleEngine, launchWizard } from "./installer-downloads.mjs";
+
+export const windowsNodeFloor = ">=24.3.0";
+const maxExpandedBytes = 128 * 1024 * 1024;
+
+/** Validate the whole namespace, including implicit parents, before any writes.
+ * Windows paths are case-insensitive here even on portable fixture hosts.
+ */
+export function validateWindowsEntries(entries) {
+ if (!Array.isArray(entries) || entries.length === 0 || entries.length > 50000) throw new Error("Unsafe archive entry count");
+ const explicit = new Set();
+ const namespace = new Map();
+ for (const entry of entries) {
+ if (typeof entry.name !== "string" || entry.link || entry.name.includes("\\")) throw new Error("Unsafe archive entry");
+ const name = entry.directory && entry.name.endsWith("/") ? entry.name.slice(0, -1) : entry.name;
+ const parts = name.split("/");
+ if (parts.some((part) => !part || part === "." || part === ".." || /[<>:"|?*\x00-\x1f\x7f]/.test(part) || /[. ]$/.test(part) || /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/i.test(part))) {
+ throw new Error("Unsafe archive path");
+ }
+ const key = name.toLowerCase();
+ if (explicit.has(key)) throw new Error("Unsafe duplicate archive path");
+ explicit.add(key);
+ for (let index = 1; index <= parts.length; index += 1) {
+ const path = parts.slice(0, index).join("/");
+ const directory = index < parts.length || Boolean(entry.directory);
+ const previous = namespace.get(path.toLowerCase());
+ if (previous && (previous.path !== path || previous.directory !== directory)) throw new Error("Unsafe archive alias");
+ namespace.set(path.toLowerCase(), { path, directory });
+ }
+ }
+}
+
+// Complete known npm cmd-shim template. EndLocal restores PATH/PATHEXT before
+// the child starts; no Node flags or extra environment assignments are accepted.
+const shimStart = `@ECHO off
+GOTO start
+:find_dp0
+SET dp0=%~dp0
+EXIT /b
+:start
+SETLOCAL
+CALL :find_dp0
+
+IF EXIST "%dp0%\\node.exe" (
+ SET "_prog=%dp0%\\node.exe"
+) ELSE (
+ SET "_prog=node"
+ SET PATHEXT=%PATHEXT:;.JS;=;%
+)
+
+endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\`;
+export function proveWindowsWrapper(text) {
+ if (typeof text !== "string") throw new Error("Unknown pnpm wrapper");
+ const normalized = text.replaceAll("\r\n", "\n");
+ for (const entry of ["node_modules\\pnpm\\bin\\pnpm.cjs", "node_modules\\pnpm\\bin\\pnpm.mjs"]) {
+ if (normalized === `${shimStart}${entry}" %*\n`) {
+ return { entry, localNode: "node.exe", fallbackNode: "node", flags: [], environment: "inherited" };
+ }
+ }
+ throw new Error("Unknown pnpm wrapper; refusing execution or replacement");
+}
+
+function regular(path) {
+ try { const info = lstatSync(path); return info.isFile() && !info.isSymbolicLink(); }
+ catch (error) { if (error.code === "ENOENT") return false; throw error; }
+}
+function exists(path) {
+ try { lstatSync(path); return true; }
+ catch (error) { if (error.code === "ENOENT") return false; throw error; }
+}
+export function windowsProcessCheck(command, args, env) {
+ // Owned direct children only, not a tree/global kill. Descendant-held stdio
+ // is outside this guarantee. Never execute .cmd through a command interpreter.
+ if (/\.(?:cmd|bat)$/i.test(command)) throw new Error("Unproven command interpreter target");
+ const result = spawnSync(command, args, { env, shell: false, encoding: "utf8", timeout: 15000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, windowsHide: true });
+ if (result.error || result.status !== 0) throw new Error("Windows prerequisite process failed");
+ return result.stdout.trim();
+}
+
+/** Bounded fixture model of the fixed native predicate, not an ACL evaluator.
+ * ReadAndExecute + Synchronize are harmless for this mutation boundary. Only
+ * distant existing directories may also allow sibling CreateDirectories (4).
+ * Effective inherited ACEs are checked again on every actual path component.
+ */
+export function windowsAclRuleUnsafe({ rights, depth, allow = true, inheritOnly = false, trusted = false }) {
+ if (!Number.isInteger(rights) || rights < 0 || rights > 0xffffffff || !Number.isInteger(depth) || depth < 0) throw new Error("Unknown Windows ACL fixture");
+ if (!allow || inheritOnly || trusted) return false;
+ const allowedRights = depth >= 2 ? 0x1200ad : 0x1200a9;
+ return (rights & ~allowedRights) !== 0;
+}
+
+// Fixed stock PowerShell intrinsics, not a loaded/evaluated PS script. Paths
+// travel only as environment data. Managed constraints/denials fail closed.
+// ACLs are read through .NET, never Get-Acl: a PowerShell 7 parent's PSModulePath
+// makes Windows PowerShell 5.1 fail to autoload Microsoft.PowerShell.Security.
+// An intentional rejection prints one fixed `unsafe:-` code (the role
+// of the component, never its path); any other exception still exits nonzero.
+const aclCheck = String.raw`
+$ErrorActionPreference = 'Stop';
+try {
+ if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'policy' };
+ $me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value;
+ $trusted = @($me, 'S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464');
+ $path = [IO.Path]::GetFullPath($env:GENTLE_WINDOWS_CHECK);
+ $depth = 0;
+ while ($path) {
+ $role = 'ancestor'; if ($depth -eq 0) { $role = 'target' } elseif ($depth -eq 1) { $role = 'parent' };
+ $item = Get-Item -LiteralPath $path -Force;
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw ($role + '-reparse') };
+ if ($item.PSIsContainer) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) };
+ if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw ($role + '-owner') };
+ $allowedRights = 0x1200a9; if ($depth -ge 2) { $allowedRights = 0x1200ad };
+ foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
+ if ($rule.AccessControlType -eq 'Allow' -and -not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -and ([long]$rule.FileSystemRights -band (-bnot [long]$allowedRights)) -and $trusted -notcontains $rule.IdentityReference.Value) { throw ($role + '-acl-mask') };
+ };
+ $parent = [IO.Directory]::GetParent($path);
+ if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++;
+ };
+ 'safe'
+} catch { if ($_.Exception.Message -cmatch '^(policy|(target|parent|ancestor)-(reparse|owner|acl-mask))$') { 'unsafe:' + $_.Exception.Message } else { throw } }
+`;
+const storageChecks = /^(?:policy|(?:target|parent|ancestor)-(?:reparse|owner|acl-mask))$/;
+/** Only exact `safe` passes. A fixed rejection code is kept as `check`; any other
+ * output is rejected without carrying its text.
+ */
+export function windowsStorageEvidence(output) {
+ if (output === "safe") return;
+ const check = typeof output === "string" && output.startsWith("unsafe:") ? output.slice(7) : "";
+ throw Object.assign(new Error("Windows ACL evidence rejected"), storageChecks.test(check) ? { check } : {});
+}
+export function verifyWindowsStorage(path, env, processAdapter = windowsProcessCheck) {
+ if (process.platform !== "win32") throw new Error("Native Windows storage verification unavailable");
+ if (!win32.isAbsolute(path) || path.startsWith("\\\\")) throw new Error("Unsafe Windows storage path");
+ windowsStorageEvidence(processAdapter(join(env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", aclCheck], { ...env, GENTLE_WINDOWS_CHECK: path }));
+}
+
+/** Strict bounded tar reader: no system tar, archive links or archive execution.
+ * Unsupported GNU/PAX extensions stop rather than extract using guessed semantics.
+ */
+export function readWindowsPnpmArchive(bytes) {
+ const tar = gunzipSync(bytes, { maxOutputLength: maxExpandedBytes });
+ const entries = [];
+ let offset = 0;
+ let ended = false;
+ const text = (block, start, length) => {
+ const value = block.subarray(start, start + length);
+ const zero = value.indexOf(0);
+ const data = zero === -1 ? value : value.subarray(0, zero);
+ const result = data.toString("utf8");
+ if (!Buffer.from(result).equals(data)) throw new Error("Unsafe tar text encoding");
+ return result;
+ };
+ const octal = (value) => {
+ if (!/^[0-7]+$/.test(value.trim())) throw new Error("Unsafe tar number");
+ return Number.parseInt(value.trim(), 8);
+ };
+ while (offset + 512 <= tar.length) {
+ const block = tar.subarray(offset, offset + 512);
+ if (block.every((value) => value === 0)) {
+ if (tar.length < offset + 1024 || !tar.subarray(offset).every((value) => value === 0)) throw new Error("Unsafe tar termination");
+ ended = true;
+ break;
+ }
+ const expected = octal(text(block, 148, 8));
+ let checksum = 0;
+ for (let index = 0; index < 512; index += 1) checksum += index >= 148 && index < 156 ? 32 : block[index];
+ if (checksum !== expected || text(block, 257, 6) !== "ustar") throw new Error("Unsafe tar header");
+ const size = octal(text(block, 124, 12));
+ const prefix = text(block, 345, 155);
+ const name = `${prefix ? `${prefix}/` : ""}${text(block, 0, 100)}`;
+ const type = block[156];
+ if (![0, 48, 53].includes(type) || text(block, 157, 100) || (type === 53 && size !== 0)) throw new Error("Unsafe tar entry type");
+ const next = offset + 512 + Math.ceil(size / 512) * 512;
+ if (next > tar.length) throw new Error("Unsafe truncated tar");
+ entries.push({ name, directory: type === 53, bytes: tar.subarray(offset + 512, offset + 512 + size) });
+ if (entries.length > 50000) throw new Error("Unsafe tar entry count");
+ offset = next;
+ }
+ if (!ended) throw new Error("Unsafe tar termination");
+ validateWindowsEntries(entries);
+ if (entries.some((entry) => !entry.name.startsWith("package/") && entry.name !== "package")) throw new Error("Unsafe pnpm package root");
+ return entries;
+}
+
+function findWindowsCommand(env, name = "pnpm") {
+ // Alternate CMD cwd-search policy is not silently flattened into our model.
+ if (Object.keys(env).some((key) => key.toLowerCase() === "nodefaultcurrentdirectoryinexepath")) throw new Error("Unknown Windows cwd-search semantics");
+ const value = (name) => Object.entries(env).find(([key]) => key.toLowerCase() === name)?.[1];
+ const path = value("path");
+ const pathExt = value("pathext");
+ if (typeof path !== "string" || typeof pathExt !== "string") throw new Error("Unknown Windows PATH/PATHEXT");
+ const extensions = pathExt.toLowerCase().split(";");
+ // .cpl is known because Windows PowerShell 5.1 appends it for its children, and
+ // bootstrap.cmd starts this helper from there. Known is not accepted: a .cpl (or
+ // any non-.cmd wrapper / non-.exe Node) found first still fails closed below.
+ if (!extensions.length || new Set(extensions).size !== extensions.length || extensions.some((extension) => !/^\.(com|exe|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|cpl)$/.test(extension))) throw new Error("Unknown Windows PATHEXT semantics");
+ // CMD ignores empty entries (a trailing `;` is the Windows default); every
+ // other non-absolute, quoted or UNC entry still fails closed.
+ for (const directory of [process.cwd(), ...path.split(";").filter((entry) => entry !== "")]) {
+ if (!win32.isAbsolute(directory) || directory.startsWith("\\\\") || directory.includes('"')) throw new Error("Unknown Windows PATH");
+ // CMD searches cwd too. An extensionless/other target must block rather
+ // than be silently skipped; honor the wrapper's inherited PATHEXT order.
+ if (exists(join(directory, name))) throw new Error("Unknown extensionless Windows prerequisite");
+ for (const extension of extensions) {
+ const command = join(directory, `${name}${extension}`);
+ if (exists(command)) return command;
+ }
+ }
+ return null;
+}
+function proveCli(node, entry, metadata, env, processAdapter) {
+ const version = processAdapter(node, ["--version"], env);
+ if (metadata.name !== "pnpm" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(metadata.version) || !compatibleEngine(windowsNodeFloor, version) || !compatibleEngine(metadata.engines?.node, version)) {
+ throw new Error("Windows pnpm engine or identity rejected");
+ }
+ if (processAdapter(node, [entry, "--version"], env) !== metadata.version) throw new Error("Windows pnpm version rejected");
+ for (const capability of ["add", "bin"]) {
+ if (!/(?:^|[\s,])--global(?:[\s,=]|$)/.test(processAdapter(node, [entry, "help", capability], env))) throw new Error("Windows pnpm global capability rejected");
+ }
+}
+
+/** Returns a direct invocation, NOT a fabricated npm/pnpm executable shim.
+ * T4 must call command + prefix with shell:false and retain this child env.
+ * `onStep` receives fixed phase names for diagnostics only; it never alters checks.
+ */
+export async function ensureWindowsPnpm({ tools, env, node = process.execPath, adapters = {}, onStep = () => {} }) {
+ const processAdapter = adapters.process ?? windowsProcessCheck;
+ const storage = adapters.storage ?? verifyWindowsStorage;
+ onStep("pnpm-discovery");
+ const existing = (adapters.findCommand ?? findWindowsCommand)(env);
+ if (existing) {
+ onStep("wrapper-storage");
+ storage(existing, env);
+ onStep("wrapper");
+ if (!/\.cmd$/i.test(existing) || !regular(existing)) throw new Error("Unknown pnpm wrapper; refusing replacement");
+ const proof = proveWindowsWrapper(readFileSync(existing, "utf8"));
+ const directory = dirname(existing);
+ const entry = join(directory, ...proof.entry.split("\\"));
+ const localNode = join(directory, proof.localNode);
+ // npm cmd-shim selects its sibling node.exe before PATH node. Preserve it.
+ onStep("node-discovery");
+ const selectedNode = exists(localNode) ? localNode : (adapters.findNode ?? ((env) => findWindowsCommand(env, "node")))(env);
+ if (!selectedNode || !/\.exe$/i.test(selectedNode)) throw new Error("Unknown wrapper-selected Node");
+ const metadataPath = join(directory, "node_modules", "pnpm", "package.json");
+ for (const [step, path] of [["node-storage", selectedNode], ["entry-storage", entry], ["metadata-storage", metadataPath]]) {
+ onStep(step);
+ storage(path, env);
+ if (!regular(path)) throw new Error("Unsafe pnpm wrapper target");
+ }
+ onStep("package");
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
+ if (metadata.bin?.pnpm !== `bin/${proof.entry.endsWith("pnpm.cjs") ? "pnpm.cjs" : "pnpm.mjs"}`) throw new Error("Windows pnpm package target rejected");
+ onStep("cli-proof");
+ proveCli(selectedNode, entry, metadata, env, processAdapter);
+ return { acquired: false, env, command: selectedNode, prefix: [entry] };
+ }
+ onStep("tools-check");
+ storage(tools, env);
+ if (!lstatSync(tools).isDirectory() || realpathSync(tools) !== resolve(tools)) throw new Error("Unsafe Windows tools");
+ const destination = join(tools, "pnpm");
+ if (exists(destination)) throw new Error("Conflicting Windows pnpm destination");
+ onStep("download");
+ const bytes = await verifiedDownload("pnpm", adapters);
+ onStep("archive");
+ const entries = readWindowsPnpmArchive(bytes);
+ const pin = artifactFor("pnpm");
+ const packageEntry = entries.find((entry) => entry.name === "package/package.json" && !entry.directory);
+ const metadata = packageEntry && JSON.parse(packageEntry.bytes.toString("utf8"));
+ if (!metadata || metadata.name !== pin.name || metadata.version !== pin.version || metadata.engines?.node !== pin.engine || metadata.bin?.pnpm !== "bin/pnpm.mjs") throw new Error("Windows pnpm pin metadata rejected");
+ if (!entries.some((entry) => entry.name === "package/bin/pnpm.mjs" && !entry.directory)) throw new Error("Windows pnpm entry missing");
+ let owned = false;
+ onStep("publish");
+ try {
+ mkdirSync(destination); // atomic no-clobber claim under the verified private root
+ owned = true;
+ storage(destination, env);
+ for (const entry of entries) {
+ const path = join(destination, ...entry.name.split("/"));
+ if (entry.directory) mkdirSync(path, { recursive: true });
+ else {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, entry.bytes, { flag: "wx" });
+ }
+ }
+ const entry = join(destination, "package", "bin", "pnpm.mjs");
+ proveCli(node, entry, metadata, env, processAdapter);
+ return { acquired: true, env, command: node, prefix: [entry] };
+ } catch {
+ if (owned) rmSync(destination, { recursive: true, force: true });
+ throw new Error("Windows pnpm acquisition failed; no installation completed");
+ }
+}
+
+// Fixed, non-sensitive helper diagnostics. Every explicit check on this path has
+// one code; the step names the phase. Anything else, including fs, JSON or spawn
+// errors, reports `unexpected-`. No path, SID or exception text is copied.
+const helperSteps = new Set(["bundle", "tools-storage", "pnpm-discovery", "wrapper-storage", "wrapper", "node-discovery", "node-storage", "entry-storage",
+ "metadata-storage", "package", "cli-proof", "tools-check", "download", "archive", "publish", "launch"]);
+const helperRejections = new Map(Object.entries({
+ "Windows bootstrap requires native Windows": "native-unavailable",
+ "Native Windows storage verification unavailable": "native-unavailable",
+ "Required wizard bundle file missing": "bundle-missing",
+ "Windows repository prerequisite rejected": "prerequisite",
+ "Unsafe Windows storage path": "unsafe-path",
+ "Windows ACL evidence rejected": "acl-evidence",
+ "Unknown Windows cwd-search semantics": "cwd-search",
+ "Unknown Windows PATH/PATHEXT": "path-missing",
+ "Unknown Windows PATHEXT semantics": "pathext",
+ "Unknown Windows PATH": "path-entry",
+ "Unknown extensionless Windows prerequisite": "extensionless",
+ "Unknown pnpm wrapper; refusing replacement": "wrapper-unknown",
+ "Unknown pnpm wrapper": "wrapper-unproven",
+ "Unknown pnpm wrapper; refusing execution or replacement": "wrapper-unproven",
+ "Unknown wrapper-selected Node": "wrapper-node",
+ "Unsafe pnpm wrapper target": "wrapper-target",
+ "Windows pnpm package target rejected": "package-target",
+ "Windows pnpm engine or identity rejected": "pnpm-engine",
+ "Windows pnpm version rejected": "pnpm-version",
+ "Windows pnpm global capability rejected": "pnpm-capability",
+ "Windows prerequisite process failed": "process-failed",
+ "Unproven command interpreter target": "interpreter",
+ "Unsafe Windows tools": "unsafe-tools",
+ "Conflicting Windows pnpm destination": "pnpm-conflict",
+ "Windows pnpm pin metadata rejected": "pnpm-pin",
+ "Windows pnpm entry missing": "pnpm-entry",
+ "Windows pnpm acquisition failed; no installation completed": "acquisition",
+ "Future wizard entry is missing; no live wizard is available": "wizard-missing",
+ "Wizard child could not start": "wizard-start",
+ "Wizard child failed": "wizard-exit",
+}));
+const archiveRejection = /^Unsafe (?:tar|archive|duplicate archive|truncated tar|pnpm package root)\b/;
+export function windowsBootstrapReason(error, step) {
+ if (!helperSteps.has(step)) throw new Error("Unknown Windows bootstrap step");
+ const message = error instanceof Error ? error.message : undefined;
+ const check = error instanceof Error && storageChecks.test(error.check ?? "") ? error.check : undefined;
+ const code = check ?? helperRejections.get(message) ?? (archiveRejection.test(message ?? "") ? "archive" : undefined);
+ return code ? `${code} (${step})` : `unexpected-${step}`;
+}
+
+export async function bootstrapWindows({ bundle, tools, env }) {
+ let step = "bundle";
+ try {
+ if (process.platform !== "win32") throw new Error("Windows bootstrap requires native Windows");
+ for (const file of ["package.json", "bin/gentle-shell-install.mjs", "scripts/installer-downloads.mjs", "scripts/installer-windows.mjs", "scripts/installer-windows-artifacts.json"]) {
+ if (!regular(join(bundle, file))) throw new Error("Required wizard bundle file missing");
+ }
+ const metadata = JSON.parse(readFileSync(join(bundle, "package.json"), "utf8"));
+ if (!compatibleEngine(windowsNodeFloor, process.versions.node) || !compatibleEngine(metadata.engines?.node, process.versions.node) || metadata.packageManager !== `pnpm@${artifactFor("pnpm").version}`) throw new Error("Windows repository prerequisite rejected");
+ step = "tools-storage";
+ verifyWindowsStorage(tools, env);
+ const result = await ensureWindowsPnpm({ tools, env, onStep: (next) => { step = next; } });
+ // Fixed data handoff for the future T5 entry. No caller-controlled command text.
+ step = "launch";
+ await launchWizard({ bundle, env: { ...result.env, GENTLE_INSTALL_PNPM_NODE: result.command, GENTLE_INSTALL_PNPM_ENTRY: result.prefix[0] } });
+ } catch (error) {
+ throw Object.assign(new Error("Windows bootstrap rejected"), { reason: windowsBootstrapReason(error, step) });
+ }
+}
diff --git a/scripts/test-installer-acceptance.sh b/scripts/test-installer-acceptance.sh
new file mode 100644
index 000000000..c06320db1
--- /dev/null
+++ b/scripts/test-installer-acceptance.sh
@@ -0,0 +1,161 @@
+#!/bin/sh
+# Opt-in Linux clean-machine acceptance for the browser installation wizard.
+# Runs the real bootstrap and wizard inside a disposable debian:bookworm-slim
+# container as a non-root user. Network access happens only inside that
+# container; nothing reads or writes the host HOME. Not part of CI.
+#
+# Usage: sh scripts/test-installer-acceptance.sh [--worktree]
+# default bundle = `git archive HEAD`
+# --worktree bundle = tracked files as they are in the working tree,
+# including uncommitted modifications (untracked files excluded)
+# GENTLE_ACCEPTANCE_KEEP=1 keeps a failed run's container for inspection.
+set -eu
+fail() { printf 'acceptance: %s\n' "$1" >&2; exit 1; }
+note() { printf 'acceptance: %s\n' "$1"; }
+source_mode='head'
+case $# in
+ 0) ;;
+ 1) [ "$1" = --worktree ] || fail "unknown argument: $1"; source_mode=worktree;;
+ *) fail 'usage: sh scripts/test-installer-acceptance.sh [--worktree]';;
+esac
+image=debian:bookworm-slim
+# Upper bound for the whole installation inside the container.
+install_limit=${GENTLE_ACCEPTANCE_INSTALL_SECONDS:-1500}
+case $install_limit in ''|*[!0-9]*) fail 'GENTLE_ACCEPTANCE_INSTALL_SECONDS must be a whole number';; esac
+command -v docker >/dev/null 2>&1 || fail 'Docker is required; refusing to run anything on the host'
+docker info >/dev/null 2>&1 || fail 'Docker daemon is unavailable; refusing to run anything on the host'
+command -v git >/dev/null 2>&1 || fail 'git is required to package the bundle'
+repo=$(git rev-parse --show-toplevel) || fail 'run from inside the repository'
+started=$(date +%s)
+elapsed() { printf '%ss' "$(( $(date +%s) - started ))"; }
+name=gentle-install-acceptance-$$-$started
+container=
+passed=0
+cleanup() {
+ if [ -n "$container" ] && [ "$passed" = 0 ] && [ "${GENTLE_ACCEPTANCE_KEEP:-}" = 1 ]; then
+ printf 'acceptance: kept %s; inspect with: docker exec -it -u tester %s bash; remove with: docker rm -f %s\n' "$container" "$container" "$container" >&2
+ elif [ -n "$container" ]; then
+ docker rm -f "$container" >/dev/null 2>&1 || printf 'acceptance: could not remove container %s\n' "$container" >&2
+ fi
+}
+trap cleanup EXIT
+trap 'exit 1' HUP INT TERM
+
+note "image $image, bundle from $source_mode"
+docker run -d --name "$name" --pull missing "$image" sleep infinity >/dev/null || fail 'cannot start the container'
+container=$name
+# Clean machine: no Node, npm, pnpm, Pi or Gentle Shell. curl and CA roots are
+# bootstrap prerequisites that a stock Debian desktop already has.
+docker exec "$container" sh -c 'export DEBIAN_FRONTEND=noninteractive
+ apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends ca-certificates curl >/dev/null
+ useradd --create-home --shell /bin/bash tester
+ mkdir /opt/bundle
+ for tool in node npm pnpm pi gentle-shell; do if command -v "$tool" >/dev/null; then echo "unexpected $tool" >&2; exit 1; fi; done' \
+ || fail 'container preparation failed'
+# gentle-shell setup makes 2 anonymous GitHub API requests (upstream Gentle AI's
+# latest-engram lookup), limited to 60 per hour per public IP. /rate_limit does
+# not consume quota. Stop early instead of failing deep inside setup.
+quota=$(docker exec "$container" sh -c 'curl -fsS --max-time 20 -H "Accept: application/vnd.github+json" https://api.github.com/rate_limit' | tr -d ' \n') \
+ || fail 'cannot query the GitHub API rate limit from the container'
+rate=$(printf '%s' "$quota" | sed -n 's/.*"rate":{\([^}]*\)}.*/\1/p')
+remaining=$(printf '%s' "$rate" | sed -n 's/.*"remaining":\([0-9]*\).*/\1/p')
+reset=$(printf '%s' "$rate" | sed -n 's/.*"reset":\([0-9]*\).*/\1/p')
+[ -n "$remaining" ] && [ -n "$reset" ] || fail 'unexpected GitHub rate limit response'
+reset_at=$(date -u -d "@$reset" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || date -u -r "$reset" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || printf 'epoch %s' "$reset")
+if [ "$remaining" -lt 5 ]; then
+ fail "only $remaining anonymous GitHub API requests remain on this network (setup needs 2); the quota resets at $reset_at. Not starting."
+fi
+note "GitHub API quota: $remaining anonymous requests left (resets at $reset_at)"
+if [ "$source_mode" = head ]; then
+ git -C "$repo" archive --format=tar HEAD
+else
+ git -C "$repo" ls-files -z | tar -C "$repo" --null -T - -cf -
+fi | docker exec -i "$container" tar --no-same-owner -xf - -C /opt/bundle || fail 'cannot copy the bundle'
+note "prepared in $(elapsed)"
+
+bootstrap_started=$(date +%s)
+# A terminal session exports SHELL (the user's login shell); docker exec does
+# not. pnpm setup needs it to pick the profile to edit, and fails with
+# ERR_PNPM_UNKNOWN_SHELL without it.
+docker exec -d -u tester -w /home/tester -e SHELL=/bin/bash "$container" sh -c \
+ 'sh /opt/bundle/scripts/bootstrap.sh > "$HOME/bootstrap.log" 2>&1; echo $? > "$HOME/bootstrap.status"' \
+ || fail 'cannot start the bootstrap'
+
+# Headless browser stand-in: the same requests the wizard page makes.
+driver=$(cat <<'DRIVER'
+set -eu
+log=$HOME/bootstrap.log
+say() { printf 'driver: %s\n' "$1"; }
+stop() { say "$1"; cat "$log" 2>/dev/null || :; exit 1; }
+waited=0
+while :; do
+ url=$(grep -o 'http://127\.0\.0\.1:[0-9]*/session?code=[A-Za-z0-9_-]*' "$log" 2>/dev/null | head -n 1) || url=
+ [ -z "$url" ] || break
+ [ ! -f "$HOME/bootstrap.status" ] || stop 'bootstrap exited before printing the session URL'
+ waited=$((waited + 1)); [ "$waited" -lt 600 ] || stop 'no session URL after 600 s'
+ sleep 1
+done
+say "session URL after ${waited}s"
+origin=${url%%/session*}
+jar=$HOME/acceptance-cookies
+curl -fsS -c "$jar" -b "$jar" -o /dev/null "$url" || stop 'session redemption failed'
+api() { curl -fsS -b "$jar" -H 'X-Gentle-Install: 1' "$@"; }
+post() { api -X POST -H "Origin: $origin" -H 'Content-Type: application/json' --data "$1" "$origin$2"; }
+plan=$(api "$origin/api/plan") || stop 'plan request failed'
+case $plan in *'"blockers":[]'*) ;; *) stop "plan has blockers: $plan";; esac
+plan_id=$(printf '%s' "$plan" | sed -n 's/.*"planId":"\([^"]*\)".*/\1/p')
+[ -n "$plan_id" ] || stop "plan has no planId: $plan"
+say "plan: $(printf '%s' "$plan" | grep -o '"actions":\[[^]]*\]' | grep -o '"id":"[^"]*"' | cut -d'"' -f4 | tr '\n' ' ')"
+post "{\"planId\":\"$plan_id\",\"consent\":true}" /api/install >/dev/null || stop 'install request rejected'
+seq=0
+polls=0
+while :; do
+ progress=$(api "$origin/api/progress?after=$seq") || stop 'progress request failed'
+ printf '%s' "$progress" | grep -o '"step":"[^"]*","status":"[^"]*"' | sed 's/"step":"\([^"]*\)","status":"\([^"]*\)"/ step \1: \2/' || :
+ last=$(printf '%s' "$progress" | grep -o '"seq":[0-9]*' | tail -n 1 | cut -d: -f2) || last=
+ [ -z "$last" ] || seq=$last
+ case $progress in *'"running":false'*'"outcome":{'*) break;; esac
+ polls=$((polls + 1)); [ "$polls" -lt "$INSTALL_LIMIT" ] || stop "installation still running after $INSTALL_LIMIT s"
+ sleep 1
+done
+outcome=$(printf '%s' "$progress" | sed -n 's/.*"outcome":{"outcome":"\([a-z-]*\)".*/\1/p')
+say "outcome: $outcome"
+post '{}' /api/shutdown >/dev/null || stop 'shutdown request rejected'
+waited=0
+until [ -f "$HOME/bootstrap.status" ]; do
+ waited=$((waited + 1)); [ "$waited" -lt 60 ] || stop 'bootstrap still running 60 s after shutdown'
+ sleep 1
+done
+status=$(cat "$HOME/bootstrap.status")
+say "bootstrap exit status: $status"
+cat "$log"
+case $outcome in ready|terminal-action-required) [ "$status" = 0 ] || exit 1;; *) exit 1;; esac
+DRIVER
+)
+printf '%s\n' "$driver" | docker exec -i -u tester -w /home/tester -e INSTALL_LIMIT="$install_limit" "$container" sh -s \
+ || fail "wizard flow failed after $(elapsed)"
+note "bootstrap and wizard finished in $(( $(date +%s) - bootstrap_started ))s"
+
+# A fresh interactive shell must find the persisted stack; the bootstrap tools
+# must be gone. bash -i without a terminal warns about job control; harmless.
+checks=$(cat <<'CHECKS'
+set -eu
+for tool in node npm pnpm gentle-shell; do printf '%s -> %s\n' "$tool" "$(command -v "$tool")"; done
+printf 'node %s, npm %s, pnpm %s\n' "$(node --version)" "$(npm --version)" "$(pnpm --version)"
+gentle-shell --version
+for dir in "$HOME"/.gentle-shell-bootstrap-tools.*; do
+ if [ -e "$dir" ] || [ -L "$dir" ]; then echo "bootstrap tools left behind: $dir" >&2; exit 1; fi
+done
+echo 'bootstrap tools removed'
+CHECKS
+)
+docker exec -u tester -w /home/tester "$container" bash -ic "$checks" || fail "fresh-terminal checks failed after $(elapsed)"
+# Informational: pnpm setup edits only ~/.bashrc, which non-interactive login
+# shells skip on Debian (see docs/install-wizard.md).
+if docker exec -u tester -w /home/tester "$container" bash -lc 'command -v gentle-shell' >/dev/null 2>&1; then
+ note 'bash -lc resolves gentle-shell'
+else
+ note 'bash -lc does not resolve gentle-shell (expected: ~/.profile is not edited)'
+fi
+passed=1
+note "PASS in $(elapsed)"
diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs
index a47e1fdbe..06160ac97 100644
--- a/scripts/verify-package-files.mjs
+++ b/scripts/verify-package-files.mjs
@@ -7,8 +7,26 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const root = join(fileURLToPath(new URL("..", import.meta.url)));
-const requiredPaths = [
+// Browser installation wizard: bootstrap scripts, entry, host and static assets.
+export const installerPaths = Object.freeze([
+ "bin/gentle-shell-install.mjs",
+ "scripts/installer-server.mjs",
+ "scripts/installer-runner.mjs",
+ "scripts/installer-preflight.mjs",
+ "scripts/installer-probes.mjs",
+ "scripts/installer-downloads.mjs",
+ "scripts/installer-windows.mjs",
+ "scripts/installer-windows-artifacts.json",
+ "scripts/bootstrap.sh",
+ "scripts/bootstrap.cmd",
+ "assets/install-wizard/index.html",
+ "assets/install-wizard/wizard.js",
+ "assets/install-wizard/wizard.css",
+]);
+
+export const requiredPaths = [
"bin/gentle-shell.mjs",
+ ...installerPaths,
"extensions/gentle-notifications.ts",
"lib/notification-audio.ts",
"lib/notification-audio-native.ts",
@@ -200,6 +218,14 @@ const contractHashes = {
requiredPaths.push(...Object.keys(contractHashes));
+// Required package-relative paths that are missing or are not regular files.
+export function missingRequiredPaths(packageRoot, paths) {
+ return paths.filter((relativePath) => {
+ const absolutePath = join(packageRoot, relativePath);
+ return !existsSync(absolutePath) || !statSync(absolutePath).isFile();
+ });
+}
+
function listFilesRecursively(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const absolutePath = join(directory, entry.name);
@@ -314,10 +340,7 @@ async function main() {
process.exit(1);
}
- const missing = requiredPaths.filter((relativePath) => {
- const absolutePath = join(root, relativePath);
- return !existsSync(absolutePath) || !statSync(absolutePath).isFile();
- });
+ const missing = missingRequiredPaths(root, requiredPaths);
if (missing.length > 0) {
console.error("gentle-pi package is missing required Pi resources:");
diff --git a/tests/install-wizard.test.ts b/tests/install-wizard.test.ts
new file mode 100644
index 000000000..cc5331f8e
--- /dev/null
+++ b/tests/install-wizard.test.ts
@@ -0,0 +1,931 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { request as httpRequest } from "node:http";
+import { join } from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import * as wizard from "../assets/install-wizard/wizard.js";
+import { planPreflight, requirements } from "../scripts/installer-preflight.mjs";
+import { blockedReasons, failedSteps } from "../scripts/installer-runner.mjs";
+import { createInstallerServer, guidance } from "../scripts/installer-server.mjs";
+import { scenarioNames, startPreview } from "../scripts/install-wizard-preview.mjs";
+
+const assetsDir = fileURLToPath(new URL("../assets/install-wizard/", import.meta.url));
+const read = (name: string) => readFileSync(join(assetsDir, name), "utf8");
+const API = { "x-gentle-install": "1" };
+const BIN = "/home/u/.local/share/pnpm/bin";
+// Backtick segments render as elements, so rendered text has no backticks.
+const plain = (value: string) => value.replaceAll("`", "");
+
+// ---------------------------------------------------------------------------
+// Real HTTP helpers (the real host serving the real assets)
+// ---------------------------------------------------------------------------
+
+type Response = { status: number; headers: Record; body: string };
+function send(port: number, { method = "GET", path = "/", headers = {} as Record, body = undefined as string | undefined } = {}):
+ Promise {
+ return new Promise((resolve, reject) => {
+ const all: Record = { host: `127.0.0.1:${port}`, ...headers };
+ if (body !== undefined) all["content-length"] = Buffer.byteLength(body);
+ const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk) => chunks.push(chunk));
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") }));
+ });
+ req.on("error", reject);
+ if (body !== undefined) req.write(body);
+ req.end();
+ });
+}
+async function login(port: number, url: string) {
+ const code = new URL(url).searchParams.get("code") ?? "";
+ const response = await send(port, { path: `/session?code=${code}` });
+ assert.equal(response.status, 200);
+ return String(response.headers["set-cookie"]).split(";")[0];
+}
+function post(port: number, path: string, cookie: string, body: unknown) {
+ return send(port, { method: "POST", path, body: JSON.stringify(body), headers: { cookie, origin: `http://127.0.0.1:${port}`,
+ "content-type": "application/json", ...API } });
+}
+async function getJson(port: number, path: string, cookie: string) {
+ const response = await send(port, { path, headers: { cookie, ...API } });
+ return { status: response.status, body: JSON.parse(response.body) };
+}
+
+const cleanInventory = {
+ platform: "linux",
+ arch: "x64",
+ node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false },
+ pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false },
+ pi: { available: false },
+ shell: { available: false },
+ gentleAi: { available: false },
+ go: { available: false },
+ globalBin: { available: true, path: BIN, writable: true, onPath: false },
+ setup: false,
+};
+
+/** The real host's /api/plan view for an inventory, so model tests never drift from the server. */
+async function serverPlanView(changes: Record = {}) {
+ const inventory = { ...cleanInventory, ...changes };
+ const host = createInstallerServer({ assetsDir, collectPlan: async () => ({ inventory, plan: planPreflight(inventory) }),
+ runInstall: async () => ({ outcome: "ready", completed: [] }) });
+ const { port, url } = await host.listen();
+ try {
+ const cookie = await login(port, url);
+ const { status, body } = await getJson(port, "/api/plan", cookie);
+ assert.equal(status, 200);
+ return body;
+ } finally {
+ await host.close("test");
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Minimal fake DOM: only the APIs wizard.js may use. innerHTML throws.
+// ---------------------------------------------------------------------------
+
+class FakeText {
+ data: string;
+ parent: FakeElement | null = null;
+ constructor(data: string) {
+ this.data = data;
+ }
+ get textContent() {
+ return this.data;
+ }
+}
+class FakeElement {
+ tagName: string;
+ ownerDocument: FakeDocument;
+ attributes = new Map();
+ children: Array = [];
+ listeners = new Map unknown>>();
+ parent: FakeElement | null = null;
+ checked = false;
+ disabled = false;
+ constructor(ownerDocument: FakeDocument, tag: string) {
+ this.ownerDocument = ownerDocument;
+ this.tagName = tag.toUpperCase();
+ }
+ get textContent(): string {
+ return this.children.map((child) => child.textContent).join("");
+ }
+ set textContent(value: string) {
+ this.replaceChildren(String(value));
+ }
+ set innerHTML(_value: string) {
+ throw new Error("innerHTML is forbidden");
+ }
+ set outerHTML(_value: string) {
+ throw new Error("outerHTML is forbidden");
+ }
+ insertAdjacentHTML() {
+ throw new Error("insertAdjacentHTML is forbidden");
+ }
+ get id() {
+ return this.attributes.get("id") ?? "";
+ }
+ append(...nodes: Array) {
+ for (const node of nodes) {
+ const child = typeof node === "string" ? new FakeText(node) : node;
+ child.parent?.children.splice(child.parent.children.indexOf(child), 1);
+ child.parent = this;
+ this.children.push(child);
+ }
+ }
+ appendChild(node: FakeElement | FakeText) {
+ this.append(node);
+ return node;
+ }
+ replaceChildren(...nodes: Array) {
+ for (const child of this.children) child.parent = null;
+ this.children = [];
+ this.append(...nodes);
+ }
+ remove() {
+ this.parent?.children.splice(this.parent.children.indexOf(this), 1);
+ this.parent = null;
+ }
+ setAttribute(name: string, value: string) {
+ if (name === "style" || /^on/i.test(name)) throw new Error(`forbidden attribute ${name}`);
+ this.attributes.set(name, String(value));
+ }
+ getAttribute(name: string) {
+ return this.attributes.get(name) ?? null;
+ }
+ hasAttribute(name: string) {
+ return this.attributes.has(name);
+ }
+ removeAttribute(name: string) {
+ this.attributes.delete(name);
+ }
+ addEventListener(type: string, listener: (event: unknown) => unknown) {
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
+ }
+ focus() {
+ this.ownerDocument.activeElement = this;
+ }
+}
+class FakeDocument {
+ activeElement: FakeElement | null = null;
+ roots = new Map();
+ constructor() {
+ for (const id of ["view", "stages", "alert", "status"]) {
+ const root = new FakeElement(this, "div");
+ root.setAttribute("id", id);
+ this.roots.set(id, root);
+ }
+ }
+ createElement(tag: string) {
+ return new FakeElement(this, tag);
+ }
+ createTextNode(text: string) {
+ return new FakeText(text);
+ }
+ getElementById(id: string) {
+ return this.roots.get(id) ?? null;
+ }
+}
+
+function walk(node: FakeElement | FakeText, out: FakeElement[] = []) {
+ if (node instanceof FakeElement) {
+ out.push(node);
+ for (const child of node.children) walk(child, out);
+ }
+ return out;
+}
+const all = (root: FakeElement, tag: string) => walk(root).filter((element) => element.tagName === tag.toUpperCase());
+const button = (root: FakeElement, text: string) => all(root, "button").find((element) => element.textContent.includes(text));
+const headings = (root: FakeElement) => all(root, "h1");
+function dispatch(element: FakeElement, type: string) {
+ for (const listener of element.listeners.get(type) ?? []) listener({ type, target: element, preventDefault() {} });
+}
+async function flush() {
+ for (let i = 0; i < 30; i += 1) await new Promise((done) => setImmediate(done));
+}
+
+type Timer = { fn: () => void; ms: number; cleared: boolean; ran: boolean };
+function fakeTimers() {
+ const queue: Timer[] = [];
+ const pending = () => queue.filter((timer) => !timer.cleared && !timer.ran);
+ return {
+ setTimeout: (fn: () => void, ms: number) => {
+ const timer = { fn, ms, cleared: false, ran: false };
+ queue.push(timer);
+ return timer;
+ },
+ clearTimeout: (timer: Timer | undefined) => {
+ if (timer) timer.cleared = true;
+ },
+ pending,
+ async runNext() {
+ const [timer] = pending();
+ assert.ok(timer, "a timer is scheduled");
+ timer.ran = true;
+ timer.fn();
+ await flush();
+ return timer;
+ },
+ };
+}
+
+type Call = { url: string; method: string; headers: Record; body: unknown };
+type Reply = [number, unknown] | Error;
+function fakeFetch(handlers: Record Reply | Promise>) {
+ const calls: Call[] = [];
+ const fetch = async (url: string, init: { method?: string; headers?: Record; body?: string } = {}) => {
+ const call = { url, method: init.method ?? "GET", headers: init.headers ?? {}, body: init.body === undefined ? undefined : JSON.parse(init.body) };
+ calls.push(call);
+ const handler = handlers[`${call.method} ${url.split("?")[0]}`];
+ assert.ok(handler, `unexpected request ${call.method} ${url}`);
+ const result = await handler(call);
+ if (result instanceof Error) throw result;
+ const [status, body] = result;
+ return { status, ok: status >= 200 && status < 300, json: async () => body };
+ };
+ return { fetch, calls };
+}
+
+type Clipboard = { writeText: (value: string) => Promise } | null;
+function mount(handlers: Record Reply | Promise>, { clipboard = null as Clipboard } = {}) {
+ const document = new FakeDocument();
+ const timers = fakeTimers();
+ const server = fakeFetch(handlers);
+ const app = wizard.createWizard({ document, fetch: server.fetch, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, clipboard });
+ const view = document.roots.get("view") as FakeElement;
+ return { document, timers, server, app, view, alert: document.roots.get("alert") as FakeElement,
+ status: document.roots.get("status") as FakeElement };
+}
+const idleProgress = (): Reply => [200, { entries: [], running: false, outcome: null }];
+const outcomeProgress = (outcome: Record): Reply => [200, { entries: [], running: false, outcome }];
+const readyOutcome = { outcome: "ready", completed: ["install-global"], guidance: guidance.outcomes.ready };
+/** Ticks the consent checkbox on the review screen and presses Install. */
+async function consentAndInstall(ui: ReturnType) {
+ const consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement;
+ consent.checked = true;
+ dispatch(consent, "change");
+ dispatch(button(ui.view, "Install") as FakeElement, "click");
+ await flush();
+}
+const posts = (ui: ReturnType, path: string) => ui.server.calls.filter((call) => call.method === "POST" && call.url === path).length;
+const progressChecks = (ui: ReturnType) => ui.server.calls.filter((call) => call.url.startsWith("/api/progress")).length;
+
+// ---------------------------------------------------------------------------
+// Static assets under the strict CSP
+// ---------------------------------------------------------------------------
+
+test("index.html loads only same-origin wizard.js and wizard.css, with no inline script, style or handlers", () => {
+ const html = read("index.html");
+ assert.match(html, /