gentle-pi 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -8
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +703 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/bundled-install.md +397 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +81 -31
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +82 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +267 -40
- package/docs/render-cache-performance.md +28 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +9 -9
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +688 -88
- package/extensions/gentle-ai.ts +886 -238
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +265 -69
- package/extensions/gentle-todo.ts +108 -13
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-classifier.ts +280 -0
- package/lib/auto-permission-runtime.ts +268 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +517 -0
- package/lib/gentle-shell-launcher.ts +74 -23
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +18 -6
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +266 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +86 -71
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +62 -19
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +7 -3
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +1 -1
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +518 -0
- package/runtime/gentle-shell-launcher.mjs +73 -22
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/auto-mode-invariants.test.ts +258 -0
- package/tests/auto-mode-sdk.test.ts +57 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-classifier.test.ts +283 -0
- package/tests/auto-permission-runtime.test.ts +196 -0
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +288 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1722 -59
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +249 -5
- package/tests/gentle-shell-bundled.test.ts +1002 -0
- package/tests/gentle-shell-launcher.test.ts +234 -13
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +541 -96
- package/tests/gentle-todo.test.ts +253 -2
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2158 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +389 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +101 -17
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +30 -0
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +381 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +61 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +120 -2
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +7 -7
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +32 -4
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import * as fsPromises from "node:fs/promises";
|
|
5
|
+
import { request as httpRequest } from "node:http";
|
|
6
|
+
import { connect } from "node:net";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { exitCodeFor, openBrowser, openerFor, redirectPage, runnerEnvironment, upgradeEnvironment, wizardHandlers, writeRedirect } from "../bin/gentle-shell-install.mjs";
|
|
11
|
+
import { planPreflight, requirements } from "../scripts/installer-preflight.mjs";
|
|
12
|
+
import { PI_INSTALL_VERSION, blockedReasons, failedSteps } from "../scripts/installer-runner.mjs";
|
|
13
|
+
import { createInstallerServer, guidance } from "../scripts/installer-server.mjs";
|
|
14
|
+
import { distributionAsset, prefixLayout } from "../scripts/bundled-install.mjs";
|
|
15
|
+
import { bundledBlockedReasons, bundledFailedSteps, bundledPlan } from "../scripts/bundled-wizard.mjs";
|
|
16
|
+
|
|
17
|
+
type Response = { status: number; headers: Record<string, string | string[] | undefined>; body: string };
|
|
18
|
+
type Plan = ReturnType<typeof planPreflight>;
|
|
19
|
+
type Collected = { inventory: Record<string, unknown>; plan: Plan };
|
|
20
|
+
type Log = (entry: Record<string, unknown>) => void;
|
|
21
|
+
type RunInstall = (request: { plan: Plan; consent: boolean }, log: Log) => Promise<Record<string, unknown>>;
|
|
22
|
+
|
|
23
|
+
const BIN = "/home/u/.local/share/pnpm/bin";
|
|
24
|
+
// Every /api/* request carries the custom header, so cross-origin fetches need a preflight.
|
|
25
|
+
const API = { "x-gentle-install": "1" };
|
|
26
|
+
const cleanInventory = {
|
|
27
|
+
platform: "linux",
|
|
28
|
+
arch: "x64",
|
|
29
|
+
node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false },
|
|
30
|
+
pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false },
|
|
31
|
+
pi: { available: false },
|
|
32
|
+
shell: { available: false },
|
|
33
|
+
gentleAi: { available: false },
|
|
34
|
+
go: { available: false },
|
|
35
|
+
globalBin: { available: true, path: BIN, writable: true, onPath: false },
|
|
36
|
+
setup: false,
|
|
37
|
+
};
|
|
38
|
+
function collected(changes: Record<string, unknown> = {}): Collected {
|
|
39
|
+
const inventory = { ...cleanInventory, ...changes };
|
|
40
|
+
return { inventory, plan: planPreflight(inventory) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const assetsDir = mkdtempSync(join(tmpdir(), "gentle-install-server-"));
|
|
44
|
+
writeFileSync(join(assetsDir, "index.html"), "<!doctype html><title>fixture</title>\n");
|
|
45
|
+
writeFileSync(join(assetsDir, "wizard.js"), "// fixture\n");
|
|
46
|
+
test.after(() => rmSync(assetsDir, { recursive: true, force: true }));
|
|
47
|
+
|
|
48
|
+
function send(port: number, { method = "GET", path = "/", headers = {} as Record<string, string>, body = undefined as string | undefined,
|
|
49
|
+
host = `127.0.0.1:${port}` as string | null } = {}): Promise<Response> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const all: Record<string, string | number> = { ...headers };
|
|
52
|
+
if (host !== null) all.host = host;
|
|
53
|
+
if (body !== undefined) all["content-length"] = Buffer.byteLength(body);
|
|
54
|
+
const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => {
|
|
55
|
+
const chunks: Buffer[] = [];
|
|
56
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
57
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") }));
|
|
58
|
+
});
|
|
59
|
+
req.on("error", reject);
|
|
60
|
+
if (body !== undefined) req.write(body);
|
|
61
|
+
req.end();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function start({ collect = (async () => collected()) as (channel: string) => Promise<Collected>, runInstall = (async () => ({ outcome: "ready", completed: [] })) as RunInstall,
|
|
66
|
+
limits = {}, clock = { value: 1_000_000 } } = {}) {
|
|
67
|
+
let collects = 0;
|
|
68
|
+
const channels: string[] = [];
|
|
69
|
+
const runs: Array<{ request: { plan: Plan; consent: boolean } }> = [];
|
|
70
|
+
const host = createInstallerServer({
|
|
71
|
+
assetsDir,
|
|
72
|
+
collectPlan: async (channel: string) => {
|
|
73
|
+
collects += 1;
|
|
74
|
+
channels.push(channel);
|
|
75
|
+
return collect(channel);
|
|
76
|
+
},
|
|
77
|
+
runInstall: async (request: { plan: Plan; consent: boolean }, log: Log) => {
|
|
78
|
+
runs.push({ request });
|
|
79
|
+
return runInstall(request, log);
|
|
80
|
+
},
|
|
81
|
+
now: () => clock.value,
|
|
82
|
+
limits,
|
|
83
|
+
});
|
|
84
|
+
const { port, url, address } = await host.listen();
|
|
85
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
86
|
+
const code = new URL(url).searchParams.get("code") ?? "";
|
|
87
|
+
const login = async () => {
|
|
88
|
+
const response = await send(port, { path: `/session?code=${code}` });
|
|
89
|
+
assert.equal(response.status, 200);
|
|
90
|
+
const cookie = String(response.headers["set-cookie"]).split(";")[0];
|
|
91
|
+
return cookie;
|
|
92
|
+
};
|
|
93
|
+
return { host, port, url, address, origin, code, login, runs, clock, collects: () => collects, channels };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function post(port: number, path: string, cookie: string, body: unknown, headers: Record<string, string> = {}) {
|
|
97
|
+
return send(port, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
path,
|
|
100
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
101
|
+
headers: { cookie, origin: `http://127.0.0.1:${port}`, "x-gentle-install": "1", "content-type": "application/json", ...headers },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function plan(port: number, cookie: string) {
|
|
105
|
+
const response = await send(port, { path: "/api/plan", headers: { cookie, ...API } });
|
|
106
|
+
assert.equal(response.status, 200);
|
|
107
|
+
return JSON.parse(response.body);
|
|
108
|
+
}
|
|
109
|
+
function deferred<T>() {
|
|
110
|
+
let resolve!: (value: T) => void;
|
|
111
|
+
const promise = new Promise<T>((done) => { resolve = done; });
|
|
112
|
+
return { promise, resolve };
|
|
113
|
+
}
|
|
114
|
+
async function waitFor(check: () => Promise<boolean> | boolean) {
|
|
115
|
+
for (let i = 0; i < 200; i += 1) {
|
|
116
|
+
if (await check()) return;
|
|
117
|
+
await new Promise((done) => setTimeout(done, 5));
|
|
118
|
+
}
|
|
119
|
+
throw new Error("condition not reached");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test("binds only 127.0.0.1 on an ephemeral port and prints a one-time session URL", async () => {
|
|
123
|
+
const { host, port, url, address } = await start();
|
|
124
|
+
try {
|
|
125
|
+
assert.equal(address, "127.0.0.1");
|
|
126
|
+
assert.ok(port > 0);
|
|
127
|
+
assert.match(url, new RegExp(`^http://127\\.0\\.0\\.1:${port}/session\\?code=[A-Za-z0-9_-]{43}$`));
|
|
128
|
+
const ipv6 = await new Promise((resolve) => {
|
|
129
|
+
const socket = connect({ host: "::1", port });
|
|
130
|
+
socket.once("connect", () => { socket.destroy(); resolve("connected"); });
|
|
131
|
+
socket.once("error", () => resolve("refused"));
|
|
132
|
+
});
|
|
133
|
+
assert.equal(ipv6, "refused");
|
|
134
|
+
} finally {
|
|
135
|
+
await host.close("test");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("Host must be exactly 127.0.0.1:<port> before any authorization", async () => {
|
|
140
|
+
const { host, port, login } = await start();
|
|
141
|
+
try {
|
|
142
|
+
const cookie = await login();
|
|
143
|
+
for (const value of [`localhost:${port}`, `evil:${port}`, "127.0.0.1", `127.0.0.1:${port + 1}`, `[::1]:${port}`, null]) {
|
|
144
|
+
const response = await send(port, { path: "/api/plan", headers: { cookie, ...API }, host: value });
|
|
145
|
+
assert.equal(response.status, 421, String(value));
|
|
146
|
+
assert.doesNotMatch(response.body, /actions|planId/);
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
await host.close("test");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("session code is single-use, expires, and sets a strict HttpOnly cookie", async () => {
|
|
154
|
+
const fresh = await start();
|
|
155
|
+
try {
|
|
156
|
+
const response = await send(fresh.port, { path: `/session?code=${fresh.code}` });
|
|
157
|
+
// 200 with a same-origin refresh, not a 303: a redirect chain started from the
|
|
158
|
+
// file: redirect page is cross-site, so the Strict cookie could be withheld on `/`.
|
|
159
|
+
assert.equal(response.status, 200);
|
|
160
|
+
assert.equal(response.headers.location, undefined);
|
|
161
|
+
assert.equal(response.headers["content-type"], "text/html; charset=utf-8");
|
|
162
|
+
assert.equal(response.headers["content-security-policy"],
|
|
163
|
+
"default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'");
|
|
164
|
+
assert.ok(response.body.includes('<meta http-equiv="refresh" content="0; url=/">'));
|
|
165
|
+
assert.ok(!/<script|<style|\sstyle=/i.test(response.body));
|
|
166
|
+
const cookie = String(response.headers["set-cookie"]);
|
|
167
|
+
assert.match(cookie, /^gentle_install_session=[A-Za-z0-9_-]{43}; HttpOnly; SameSite=Strict; Path=\/$/);
|
|
168
|
+
assert.doesNotMatch(cookie, /Domain=/i);
|
|
169
|
+
const reused = await send(fresh.port, { path: `/session?code=${fresh.code}` });
|
|
170
|
+
assert.equal(reused.status, 401);
|
|
171
|
+
assert.equal(reused.headers["set-cookie"], undefined);
|
|
172
|
+
const wrong = await send(fresh.port, { path: "/session?code=nope" });
|
|
173
|
+
assert.equal(wrong.status, 401);
|
|
174
|
+
} finally {
|
|
175
|
+
await fresh.host.close("test");
|
|
176
|
+
}
|
|
177
|
+
const late = await start({ limits: { codeTtlMs: 120_000 } });
|
|
178
|
+
try {
|
|
179
|
+
late.clock.value += 120_001;
|
|
180
|
+
const expired = await send(late.port, { path: `/session?code=${late.code}` });
|
|
181
|
+
assert.equal(expired.status, 401);
|
|
182
|
+
assert.equal(expired.headers["set-cookie"], undefined);
|
|
183
|
+
} finally {
|
|
184
|
+
await late.host.close("test");
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("API and asset requests need the session cookie", async () => {
|
|
189
|
+
const { host, port, login } = await start();
|
|
190
|
+
try {
|
|
191
|
+
for (const path of ["/", "/wizard.js", "/api/plan", "/api/progress"]) {
|
|
192
|
+
assert.equal((await send(port, { path })).status, 401, path);
|
|
193
|
+
assert.equal((await send(port, { path, headers: { cookie: "gentle_install_session=forged" } })).status, 401, path);
|
|
194
|
+
}
|
|
195
|
+
const cookie = await login();
|
|
196
|
+
const page = await send(port, { path: "/", headers: { cookie } });
|
|
197
|
+
assert.equal(page.status, 200);
|
|
198
|
+
assert.match(String(page.headers["content-type"]), /^text\/html/);
|
|
199
|
+
assert.equal(page.body, readFileSync(join(assetsDir, "index.html"), "utf8"));
|
|
200
|
+
const script = await send(port, { path: "/wizard.js", headers: { cookie } });
|
|
201
|
+
assert.equal(script.status, 200);
|
|
202
|
+
assert.match(String(script.headers["content-type"]), /^text\/javascript/);
|
|
203
|
+
assert.equal((await send(port, { path: "/wizard.css", headers: { cookie } })).status, 404);
|
|
204
|
+
} finally {
|
|
205
|
+
await host.close("test");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("POST needs the exact Origin and custom header; GET rejects a foreign Origin", async () => {
|
|
210
|
+
const { host, port, login, runs } = await start();
|
|
211
|
+
try {
|
|
212
|
+
const cookie = await login();
|
|
213
|
+
const { planId } = await plan(port, cookie);
|
|
214
|
+
const body = { planId, consent: true };
|
|
215
|
+
assert.equal((await post(port, "/api/install", cookie, body, { origin: "http://evil.test" })).status, 403);
|
|
216
|
+
assert.equal((await post(port, "/api/install", cookie, body, { origin: `http://localhost:${port}` })).status, 403);
|
|
217
|
+
assert.equal((await post(port, "/api/install", cookie, body, { origin: "null" })).status, 403);
|
|
218
|
+
const noOrigin = await send(port, { method: "POST", path: "/api/install", body: JSON.stringify(body),
|
|
219
|
+
headers: { cookie, "x-gentle-install": "1", "content-type": "application/json" } });
|
|
220
|
+
assert.equal(noOrigin.status, 403);
|
|
221
|
+
const noHeader = await send(port, { method: "POST", path: "/api/install", body: JSON.stringify(body),
|
|
222
|
+
headers: { cookie, origin: `http://127.0.0.1:${port}`, "content-type": "application/json" } });
|
|
223
|
+
assert.equal(noHeader.status, 403);
|
|
224
|
+
assert.equal((await post(port, "/api/install", cookie, body, { "x-gentle-install": "true" })).status, 403);
|
|
225
|
+
assert.equal((await post(port, "/api/install", cookie, body, { "content-type": "text/plain" })).status, 415);
|
|
226
|
+
assert.equal((await send(port, { path: "/api/plan", headers: { cookie, ...API, origin: "http://evil.test" } })).status, 403);
|
|
227
|
+
assert.equal(runs.length, 0);
|
|
228
|
+
} finally {
|
|
229
|
+
await host.close("test");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("/api/plan is built server-side and explains profile and runtime persistence", async () => {
|
|
234
|
+
const { host, port, login, collects } = await start();
|
|
235
|
+
try {
|
|
236
|
+
const cookie = await login();
|
|
237
|
+
const view = await plan(port, cookie);
|
|
238
|
+
assert.equal(collects(), 1);
|
|
239
|
+
assert.match(view.planId, /^[A-Za-z0-9_-]{16,}$/);
|
|
240
|
+
assert.deepEqual(view.actions.map((action: { id: string }) => action.id), collected().plan.actions.map((action) => action.id));
|
|
241
|
+
for (const action of view.actions) {
|
|
242
|
+
assert.deepEqual(Object.keys(action).sort(), ["description", "id"]);
|
|
243
|
+
assert.ok(action.description.length > 10);
|
|
244
|
+
}
|
|
245
|
+
assert.equal(view.profileChange.changesProfile, true);
|
|
246
|
+
assert.equal(view.profileChange.binDir, BIN);
|
|
247
|
+
assert.equal(view.profileChange.command, "pnpm setup");
|
|
248
|
+
assert.match(view.profileChange.description, /pnpm setup/);
|
|
249
|
+
assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm"]);
|
|
250
|
+
assert.equal(view.persistence.pnpmHome, "/home/u/.local/share/pnpm");
|
|
251
|
+
assert.match(view.persistence.description, /PNPM_HOME/);
|
|
252
|
+
assert.deepEqual(view.blockers, []);
|
|
253
|
+
// The plan endpoint accepts no client input.
|
|
254
|
+
assert.equal((await post(port, "/api/plan", cookie, { plan: collected().plan })).status, 405);
|
|
255
|
+
assert.equal((await send(port, { path: "/api/plan?plan=x", headers: { cookie, ...API } })).status, 400);
|
|
256
|
+
} finally {
|
|
257
|
+
await host.close("test");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("/api/plan lists only Node.js and npm for persistence when a newer pnpm 11 is already in $PNPM_HOME/bin", async () => {
|
|
262
|
+
const { host, port, login, runs } = await start({ collect: async () => collected({
|
|
263
|
+
pnpm: { available: true, version: "11.5.0", usable: true, compatible: true, persistent: true, inGlobalBin: true },
|
|
264
|
+
}) });
|
|
265
|
+
try {
|
|
266
|
+
const view = await plan(port, await login());
|
|
267
|
+
assert.deepEqual(view.blockers, []);
|
|
268
|
+
assert.deepEqual(view.actions.map((action: { id: string }) => action.id).filter((id: string) => /^(persist-|configure-npm)/.test(id)),
|
|
269
|
+
["persist-node", "persist-npm", "configure-npm-prefix"]);
|
|
270
|
+
assert.deepEqual(view.persistence.tools, ["node", "npm"]);
|
|
271
|
+
assert.ok(view.persistence.description.startsWith("node, npm will be installed under $PNPM_HOME"), view.persistence.description);
|
|
272
|
+
assert.deepEqual(runs, []);
|
|
273
|
+
} finally {
|
|
274
|
+
await host.close("test");
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("/api/plan reports blockers with guidance and no profile change when already on PATH", async () => {
|
|
279
|
+
const { host, port, login } = await start({ collect: async () => collected({
|
|
280
|
+
node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true },
|
|
281
|
+
pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true },
|
|
282
|
+
globalBin: { available: true, path: BIN, writable: true, onPath: true },
|
|
283
|
+
pi: { available: null },
|
|
284
|
+
}) });
|
|
285
|
+
try {
|
|
286
|
+
const view = await plan(port, await login());
|
|
287
|
+
assert.deepEqual(view.actions, []);
|
|
288
|
+
assert.equal(view.blockers.length, 1);
|
|
289
|
+
assert.equal(view.blockers[0].code, "unknown-tool");
|
|
290
|
+
assert.equal(view.blockers[0].tool, "pi");
|
|
291
|
+
assert.ok(view.blockers[0].guidance.length > 20);
|
|
292
|
+
assert.equal(view.profileChange.changesProfile, false);
|
|
293
|
+
assert.deepEqual(view.persistence.tools, []);
|
|
294
|
+
} finally {
|
|
295
|
+
await host.close("test");
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("/api/plan names the found and required version of an older Gentle Shell and how to resolve it", async () => {
|
|
300
|
+
const { host, port, login } = await start({ collect: async () => collected({
|
|
301
|
+
pi: { available: true, version: "1.0.4", usable: true },
|
|
302
|
+
shell: { available: true, version: "3.4.0", usable: false, global: true },
|
|
303
|
+
gentleAi: { available: null }, setup: { available: null },
|
|
304
|
+
}) });
|
|
305
|
+
try {
|
|
306
|
+
const view = await plan(port, await login());
|
|
307
|
+
assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
|
|
308
|
+
assert.equal(view.blockers[0].guidance,
|
|
309
|
+
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.1.0 or newer. Nothing was replaced. " +
|
|
310
|
+
"Update it with `pnpm add -g gentle-pi@4.1.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
|
|
311
|
+
assert.deepEqual(view.actions, []);
|
|
312
|
+
} finally {
|
|
313
|
+
await host.close("test");
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("/api/plan installs the installer's Pi alongside an older Pi that neither pnpm nor npm manages, and says so before consent", async () => {
|
|
318
|
+
const { host, port, login, runs } = await start({ collect: async () => collected({ pi: { available: true, version: "0.99.0", usable: true, external: true } }) });
|
|
319
|
+
try {
|
|
320
|
+
const view = await plan(port, await login());
|
|
321
|
+
assert.deepEqual(view.blockers, []);
|
|
322
|
+
const install = view.actions.find((action: { id: string }) => action.id === "install-pi");
|
|
323
|
+
assert.equal(install.description, `Install Pi ${PI_INSTALL_VERSION} globally with pnpm for Gentle Shell. The Pi 0.99.0 already on this computer ` +
|
|
324
|
+
"was not installed with pnpm or npm, so it is left unchanged. Gentle Shell uses the installer's Pi; the `pi` command in a terminal may still run the older one.");
|
|
325
|
+
assert.deepEqual(runs, []);
|
|
326
|
+
} finally {
|
|
327
|
+
await host.close("test");
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("/api/plan shows an older Pi's update with its found and target versions and package manager, before consent", async () => {
|
|
332
|
+
for (const owner of ["pnpm", "npm"]) {
|
|
333
|
+
const { host, port, login, runs } = await start({ collect: async () => collected({ pi: { available: true, version: "0.87.1", usable: true, owner } }) });
|
|
334
|
+
try {
|
|
335
|
+
const view = await plan(port, await login());
|
|
336
|
+
assert.deepEqual(view.blockers, []);
|
|
337
|
+
const update = view.actions.find((action: { id: string }) => action.id === "update-pi");
|
|
338
|
+
assert.equal(update.description, `Update Pi 0.87.1 to ${PI_INSTALL_VERSION} with ${owner}, the package manager that installed it. ` +
|
|
339
|
+
`Gentle Shell needs Pi ${requirements.pi} or newer.`);
|
|
340
|
+
assert.ok(view.actions.findIndex((action: { id: string }) => action.id === "update-pi") <
|
|
341
|
+
view.actions.findIndex((action: { id: string }) => action.id === "install-shell"));
|
|
342
|
+
assert.deepEqual(runs, []);
|
|
343
|
+
} finally {
|
|
344
|
+
await host.close("test");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("/api/plan says, before consent, that an older Node and an incompatible pnpm stay unchanged next to the pinned copies", async () => {
|
|
350
|
+
const { host, port, login, runs } = await start({ collect: async () => collected({
|
|
351
|
+
node: { available: true, version: "24.21.0", usable: true, persistent: false, npm: false, found: "22.18.0" },
|
|
352
|
+
pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false, found: "10.27.0" },
|
|
353
|
+
}) });
|
|
354
|
+
try {
|
|
355
|
+
const view = await plan(port, await login());
|
|
356
|
+
assert.deepEqual(view.blockers, []);
|
|
357
|
+
assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm"]);
|
|
358
|
+
const text = view.persistence.description;
|
|
359
|
+
assert.ok(text.includes(`Node.js 22.18.0 on this computer is older than ${requirements.node}, so the installer uses its pinned Node.js 24.21.0 ` +
|
|
360
|
+
"and installs it under $PNPM_HOME. Your Node.js 22.18.0 is left unchanged."), text);
|
|
361
|
+
assert.ok(text.includes(`pnpm 10.27.0 on this computer is not a pnpm ${requirements.pnpm.split(".")[0]} release this installer can use, ` +
|
|
362
|
+
`so the installer uses its pinned pnpm ${requirements.pnpm} and installs it under $PNPM_HOME. Your pnpm 10.27.0 is left unchanged.`), text);
|
|
363
|
+
// No PATH precedence is promised: a version manager may put its own copy first again.
|
|
364
|
+
assert.ok(text.includes("A version manager such as `mise activate` may still put your own versions first on PATH in new terminals."), text);
|
|
365
|
+
assert.deepEqual(runs, []);
|
|
366
|
+
} finally {
|
|
367
|
+
await host.close("test");
|
|
368
|
+
}
|
|
369
|
+
// Without a found version the description is unchanged.
|
|
370
|
+
const plain = await start();
|
|
371
|
+
try {
|
|
372
|
+
assert.doesNotMatch((await plan(plain.port, await plain.login())).persistence.description, /left unchanged|version manager/);
|
|
373
|
+
} finally {
|
|
374
|
+
await plain.host.close("test");
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("/api/plan says before consent which Go is downloaded, only to build, with the Go found and the system unchanged", async () => {
|
|
379
|
+
for (const [go, found] of [[{ available: true, version: "1.24.0", usable: true }, "Go 1.24.0 on this computer is older than 1.25.10"],
|
|
380
|
+
[{ available: false }, "Go is missing on this computer"]] as const) {
|
|
381
|
+
const { host, port, login } = await start({ collect: async () => collectedFor("main", { ...mainReady, go }) });
|
|
382
|
+
try {
|
|
383
|
+
const view = await plan(port, await login());
|
|
384
|
+
assert.deepEqual(view.blockers, []);
|
|
385
|
+
const acquire = view.actions.find((action: { id: string }) => action.id === "acquire-go");
|
|
386
|
+
assert.equal(acquire.description, `${found}, so the installer downloads Go 1.25.14 from go.dev, verifies its pinned SHA-256 checksum ` +
|
|
387
|
+
"and uses it only to build Gentle AI. It is kept in the installer's own folder (~/.pi/gentle-ai/tools/go); your Go, PATH and shell profile are not changed.");
|
|
388
|
+
assert.equal(view.actions.find((action: { id: string }) => action.id === "verify-go").description,
|
|
389
|
+
"Check that the downloaded Go runs and reports the pinned version.");
|
|
390
|
+
} finally {
|
|
391
|
+
await host.close("test");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("/api/plan says before consent that a pnpm in $PNPM_HOME/bin that could not be checked is left as it is", async () => {
|
|
397
|
+
const note = "The pnpm in the pnpm global bin directory could not be checked, so it was left as it is, and the installer uses its own verified pnpm for this installation.";
|
|
398
|
+
const pnpm = { available: true, version: requirements.pnpm, usable: true, compatible: true, persistent: true, inGlobalBin: true, unchecked: true };
|
|
399
|
+
const cases = [
|
|
400
|
+
[{ available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, [], `No runtime needs to be installed under $PNPM_HOME. ${note}`],
|
|
401
|
+
[{ available: true, version: "24.21.0", usable: true, persistent: false, npm: false }, ["node", "npm"], null],
|
|
402
|
+
] as const;
|
|
403
|
+
for (const [node, tools, exact] of cases) {
|
|
404
|
+
const { host, port, login, runs } = await start({ collect: async () => collected({ node, pnpm }) });
|
|
405
|
+
try {
|
|
406
|
+
const view = await plan(port, await login());
|
|
407
|
+
assert.deepEqual(view.blockers, []);
|
|
408
|
+
assert.equal(view.ready, false);
|
|
409
|
+
assert.deepEqual(view.persistence.tools, tools);
|
|
410
|
+
if (exact) assert.equal(view.persistence.description, exact);
|
|
411
|
+
else assert.ok(view.persistence.description.endsWith(` ${note}`), view.persistence.description);
|
|
412
|
+
assert.deepEqual(runs, []);
|
|
413
|
+
} finally {
|
|
414
|
+
await host.close("test");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// A checked pnpm there adds nothing.
|
|
418
|
+
const checked = await start({ collect: async () => collected({ pnpm: { ...pnpm, unchecked: undefined } }) });
|
|
419
|
+
try {
|
|
420
|
+
assert.doesNotMatch((await plan(checked.port, await checked.login())).persistence.description, /could not be checked/);
|
|
421
|
+
} finally {
|
|
422
|
+
await checked.host.close("test");
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("/api/plan explains a pnpm in $PNPM_HOME/bin that the installer will not replace, with how to update it", async () => {
|
|
427
|
+
const inGlobalBin = (pnpm: object) => ({ pnpm: { ...pnpm, inGlobalBin: true } });
|
|
428
|
+
const cases = [
|
|
429
|
+
[inGlobalBin({ available: true, version: "11.0.5", usable: true, compatible: false, persistent: true }), "incompatible-tool",
|
|
430
|
+
`pnpm 11.0.5 is installed in the pnpm global bin directory, where this installer would put its own pnpm, but this installer needs pnpm ${requirements.pnpm} or newer. ` +
|
|
431
|
+
`Nothing was replaced. Update it with \`pnpm self-update ${requirements.pnpm}\`, then select Check again.`],
|
|
432
|
+
[inGlobalBin({ available: null }), "unknown-tool",
|
|
433
|
+
"A pnpm is installed in the pnpm global bin directory, where this installer would put its own pnpm, but its version could not be checked. " +
|
|
434
|
+
`Nothing was replaced. If \`pnpm --version\` reports a version older than ${requirements.pnpm}, update it with \`pnpm self-update ${requirements.pnpm}\`, then select Check again.`],
|
|
435
|
+
] as const;
|
|
436
|
+
for (const [change, code, text] of cases) {
|
|
437
|
+
const { host, port, login } = await start({ collect: async () => collected({ ...change, node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true } }) });
|
|
438
|
+
try {
|
|
439
|
+
const view = await plan(port, await login());
|
|
440
|
+
assert.deepEqual(view.actions, []);
|
|
441
|
+
const blocker = view.blockers.find((item: { tool: string }) => item.tool === "pnpm");
|
|
442
|
+
assert.equal(blocker.code, code);
|
|
443
|
+
assert.equal(blocker.guidance, text);
|
|
444
|
+
} finally {
|
|
445
|
+
await host.close("test");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("/api/plan explains a Gentle Shell that neither pnpm nor npm manages", async () => {
|
|
451
|
+
const { host, port, login } = await start({ collect: async () => collected({
|
|
452
|
+
pi: { available: true, version: "1.0.4", usable: true },
|
|
453
|
+
shell: { available: null, outsidePnpm: true }, gentleAi: { available: null }, setup: { available: null },
|
|
454
|
+
}) });
|
|
455
|
+
try {
|
|
456
|
+
const view = await plan(port, await login());
|
|
457
|
+
assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["unknown-tool", "shell"]]);
|
|
458
|
+
assert.equal(view.blockers[0].guidance,
|
|
459
|
+
"Gentle Shell is already installed, but neither pnpm nor npm manages it (a linked source checkout, for example), " +
|
|
460
|
+
"so this installer cannot update it. Nothing was replaced. Update it the way you installed it, then select Check again.");
|
|
461
|
+
} finally {
|
|
462
|
+
await host.close("test");
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("/api/plan explains a Pi whose version cannot be read", async () => {
|
|
467
|
+
const { host, port, login } = await start({ collect: async () => collected({ pi: { available: null, outsidePnpm: true } }) });
|
|
468
|
+
try {
|
|
469
|
+
const view = await plan(port, await login());
|
|
470
|
+
assert.equal(view.blockers.find((item: { tool: string }) => item.tool === "pi").guidance,
|
|
471
|
+
"Pi is already installed, but `pi --version` did not report a version this installer can check. Make sure `pi --version` works in a terminal, then select Check again.");
|
|
472
|
+
} finally {
|
|
473
|
+
await host.close("test");
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("/api/plan says that pnpm may put a newer Pi next to Gentle Shell, at least the minimum", async () => {
|
|
478
|
+
// A newer Pi installed with pnpm never blocks: it is kept as it is.
|
|
479
|
+
const { host, port, login } = await start({ collect: async () => collected({ pi: { available: true, version: "1.1.0", usable: true } }) });
|
|
480
|
+
try {
|
|
481
|
+
const view = await plan(port, await login());
|
|
482
|
+
assert.deepEqual(view.blockers, []);
|
|
483
|
+
assert.equal(view.actions.find((action: { id: string }) => action.id === "install-shell").description,
|
|
484
|
+
`Install Gentle Shell (gentle-pi) globally with pnpm. pnpm may put a newer Pi than ${PI_INSTALL_VERSION} next to it ` +
|
|
485
|
+
`(at least Pi ${requirements.pi}), and Gentle Shell runs that Pi.`);
|
|
486
|
+
} finally {
|
|
487
|
+
await host.close("test");
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("the outcome says which Pi Gentle Shell runs only when it is not the installer's Pi", async () => {
|
|
492
|
+
const cases: [object, string][] = [
|
|
493
|
+
[{ outcome: "ready", completed: [], piVersion: "1.1.0" }, `${guidance.outcomes.ready} Gentle Shell runs Pi 1.1.0, which pnpm installed next to it.`],
|
|
494
|
+
[{ outcome: "terminal-action-required", action: "open-new-terminal", completed: [], piVersion: "1.1.0" },
|
|
495
|
+
`${guidance.outcomes["terminal-action-required"]} Gentle Shell runs Pi 1.1.0, which pnpm installed next to it.`],
|
|
496
|
+
[{ outcome: "ready", completed: [] }, guidance.outcomes.ready],
|
|
497
|
+
[{ outcome: "ready", completed: [], piVersion: PI_INSTALL_VERSION }, guidance.outcomes.ready],
|
|
498
|
+
[{ outcome: "ready", completed: [], piVersion: "1.1.0 <script>" }, guidance.outcomes.ready],
|
|
499
|
+
];
|
|
500
|
+
for (const [result, expected] of cases) {
|
|
501
|
+
const { host, port, login } = await start({ runInstall: async () => result as never });
|
|
502
|
+
try {
|
|
503
|
+
const cookie = await login();
|
|
504
|
+
const { planId } = await plan(port, cookie);
|
|
505
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
506
|
+
await waitFor(() => host.outcome() !== null);
|
|
507
|
+
assert.equal(host.outcome()?.guidance, expected, JSON.stringify(result));
|
|
508
|
+
assert.equal("piVersion" in (host.outcome() ?? {}), false);
|
|
509
|
+
} finally {
|
|
510
|
+
await host.close("test");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("/api/plan describes updating an existing Gentle Shell on either channel", async () => {
|
|
516
|
+
const shell = { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" };
|
|
517
|
+
const { host, port, login } = await start({ collect: async (channel) => collectedFor(channel, { pi: { available: true, version: "1.0.4", usable: true },
|
|
518
|
+
shell, go: { available: true, version: "1.26.0", usable: true }, gentleAi: { available: null }, setup: { available: null } }) });
|
|
519
|
+
try {
|
|
520
|
+
const cookie = await login();
|
|
521
|
+
const release = await plan(port, cookie);
|
|
522
|
+
assert.deepEqual(release.actions.map((action: { id: string; description: string }) => [action.id, action.description]), [
|
|
523
|
+
["update-shell-release", "Update Gentle Shell to the latest release with the package manager that installed it (pnpm or npm)."],
|
|
524
|
+
["setup-shell", release.actions[1].description],
|
|
525
|
+
["verify-readiness", "Verify that the installed stack is ready."]]);
|
|
526
|
+
const main = JSON.parse((await send(port, { path: "/api/plan?channel=main", headers: { cookie, ...API } })).body);
|
|
527
|
+
assert.equal(main.actions[0].description,
|
|
528
|
+
"Update Gentle Shell and Gentle AI to the latest commits of `main`, built on this computer, with the package manager that installed Gentle Shell.");
|
|
529
|
+
} finally {
|
|
530
|
+
await host.close("test");
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("/api/plan keeps the generic guidance for an unknown tool without more evidence", async () => {
|
|
535
|
+
const { host, port, login } = await start({ collect: async () => collected({ shell: { available: null } }) });
|
|
536
|
+
try {
|
|
537
|
+
const view = await plan(port, await login());
|
|
538
|
+
const blocker = view.blockers.find((item: { tool: string }) => item.tool === "shell");
|
|
539
|
+
assert.equal(blocker.guidance, guidance.blockers["unknown-tool"]);
|
|
540
|
+
} finally {
|
|
541
|
+
await host.close("test");
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("/api/plan keeps the generic guidance when no older version is known", async () => {
|
|
546
|
+
const { host, port, login } = await start({ collect: async () => collected({
|
|
547
|
+
gentleAi: { available: true, version: "1.0.0", usable: true, compatible: false } }) });
|
|
548
|
+
try {
|
|
549
|
+
const view = await plan(port, await login());
|
|
550
|
+
const blocker = view.blockers.find((item: { tool: string }) => item.tool === "gentleAi");
|
|
551
|
+
assert.equal(blocker.guidance, guidance.blockers["incompatible-tool"]);
|
|
552
|
+
} finally {
|
|
553
|
+
await host.close("test");
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const mainReady = { pi: { available: false }, shell: { available: false }, go: { available: true, version: "1.26.0", usable: true } };
|
|
558
|
+
function collectedFor(channel: string, changes: Record<string, unknown> = mainReady): Collected {
|
|
559
|
+
const inventory = { ...cleanInventory, ...changes };
|
|
560
|
+
return { inventory, plan: planPreflight(inventory, { channel }) };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
test("/api/plan plans the release channel by default and the main channel on request", async () => {
|
|
564
|
+
const { host, port, login, channels } = await start({ collect: async (channel) => collectedFor(channel) });
|
|
565
|
+
try {
|
|
566
|
+
const cookie = await login();
|
|
567
|
+
const release = await plan(port, cookie);
|
|
568
|
+
assert.equal(release.channel, "release");
|
|
569
|
+
const response = await send(port, { path: "/api/plan?channel=main", headers: { cookie, ...API } });
|
|
570
|
+
assert.equal(response.status, 200);
|
|
571
|
+
const main = JSON.parse(response.body);
|
|
572
|
+
assert.equal(main.channel, "main");
|
|
573
|
+
const described = Object.fromEntries(main.actions.map((action: { id: string; description: string }) => [action.id, action.description]));
|
|
574
|
+
assert.equal(described["build-gentle-ai-main"], "Build Gentle AI from the latest commit of its `main` branch with Go, verified by Go's checksum database, and use it instead of the pinned release binary.");
|
|
575
|
+
assert.equal(described["install-shell-main"], "Install Gentle Shell from the latest commit of its `main` branch with pnpm, replacing the release package.");
|
|
576
|
+
assert.equal(described["record-channel"], "Remember the `main` channel, so `gentle-shell upgrade` keeps following `main`.");
|
|
577
|
+
assert.deepEqual(channels, ["release", "main"]);
|
|
578
|
+
} finally {
|
|
579
|
+
await host.close("test");
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("/api/plan rejects any other channel or query", async () => {
|
|
584
|
+
const { host, port, login, collects } = await start({ collect: async (channel) => collectedFor(channel) });
|
|
585
|
+
try {
|
|
586
|
+
const cookie = await login();
|
|
587
|
+
for (const query of ["channel=nightly", "channel=main&x=1", "x=main", "channel=main&channel=release", "channel="]) {
|
|
588
|
+
assert.equal((await send(port, { path: `/api/plan?${query}`, headers: { cookie, ...API } })).status, 400, query);
|
|
589
|
+
}
|
|
590
|
+
assert.equal(collects(), 0);
|
|
591
|
+
} finally {
|
|
592
|
+
await host.close("test");
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("installing a main plan re-checks the computer on the main channel", async () => {
|
|
597
|
+
const { host, port, login, channels, runs } = await start({ collect: async (channel) => collectedFor(channel) });
|
|
598
|
+
try {
|
|
599
|
+
const cookie = await login();
|
|
600
|
+
const response = await send(port, { path: "/api/plan?channel=main", headers: { cookie, ...API } });
|
|
601
|
+
const view = JSON.parse(response.body);
|
|
602
|
+
assert.equal((await post(port, "/api/install", cookie, { consent: true, planId: view.planId })).status, 202);
|
|
603
|
+
assert.deepEqual(channels, ["main", "main"]);
|
|
604
|
+
assert.ok(runs[0].request.plan.actions.some((action: { id: string }) => action.id === "install-shell-main"));
|
|
605
|
+
} finally {
|
|
606
|
+
await host.close("test");
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("/api/plan offers the pinned Go on the main channel for a Go it cannot check", async () => {
|
|
611
|
+
const { host, port, login } = await start({ collect: async (channel) => collectedFor(channel, { pi: { available: false }, shell: { available: false }, go: { available: null } }) });
|
|
612
|
+
try {
|
|
613
|
+
const cookie = await login();
|
|
614
|
+
const view = JSON.parse((await send(port, { path: "/api/plan?channel=main", headers: { cookie, ...API } })).body);
|
|
615
|
+
assert.equal(view.blockers.some((item: { tool: string }) => item.tool === "go"), false);
|
|
616
|
+
const ids = view.actions.map((action: { id: string }) => action.id);
|
|
617
|
+
assert.equal(ids.indexOf("verify-go"), ids.indexOf("acquire-go") + 1);
|
|
618
|
+
assert.equal(view.actions.find((action: { id: string }) => action.id === "acquire-go").description, "The Go on this computer did not report a version this installer can check, so the installer downloads Go 1.25.14 " +
|
|
619
|
+
"from go.dev, verifies its pinned SHA-256 checksum and uses it only to build Gentle AI. It is kept in the installer's own folder " +
|
|
620
|
+
"(~/.pi/gentle-ai/tools/go); your Go, PATH and shell profile are not changed.");
|
|
621
|
+
} finally {
|
|
622
|
+
await host.close("test");
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("/api/plan keeps the preflight's camelCase tool names, but no other tool text", async () => {
|
|
627
|
+
const { host, port, login } = await start({ collect: async () => {
|
|
628
|
+
const result = collected({ gentleAi: { available: true, version: "1.0.0", usable: true, compatible: false },
|
|
629
|
+
globalBin: { available: true, path: BIN, writable: false, onPath: true } });
|
|
630
|
+
result.plan.blockers.push({ code: "unknown-tool", tool: "Evil<tool>" }, { code: "unknown-tool", tool: "x".repeat(65) });
|
|
631
|
+
return result;
|
|
632
|
+
} });
|
|
633
|
+
try {
|
|
634
|
+
const view = await plan(port, await login());
|
|
635
|
+
assert.deepEqual(view.blockers.map((blocker: { tool: string }) => blocker.tool), ["gentleAi", "globalBin", "unknown", "unknown"]);
|
|
636
|
+
} finally {
|
|
637
|
+
await host.close("test");
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("install body must be exactly { planId, consent: true }; the runner is not called otherwise", async () => {
|
|
642
|
+
const { host, port, login, runs } = await start();
|
|
643
|
+
try {
|
|
644
|
+
const cookie = await login();
|
|
645
|
+
const { planId } = await plan(port, cookie);
|
|
646
|
+
for (const body of [
|
|
647
|
+
{ planId },
|
|
648
|
+
{ planId, consent: false },
|
|
649
|
+
{ planId, consent: "true" },
|
|
650
|
+
{ planId, consent: true, extra: 1 },
|
|
651
|
+
{ planId, consent: true, plan: { actions: [] } },
|
|
652
|
+
{ consent: true },
|
|
653
|
+
[planId, true],
|
|
654
|
+
"not json",
|
|
655
|
+
"null",
|
|
656
|
+
]) {
|
|
657
|
+
const response = await post(port, "/api/install", cookie, body);
|
|
658
|
+
assert.equal(response.status, 400, JSON.stringify(body));
|
|
659
|
+
}
|
|
660
|
+
const oversized = await post(port, "/api/install", cookie, JSON.stringify({ planId, consent: true, pad: "x".repeat(2048) }));
|
|
661
|
+
assert.equal(oversized.status, 413);
|
|
662
|
+
// Without Content-Length, the streamed body is bounded too.
|
|
663
|
+
const chunked = await new Promise<number>((resolve, reject) => {
|
|
664
|
+
const req = httpRequest({ host: "127.0.0.1", port, method: "POST", path: "/api/install", agent: false, headers: {
|
|
665
|
+
cookie, origin: `http://127.0.0.1:${port}`, "x-gentle-install": "1", "content-type": "application/json",
|
|
666
|
+
"transfer-encoding": "chunked" } }, (res) => {
|
|
667
|
+
res.resume();
|
|
668
|
+
resolve(res.statusCode ?? 0);
|
|
669
|
+
});
|
|
670
|
+
req.on("error", reject);
|
|
671
|
+
req.write(`{"planId":"${planId}","pad":"${"x".repeat(700)}`);
|
|
672
|
+
req.end(`${"y".repeat(700)}","consent":true}`);
|
|
673
|
+
});
|
|
674
|
+
assert.equal(chunked, 413);
|
|
675
|
+
// A whole client plan never fits the 1 KiB body bound.
|
|
676
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true, plan: collected().plan })).status, 413);
|
|
677
|
+
assert.equal(runs.length, 0);
|
|
678
|
+
} finally {
|
|
679
|
+
await host.close("test");
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("stale planId or a changed re-inventory returns 409 plan-changed without running", async () => {
|
|
684
|
+
let current = collected();
|
|
685
|
+
const { host, port, login, runs } = await start({ collect: async () => current });
|
|
686
|
+
try {
|
|
687
|
+
const cookie = await login();
|
|
688
|
+
const first = await plan(port, cookie);
|
|
689
|
+
const second = await plan(port, cookie);
|
|
690
|
+
assert.notEqual(first.planId, second.planId);
|
|
691
|
+
const stale = await post(port, "/api/install", cookie, { planId: first.planId, consent: true });
|
|
692
|
+
assert.equal(stale.status, 409);
|
|
693
|
+
assert.equal(JSON.parse(stale.body).error, "plan-changed");
|
|
694
|
+
current = collected({ globalBin: { available: true, path: BIN, writable: true, onPath: true } });
|
|
695
|
+
const changed = await post(port, "/api/install", cookie, { planId: second.planId, consent: true });
|
|
696
|
+
assert.equal(changed.status, 409);
|
|
697
|
+
assert.equal(JSON.parse(changed.body).error, "plan-changed");
|
|
698
|
+
assert.equal(runs.length, 0);
|
|
699
|
+
} finally {
|
|
700
|
+
await host.close("test");
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// A2: the reused-folder notice is advisory. A re-inventory whose walk timed out (no
|
|
705
|
+
// notice), or found another one, still installs the consented plan: no 409 loop.
|
|
706
|
+
test("a re-inventory that differs only in the reused-folder notice still installs the consented plan", async () => {
|
|
707
|
+
const weak = { check: "parent-acl-mask", at: "C:\\Users\\m\\AppData\\Roaming" };
|
|
708
|
+
const windows = { platform: "win32", node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true },
|
|
709
|
+
pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true }, go: { available: true, version: "1.26.0", usable: true } };
|
|
710
|
+
for (const later of [{}, { folders: { node: { check: "unchecked", at: "C:\\nodejs\\node.exe" } } }]) {
|
|
711
|
+
let current = collected({ ...windows, folders: { node: weak } });
|
|
712
|
+
const { host, port, login, runs } = await start({ collect: async () => current });
|
|
713
|
+
try {
|
|
714
|
+
const cookie = await login();
|
|
715
|
+
const { planId } = await plan(port, cookie);
|
|
716
|
+
current = collected({ ...windows, ...later });
|
|
717
|
+
const response = await post(port, "/api/install", cookie, { planId, consent: true });
|
|
718
|
+
assert.equal(response.status, 202, response.body);
|
|
719
|
+
assert.equal(runs.length, 1);
|
|
720
|
+
assert.deepEqual(runs[0].request.plan.tools.folders?.reused.map((entry: { tool: string }) => entry.tool), ["node"], "the consented plan runs");
|
|
721
|
+
} finally {
|
|
722
|
+
await host.close("test");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Any other change still stops it.
|
|
726
|
+
let current = collected({ ...windows, folders: { node: weak } });
|
|
727
|
+
const { host, port, login, runs } = await start({ collect: async () => current });
|
|
728
|
+
try {
|
|
729
|
+
const cookie = await login();
|
|
730
|
+
const { planId } = await plan(port, cookie);
|
|
731
|
+
current = collected({ ...windows, folders: { node: weak }, globalBin: { available: true, path: BIN, writable: true, onPath: true } });
|
|
732
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 409);
|
|
733
|
+
assert.equal(runs.length, 0);
|
|
734
|
+
} finally {
|
|
735
|
+
await host.close("test");
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("install runs the server-stored plan once, single-flight, and reports progress with guidance", async () => {
|
|
740
|
+
const gate = deferred<void>();
|
|
741
|
+
const runInstall: RunInstall = async (_request, log) => {
|
|
742
|
+
log({ step: "check-npm", status: "done" });
|
|
743
|
+
await gate.promise;
|
|
744
|
+
log({ step: "install-global", status: "failed" });
|
|
745
|
+
return { outcome: "failed", failedStep: "install-global", completed: ["check-npm"] };
|
|
746
|
+
};
|
|
747
|
+
const { host, port, login, runs } = await start({ runInstall });
|
|
748
|
+
try {
|
|
749
|
+
const cookie = await login();
|
|
750
|
+
const { planId } = await plan(port, cookie);
|
|
751
|
+
const started = await post(port, "/api/install", cookie, { planId, consent: true });
|
|
752
|
+
assert.equal(started.status, 202);
|
|
753
|
+
assert.equal(runs.length, 1);
|
|
754
|
+
assert.deepEqual(runs[0].request, { plan: collected().plan, consent: true });
|
|
755
|
+
const again = await post(port, "/api/install", cookie, { planId, consent: true });
|
|
756
|
+
assert.equal(again.status, 409);
|
|
757
|
+
assert.equal(JSON.parse(again.body).error, "install-running");
|
|
758
|
+
assert.equal((await send(port, { path: "/api/plan", headers: { cookie, ...API } })).status, 409);
|
|
759
|
+
const shutdown = await post(port, "/api/shutdown", cookie, {});
|
|
760
|
+
assert.equal(shutdown.status, 409);
|
|
761
|
+
gate.resolve();
|
|
762
|
+
let progress: { entries: Array<Record<string, unknown>>; running: boolean; outcome: Record<string, unknown> | null } = {
|
|
763
|
+
entries: [], running: true, outcome: null };
|
|
764
|
+
await waitFor(async () => {
|
|
765
|
+
progress = JSON.parse((await send(port, { path: "/api/progress?after=0", headers: { cookie, ...API } })).body);
|
|
766
|
+
return progress.running === false;
|
|
767
|
+
});
|
|
768
|
+
assert.deepEqual(progress.entries, [
|
|
769
|
+
{ seq: 1, step: "check-npm", status: "done", reason: null },
|
|
770
|
+
{ seq: 2, step: "install-global", status: "failed", reason: null },
|
|
771
|
+
]);
|
|
772
|
+
assert.equal(progress.outcome?.outcome, "failed");
|
|
773
|
+
assert.equal(progress.outcome?.failedStep, "install-global");
|
|
774
|
+
assert.equal(progress.outcome?.guidance, guidance.failed["install-global"]);
|
|
775
|
+
const later = JSON.parse((await send(port, { path: "/api/progress?after=1", headers: { cookie, ...API } })).body);
|
|
776
|
+
assert.deepEqual(later.entries.map((entry: { seq: number }) => entry.seq), [2]);
|
|
777
|
+
assert.equal((await send(port, { path: "/api/progress?after=-1", headers: { cookie, ...API } })).status, 400);
|
|
778
|
+
assert.equal((await send(port, { path: "/api/progress?after=1&x=2", headers: { cookie, ...API } })).status, 400);
|
|
779
|
+
assert.equal(host.outcome()?.outcome, "failed");
|
|
780
|
+
} finally {
|
|
781
|
+
gate.resolve();
|
|
782
|
+
await host.close("test");
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("progress log is a bounded ring buffer with restricted fields", async () => {
|
|
787
|
+
const runInstall: RunInstall = async (_request, log) => {
|
|
788
|
+
for (let i = 0; i < 300; i += 1) log({ step: `step-${i}`, status: "done", stdout: "secret /home/u/.npmrc", reason: undefined });
|
|
789
|
+
log({ step: "Bad Step; rm -rf", status: "done\n", reason: "x".repeat(500) });
|
|
790
|
+
return { outcome: "blocked", reason: "existing-stack", completed: [], stdout: "secret" };
|
|
791
|
+
};
|
|
792
|
+
const { host, port, login } = await start({ runInstall });
|
|
793
|
+
try {
|
|
794
|
+
const cookie = await login();
|
|
795
|
+
const { planId } = await plan(port, cookie);
|
|
796
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
797
|
+
let progress: { entries: Array<Record<string, unknown>>; running: boolean; outcome: Record<string, unknown> } | null = null;
|
|
798
|
+
await waitFor(async () => {
|
|
799
|
+
progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
800
|
+
return progress?.running === false;
|
|
801
|
+
});
|
|
802
|
+
const { entries, outcome } = progress!;
|
|
803
|
+
assert.equal(entries.length, 200);
|
|
804
|
+
assert.equal(entries[0].seq, 102);
|
|
805
|
+
for (const entry of entries) assert.deepEqual(Object.keys(entry).sort(), ["reason", "seq", "status", "step"]);
|
|
806
|
+
assert.deepEqual(entries.at(-1), { seq: 301, step: "unknown", status: "unknown", reason: "unknown" });
|
|
807
|
+
assert.doesNotMatch(JSON.stringify(progress), /secret|npmrc/);
|
|
808
|
+
assert.equal(outcome.outcome, "blocked");
|
|
809
|
+
assert.equal(outcome.reason, "existing-stack");
|
|
810
|
+
assert.equal(outcome.guidance, guidance.blocked["existing-stack"]);
|
|
811
|
+
} finally {
|
|
812
|
+
await host.close("test");
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("a throwing runner becomes a failed outcome with generic guidance", async () => {
|
|
817
|
+
const { host, port, login } = await start({ runInstall: async () => { throw new Error("/home/u/secret path"); } });
|
|
818
|
+
try {
|
|
819
|
+
const cookie = await login();
|
|
820
|
+
const { planId } = await plan(port, cookie);
|
|
821
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
822
|
+
await waitFor(() => host.outcome() !== null);
|
|
823
|
+
const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
824
|
+
assert.equal(progress.outcome.outcome, "failed");
|
|
825
|
+
assert.equal(progress.outcome.guidance, guidance.fallback);
|
|
826
|
+
assert.doesNotMatch(JSON.stringify(progress), /secret/);
|
|
827
|
+
} finally {
|
|
828
|
+
await host.close("test");
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("a runner result that throws while being viewed still ends the installation as failed", async () => {
|
|
833
|
+
const clock = { value: 5_000 };
|
|
834
|
+
const hostile = { outcome: "failed", completed: [], get failedStep(): string { throw new Error("/home/u/secret getter"); } };
|
|
835
|
+
const { host, port, login } = await start({ clock, limits: { idleMs: 60_000 }, runInstall: async () => hostile });
|
|
836
|
+
try {
|
|
837
|
+
const cookie = await login();
|
|
838
|
+
const { planId } = await plan(port, cookie);
|
|
839
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
840
|
+
await waitFor(() => host.outcome() !== null);
|
|
841
|
+
const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
842
|
+
assert.equal(progress.running, false);
|
|
843
|
+
assert.deepEqual(progress.outcome, { outcome: "failed", failedStep: null, completed: [], guidance: guidance.fallback });
|
|
844
|
+
assert.doesNotMatch(JSON.stringify(progress), /secret/);
|
|
845
|
+
// installing was reset, so the idle timeout can close the wizard again.
|
|
846
|
+
clock.value += 60_000;
|
|
847
|
+
assert.equal(host.checkIdle(), true);
|
|
848
|
+
} finally {
|
|
849
|
+
await host.close("test");
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test("a failed setup passes only its bounded detail through, with rate-limit guidance when it matches", async () => {
|
|
854
|
+
const rateLimited = "Error: execute install pipeline: download engram binary: fetch latest engram version: GitHub API returned HTTP 403";
|
|
855
|
+
const cases = [
|
|
856
|
+
{ detail: rateLimited, guidance: guidance.setupRateLimit, shown: rateLimited },
|
|
857
|
+
{ detail: "Error: API rate limit exceeded for 203.0.113.7", guidance: guidance.setupRateLimit, shown: "Error: API rate limit exceeded for 203.0.113.7" },
|
|
858
|
+
{ detail: "Error: disk full", guidance: guidance.failed["shell-setup"], shown: "Error: disk full" },
|
|
859
|
+
// GitHub alone or 403 alone is not a rate limit.
|
|
860
|
+
{ detail: "Error: GitHub API returned HTTP 500", guidance: guidance.failed["shell-setup"], shown: "Error: GitHub API returned HTTP 500" },
|
|
861
|
+
// The host bounds the text again: control characters out, at most 300 characters.
|
|
862
|
+
{ detail: `bad\u0000\u001b[31m${"x".repeat(400)}`, guidance: guidance.failed["shell-setup"], shown: `bad[31m${"x".repeat(293)}` },
|
|
863
|
+
{ detail: " \n ", guidance: guidance.failed["shell-setup"], shown: undefined },
|
|
864
|
+
{ detail: 42, guidance: guidance.failed["shell-setup"], shown: undefined },
|
|
865
|
+
];
|
|
866
|
+
for (const item of cases) {
|
|
867
|
+
const { host, port, login } = await start({ runInstall: async () => ({ outcome: "failed", failedStep: "shell-setup", completed: [], detail: item.detail, stderrTail: "secret raw output" }) });
|
|
868
|
+
try {
|
|
869
|
+
const cookie = await login();
|
|
870
|
+
const { planId } = await plan(port, cookie);
|
|
871
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
872
|
+
await waitFor(() => host.outcome() !== null);
|
|
873
|
+
const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
874
|
+
assert.equal(progress.outcome.failedStep, "shell-setup");
|
|
875
|
+
assert.equal(progress.outcome.guidance, item.guidance, String(item.detail));
|
|
876
|
+
assert.equal(progress.outcome.detail, item.shown);
|
|
877
|
+
assert.doesNotMatch(JSON.stringify(progress), /secret raw output/);
|
|
878
|
+
} finally {
|
|
879
|
+
await host.close("test");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// A detail on any other step never reaches the browser.
|
|
883
|
+
const { host, port, login } = await start({ runInstall: async () => ({ outcome: "failed", failedStep: "record-channel", completed: [], detail: rateLimited }) });
|
|
884
|
+
try {
|
|
885
|
+
const cookie = await login();
|
|
886
|
+
const { planId } = await plan(port, cookie);
|
|
887
|
+
await post(port, "/api/install", cookie, { planId, consent: true });
|
|
888
|
+
await waitFor(() => host.outcome() !== null);
|
|
889
|
+
const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
890
|
+
assert.equal("detail" in progress.outcome, false);
|
|
891
|
+
assert.equal(progress.outcome.guidance, guidance.failed["record-channel"]);
|
|
892
|
+
} finally {
|
|
893
|
+
await host.close("test");
|
|
894
|
+
}
|
|
895
|
+
// pnpm setup's detail passes through too; an unknown or unsupported SHELL gets its own guidance.
|
|
896
|
+
for (const [detail, expected] of [
|
|
897
|
+
["[ERR_PNPM_UNKNOWN_SHELL] Could not infer shell type.", guidance.persistPathShell],
|
|
898
|
+
['[ERR_PNPM_UNSUPPORTED_SHELL] Can\'t setup configuration for "tcsh" shell', guidance.persistPathShell],
|
|
899
|
+
["Error: EACCES: permission denied, open '~/.bashrc'", guidance.failed["persist-path"]],
|
|
900
|
+
[rateLimited, guidance.failed["persist-path"]],
|
|
901
|
+
]) {
|
|
902
|
+
const run = await start({ runInstall: async () => ({ outcome: "failed", failedStep: "persist-path", completed: [], detail }) });
|
|
903
|
+
try {
|
|
904
|
+
const cookie = await run.login();
|
|
905
|
+
const { planId } = await plan(run.port, cookie);
|
|
906
|
+
await post(run.port, "/api/install", cookie, { planId, consent: true });
|
|
907
|
+
await waitFor(() => run.host.outcome() !== null);
|
|
908
|
+
const progress = JSON.parse((await send(run.port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
909
|
+
assert.equal(progress.outcome.detail, detail);
|
|
910
|
+
assert.equal(progress.outcome.guidance, expected, detail);
|
|
911
|
+
} finally {
|
|
912
|
+
await run.host.close("test");
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// A failed Go download passes its folder conflict through, with guidance to remove that folder.
|
|
916
|
+
const conflict = "Conflicting Go destination: ~/.pi/gentle-ai/tools/go/1.25.14";
|
|
917
|
+
for (const [detail, expected] of [[conflict, guidance.goDestinationConflict], ["Error: size", guidance.failed["acquire-go"]]]) {
|
|
918
|
+
const run = await start({ runInstall: async () => ({ outcome: "failed", failedStep: "acquire-go", completed: [], detail }) });
|
|
919
|
+
try {
|
|
920
|
+
const cookie = await run.login();
|
|
921
|
+
const { planId } = await plan(run.port, cookie);
|
|
922
|
+
await post(run.port, "/api/install", cookie, { planId, consent: true });
|
|
923
|
+
await waitFor(() => run.host.outcome() !== null);
|
|
924
|
+
const progress = JSON.parse((await send(run.port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
925
|
+
assert.equal(progress.outcome.detail, detail);
|
|
926
|
+
assert.equal(progress.outcome.guidance, expected, detail);
|
|
927
|
+
} finally {
|
|
928
|
+
await run.host.close("test");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// The install, main-channel and update steps pass their detail through; GitHub's anonymous
|
|
932
|
+
// API limit (a main commit GitHub refused with HTTP 403) gets the rate-limit guidance.
|
|
933
|
+
const mainLimited = "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-shell (HTTP 403)";
|
|
934
|
+
for (const [step, detail, expected] of [
|
|
935
|
+
["install-global", ".../gentle-pi postinstall: root cause Error: go: open ~/x: The directory name is invalid.", guidance.failed["install-global"]],
|
|
936
|
+
["install-global", rateLimited, guidance.githubRateLimit],
|
|
937
|
+
["install-shell-main", mainLimited, guidance.githubRateLimit],
|
|
938
|
+
["install-shell-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-shell (HTTP 500)", guidance.failed["install-shell-main"]],
|
|
939
|
+
["build-gentle-ai-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-ai (HTTP 403)", guidance.githubRateLimit],
|
|
940
|
+
["build-gentle-ai-main", "golang.org/x/sys/cpu: fork/exec ~/asm.exe: The directory name is invalid.", guidance.failed["build-gentle-ai-main"]],
|
|
941
|
+
["update-shell", mainLimited, guidance.githubRateLimit],
|
|
942
|
+
["update-shell", "tar: Error is not recoverable: exiting now", guidance.failed["update-shell"]],
|
|
943
|
+
]) {
|
|
944
|
+
const run = await start({ runInstall: async () => ({ outcome: "failed", failedStep: step, completed: [], detail }) });
|
|
945
|
+
try {
|
|
946
|
+
const cookie = await run.login();
|
|
947
|
+
const { planId } = await plan(run.port, cookie);
|
|
948
|
+
await post(run.port, "/api/install", cookie, { planId, consent: true });
|
|
949
|
+
await waitFor(() => run.host.outcome() !== null);
|
|
950
|
+
const progress = JSON.parse((await send(run.port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
951
|
+
assert.equal(progress.outcome.detail, detail, step);
|
|
952
|
+
assert.equal(progress.outcome.guidance, expected, `${step}: ${detail}`);
|
|
953
|
+
} finally {
|
|
954
|
+
await run.host.close("test");
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
assert.match(guidance.githubRateLimit, /GitHub/);
|
|
958
|
+
assert.match(guidance.githubRateLimit, /hour/);
|
|
959
|
+
assert.doesNotMatch(guidance.githubRateLimit, /gentle-shell setup|token|credential|password|log in|sign in/i);
|
|
960
|
+
assert.match(guidance.goDestinationConflict, /earlier/);
|
|
961
|
+
assert.match(guidance.goDestinationConflict, /Remove that folder/);
|
|
962
|
+
assert.doesNotMatch(guidance.goDestinationConflict, /network/);
|
|
963
|
+
assert.match(guidance.persistPathShell, /SHELL/);
|
|
964
|
+
assert.match(guidance.persistPathShell, /regular terminal/);
|
|
965
|
+
assert.match(guidance.persistPathShell, /\$PNPM_HOME\/bin/);
|
|
966
|
+
assert.match(guidance.setupRateLimit, /GitHub/);
|
|
967
|
+
assert.match(guidance.setupRateLimit, /hour/);
|
|
968
|
+
assert.doesNotMatch(guidance.setupRateLimit, /token|credential|password|log in|sign in/i);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("only the fixed allowlist is routed: traversal and unknown paths 404, wrong methods 405", async () => {
|
|
972
|
+
const { host, port, login } = await start();
|
|
973
|
+
try {
|
|
974
|
+
const cookie = await login();
|
|
975
|
+
for (const path of ["/../package.json", "/%2e%2e/package.json", "/..%2fpackage.json", "/api%2fplan", "/api/run", "/index.html/..",
|
|
976
|
+
"/wizard.js/../../package.json", "//wizard.js", "/api/plan/", "/favicon.ico", "/assets/index.html"]) {
|
|
977
|
+
const response = await send(port, { path, headers: { cookie } });
|
|
978
|
+
assert.equal(response.status, 404, path);
|
|
979
|
+
assert.doesNotMatch(response.body, /gentle-pi|"version"/);
|
|
980
|
+
}
|
|
981
|
+
assert.equal((await post(port, "/api/run", cookie, {})).status, 404);
|
|
982
|
+
assert.equal((await send(port, { method: "DELETE", path: "/api/plan", headers: { cookie, ...API } })).status, 405);
|
|
983
|
+
assert.equal((await send(port, { method: "GET", path: "/api/install", headers: { cookie } })).status, 405);
|
|
984
|
+
assert.equal((await send(port, { method: "GET", path: "/api/shutdown", headers: { cookie } })).status, 405);
|
|
985
|
+
assert.equal((await post(port, "/", cookie, {})).status, 405);
|
|
986
|
+
assert.equal((await send(port, { method: "PUT", path: "/session?code=x" })).status, 405);
|
|
987
|
+
} finally {
|
|
988
|
+
await host.close("test");
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("every response carries strict headers and never CORS headers", async () => {
|
|
993
|
+
const { host, port, login } = await start();
|
|
994
|
+
try {
|
|
995
|
+
const cookie = await login();
|
|
996
|
+
const responses = [
|
|
997
|
+
await send(port, { path: "/", headers: { cookie } }),
|
|
998
|
+
await send(port, { path: "/api/plan", headers: { cookie, ...API } }),
|
|
999
|
+
await send(port, { path: "/api/plan" }),
|
|
1000
|
+
await send(port, { path: "/nope", headers: { cookie } }),
|
|
1001
|
+
await send(port, { path: "/", host: "evil:1" }),
|
|
1002
|
+
await send(port, { method: "OPTIONS", path: "/api/install",
|
|
1003
|
+
headers: { cookie, origin: "http://evil.test", "access-control-request-method": "POST" } }),
|
|
1004
|
+
await send(port, { path: "/session?code=bad" }),
|
|
1005
|
+
];
|
|
1006
|
+
for (const response of responses) {
|
|
1007
|
+
assert.equal(response.headers["content-security-policy"],
|
|
1008
|
+
"default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'");
|
|
1009
|
+
assert.equal(response.headers["x-content-type-options"], "nosniff");
|
|
1010
|
+
assert.equal(response.headers["cache-control"], "no-store");
|
|
1011
|
+
assert.equal(response.headers["referrer-policy"], "no-referrer");
|
|
1012
|
+
assert.equal(response.headers["x-frame-options"], "DENY");
|
|
1013
|
+
assert.equal(response.headers["cross-origin-resource-policy"], "same-origin");
|
|
1014
|
+
assert.equal(response.headers["cross-origin-opener-policy"], "same-origin");
|
|
1015
|
+
assert.deepEqual(Object.keys(response.headers).filter((name) => name.startsWith("access-control-")), []);
|
|
1016
|
+
}
|
|
1017
|
+
assert.equal(responses[5].status, 405);
|
|
1018
|
+
} finally {
|
|
1019
|
+
await host.close("test");
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
test("shutdown closes the server; closed reports the last outcome", async () => {
|
|
1024
|
+
const { host, port, login } = await start();
|
|
1025
|
+
const cookie = await login();
|
|
1026
|
+
const { planId } = await plan(port, cookie);
|
|
1027
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
1028
|
+
await waitFor(() => host.outcome() !== null);
|
|
1029
|
+
const response = await post(port, "/api/shutdown", cookie, {});
|
|
1030
|
+
assert.equal(response.status, 200);
|
|
1031
|
+
const closed = await host.closed;
|
|
1032
|
+
assert.equal(closed.reason, "shutdown");
|
|
1033
|
+
assert.equal(closed.outcome?.outcome, "ready");
|
|
1034
|
+
await assert.rejects(send(port, { path: "/api/plan", headers: { cookie, ...API } }));
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("idle timeout closes the server, but never while installing", async () => {
|
|
1038
|
+
const gate = deferred<void>();
|
|
1039
|
+
const clock = { value: 5_000 };
|
|
1040
|
+
const { host, port, login } = await start({ clock, limits: { idleMs: 60_000 }, runInstall: async () => {
|
|
1041
|
+
await gate.promise;
|
|
1042
|
+
return { outcome: "ready", completed: [] };
|
|
1043
|
+
} });
|
|
1044
|
+
try {
|
|
1045
|
+
const cookie = await login();
|
|
1046
|
+
const { planId } = await plan(port, cookie);
|
|
1047
|
+
clock.value += 59_000;
|
|
1048
|
+
assert.equal(host.checkIdle(), false);
|
|
1049
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
1050
|
+
clock.value += 120_000;
|
|
1051
|
+
assert.equal(host.checkIdle(), false);
|
|
1052
|
+
gate.resolve();
|
|
1053
|
+
await waitFor(() => host.outcome() !== null);
|
|
1054
|
+
clock.value += 60_000;
|
|
1055
|
+
assert.equal(host.checkIdle(), true);
|
|
1056
|
+
const closed = await host.closed;
|
|
1057
|
+
assert.equal(closed.reason, "idle");
|
|
1058
|
+
} finally {
|
|
1059
|
+
gate.resolve();
|
|
1060
|
+
await host.close("test");
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("guidance covers every runner blocked reason and failed step", () => {
|
|
1065
|
+
assert.ok(blockedReasons.length >= 13 && failedSteps.length >= 14);
|
|
1066
|
+
for (const reason of blockedReasons) assert.ok(typeof guidance.blocked[reason] === "string" && guidance.blocked[reason].length > 20, reason);
|
|
1067
|
+
for (const step of failedSteps) assert.ok(typeof guidance.failed[step] === "string" && guidance.failed[step].length > 20, step);
|
|
1068
|
+
// No stale guidance for a reason or step the runners (standard and bundled) no longer have.
|
|
1069
|
+
assert.deepEqual(Object.keys(guidance.blocked).sort(), [...new Set([...blockedReasons, ...bundledBlockedReasons])].sort());
|
|
1070
|
+
assert.deepEqual(Object.keys(guidance.failed).sort(), [...new Set([...failedSteps, ...bundledFailedSteps])].sort());
|
|
1071
|
+
for (const code of ["unsupported-target", "unknown-tool", "incompatible-tool"]) assert.ok(guidance.blockers[code].length > 20, code);
|
|
1072
|
+
assert.ok(guidance.fallback.length > 20);
|
|
1073
|
+
assert.ok(Object.isFrozen(guidance) && Object.isFrozen(guidance.blocked) && Object.isFrozen(guidance.failed));
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("entry helpers: fixed per-platform opener, exit codes and runner environment", async () => {
|
|
1077
|
+
// The opener receives the private redirect file, never the session URL.
|
|
1078
|
+
const file = "/tmp/gentle-shell-install-Ab12/open.html";
|
|
1079
|
+
assert.deepEqual(openerFor("darwin", file, {}), { command: "/usr/bin/open", args: [file] });
|
|
1080
|
+
assert.deepEqual(openerFor("linux", file, {}), { command: "xdg-open", args: [file] });
|
|
1081
|
+
const winFile = "C:\\Users\\u\\AppData\\Local\\Temp\\gentle-shell-install-Ab12\\open.html";
|
|
1082
|
+
assert.deepEqual(openerFor("win32", winFile, { SystemRoot: "C:\\Windows" }),
|
|
1083
|
+
{ command: "C:\\Windows\\System32\\rundll32.exe", args: ["url.dll,FileProtocolHandler", winFile] });
|
|
1084
|
+
assert.equal(openerFor("win32", winFile, { SystemRoot: "relative" }), null);
|
|
1085
|
+
assert.equal(openerFor("freebsd", file, {}), null);
|
|
1086
|
+
|
|
1087
|
+
assert.equal(exitCodeFor({ outcome: "ready" }), 0);
|
|
1088
|
+
assert.equal(exitCodeFor({ outcome: "terminal-action-required" }), 0);
|
|
1089
|
+
for (const outcome of [{ outcome: "blocked" }, { outcome: "failed" }, null]) assert.equal(exitCodeFor(outcome), 1);
|
|
1090
|
+
|
|
1091
|
+
const TOOLS = "/home/u/.gentle-shell-bootstrap-tools.Ab12";
|
|
1092
|
+
const files = new Set([`${TOOLS}/pnpm/bin/pnpm`, "/usr/bin/pnpm"]);
|
|
1093
|
+
const fs = { isFile: async (path: string) => files.has(path) };
|
|
1094
|
+
const wizardEnv = { HOME: "/home/u", PATH: `${TOOLS}/node/bin:${TOOLS}/pnpm/bin:/opt/bin:/bin` };
|
|
1095
|
+
// The bootstrap's pnpm comes first; other tool dirs are dropped.
|
|
1096
|
+
assert.deepEqual(await runnerEnvironment({ platform: "linux", env: wizardEnv, fs }),
|
|
1097
|
+
{ HOME: "/home/u", PATH: `${TOOLS}/pnpm/bin:/opt/bin:/bin` });
|
|
1098
|
+
// The bootstrap acquired its pnpm because the user's is incompatible: the runner never runs the user's.
|
|
1099
|
+
files.add("/opt/bin/pnpm");
|
|
1100
|
+
assert.deepEqual(await runnerEnvironment({ platform: "linux", env: wizardEnv, fs }),
|
|
1101
|
+
{ HOME: "/home/u", PATH: `${TOOLS}/pnpm/bin:/opt/bin:/bin` });
|
|
1102
|
+
// The user's own pnpm, reused by the bootstrap, stays the one on PATH.
|
|
1103
|
+
const reused = { HOME: "/home/u", PATH: `${TOOLS}/node/bin:/opt/bin:/bin` };
|
|
1104
|
+
assert.deepEqual(await runnerEnvironment({ platform: "linux", env: reused, fs }), { HOME: "/home/u", PATH: "/opt/bin:/bin" });
|
|
1105
|
+
const windowsEnv = { USERPROFILE: "C:\\Users\\u", Path: `C:\\Users\\u\\AppData\\Local\\.gentle-shell-bootstrap-tools.1\\node;C:\\Windows`,
|
|
1106
|
+
GENTLE_INSTALL_PNPM_NODE: "C:\\t\\node.exe", GENTLE_INSTALL_PNPM_ENTRY: "C:\\t\\pnpm.mjs" };
|
|
1107
|
+
assert.deepEqual(await runnerEnvironment({ platform: "win32", env: windowsEnv, fs }),
|
|
1108
|
+
{ ...windowsEnv, Path: "C:\\Windows" });
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test("every /api/* request needs X-Gentle-Install: 1 before any state change", async () => {
|
|
1112
|
+
const { host, port, login, collects } = await start();
|
|
1113
|
+
try {
|
|
1114
|
+
const cookie = await login();
|
|
1115
|
+
for (const value of [undefined, "0", "true"]) {
|
|
1116
|
+
const headers: Record<string, string> = { cookie };
|
|
1117
|
+
if (value !== undefined) headers["x-gentle-install"] = value;
|
|
1118
|
+
assert.equal((await send(port, { path: "/api/plan", headers })).status, 403, String(value));
|
|
1119
|
+
assert.equal((await send(port, { path: "/api/progress", headers })).status, 403, String(value));
|
|
1120
|
+
}
|
|
1121
|
+
assert.equal(collects(), 0);
|
|
1122
|
+
const { planId } = await plan(port, cookie);
|
|
1123
|
+
assert.equal(collects(), 1);
|
|
1124
|
+
// A rejected plan request neither probes nor rotates the stored plan.
|
|
1125
|
+
assert.equal((await send(port, { path: "/api/plan", headers: { cookie } })).status, 403);
|
|
1126
|
+
assert.equal(collects(), 1);
|
|
1127
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
1128
|
+
// Assets are page navigations and need no custom header.
|
|
1129
|
+
assert.equal((await send(port, { path: "/", headers: { cookie } })).status, 200);
|
|
1130
|
+
} finally {
|
|
1131
|
+
await host.close("test");
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
test("only authenticated traffic extends the idle timeout", async () => {
|
|
1136
|
+
const clock = { value: 10_000 };
|
|
1137
|
+
const { host, port } = await start({ clock, limits: { idleMs: 60_000 } });
|
|
1138
|
+
try {
|
|
1139
|
+
clock.value += 59_000;
|
|
1140
|
+
// Wrong Host, missing cookie, bad codes and forged cookies do not count as activity.
|
|
1141
|
+
await send(port, { path: "/api/plan", host: `localhost:${port}` });
|
|
1142
|
+
await send(port, { path: "/api/plan", headers: API });
|
|
1143
|
+
await send(port, { path: "/session?code=wrong" });
|
|
1144
|
+
await send(port, { path: "/", headers: { cookie: "gentle_install_session=forged" } });
|
|
1145
|
+
await send(port, { path: "/nope" });
|
|
1146
|
+
clock.value += 1_000;
|
|
1147
|
+
assert.equal(host.checkIdle(), true);
|
|
1148
|
+
assert.equal((await host.closed).reason, "idle");
|
|
1149
|
+
} finally {
|
|
1150
|
+
await host.close("test");
|
|
1151
|
+
}
|
|
1152
|
+
const active = await start({ clock, limits: { idleMs: 60_000 } });
|
|
1153
|
+
try {
|
|
1154
|
+
clock.value += 59_000;
|
|
1155
|
+
// Redeeming the code is activity, and so is an authenticated request.
|
|
1156
|
+
const cookie = await active.login();
|
|
1157
|
+
clock.value += 59_000;
|
|
1158
|
+
assert.equal(active.host.checkIdle(), false);
|
|
1159
|
+
assert.equal((await send(active.port, { path: "/api/progress", headers: { cookie, ...API } })).status, 200);
|
|
1160
|
+
clock.value += 59_000;
|
|
1161
|
+
assert.equal(active.host.checkIdle(), false);
|
|
1162
|
+
clock.value += 1_000;
|
|
1163
|
+
assert.equal(active.host.checkIdle(), true);
|
|
1164
|
+
} finally {
|
|
1165
|
+
await active.host.close("test");
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("a completed installation is final: later installs get 409 already-completed", async () => {
|
|
1170
|
+
for (const result of [{ outcome: "ready", completed: [] }, { outcome: "failed", failedStep: "install-global", completed: [] },
|
|
1171
|
+
{ outcome: "blocked", reason: "existing-stack", completed: [] }]) {
|
|
1172
|
+
const { host, port, login, runs } = await start({ runInstall: async () => result });
|
|
1173
|
+
try {
|
|
1174
|
+
const cookie = await login();
|
|
1175
|
+
const first = await plan(port, cookie);
|
|
1176
|
+
assert.equal((await post(port, "/api/install", cookie, { planId: first.planId, consent: true })).status, 202);
|
|
1177
|
+
await waitFor(() => host.outcome() !== null);
|
|
1178
|
+
const second = await plan(port, cookie);
|
|
1179
|
+
const again = await post(port, "/api/install", cookie, { planId: second.planId, consent: true });
|
|
1180
|
+
assert.equal(again.status, 409, result.outcome);
|
|
1181
|
+
assert.equal(JSON.parse(again.body).error, "already-completed");
|
|
1182
|
+
assert.equal(runs.length, 1);
|
|
1183
|
+
assert.equal(host.outcome()?.outcome, result.outcome);
|
|
1184
|
+
const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body);
|
|
1185
|
+
assert.equal(progress.outcome.outcome, result.outcome);
|
|
1186
|
+
} finally {
|
|
1187
|
+
await host.close("test");
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
// The one-time code reaches the browser through a private redirect file, so it
|
|
1193
|
+
// never appears in the opener's argv (visible to local process listings).
|
|
1194
|
+
const redirectRoot = mkdtempSync(join(tmpdir(), "gentle-install-redirect-test-"));
|
|
1195
|
+
test.after(() => rmSync(redirectRoot, { recursive: true, force: true }));
|
|
1196
|
+
const SESSION_URL = "http://127.0.0.1:4000/session?code=Abc_123-xyz";
|
|
1197
|
+
|
|
1198
|
+
type Spawned = { command: string; args: string[]; options: Record<string, unknown> };
|
|
1199
|
+
function fakeSpawn({ throws = false } = {}) {
|
|
1200
|
+
const calls: Spawned[] = [];
|
|
1201
|
+
const children: EventEmitter[] = [];
|
|
1202
|
+
const spawn = (command: string, args: string[], options: Record<string, unknown>) => {
|
|
1203
|
+
calls.push({ command, args, options });
|
|
1204
|
+
if (throws) throw new Error("spawn failed");
|
|
1205
|
+
const child = Object.assign(new EventEmitter(), { unref() {} });
|
|
1206
|
+
children.push(child);
|
|
1207
|
+
return child;
|
|
1208
|
+
};
|
|
1209
|
+
return { spawn, calls, children };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
test("redirect page escapes the URL and only refreshes to it", () => {
|
|
1213
|
+
const page = redirectPage(SESSION_URL);
|
|
1214
|
+
assert.ok(page.includes(`<meta http-equiv="refresh" content="0;url=${SESSION_URL}">`));
|
|
1215
|
+
assert.ok(!page.includes("<script"));
|
|
1216
|
+
const hostile = redirectPage(`http://127.0.0.1:1/session?code=a"><script>&'`);
|
|
1217
|
+
assert.ok(!hostile.includes("<script>") && !hostile.includes(`a">`));
|
|
1218
|
+
assert.ok(hostile.includes("a"><script>&'"));
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test("writeRedirect creates a private directory and file, and remove() deletes both", async () => {
|
|
1222
|
+
const tempDir = mkdtempSync(join(redirectRoot, "write-"));
|
|
1223
|
+
const redirect = await writeRedirect(SESSION_URL, { tempDir, files: fsPromises });
|
|
1224
|
+
assert.equal(redirect.file, join(redirect.dir, "open.html"));
|
|
1225
|
+
assert.ok(redirect.dir.startsWith(join(tempDir, "gentle-shell-install-")));
|
|
1226
|
+
assert.equal(readFileSync(redirect.file, "utf8"), redirectPage(SESSION_URL));
|
|
1227
|
+
if (process.platform !== "win32") {
|
|
1228
|
+
assert.equal(statSync(redirect.dir).mode & 0o777, 0o700);
|
|
1229
|
+
assert.equal(statSync(redirect.file).mode & 0o777, 0o600);
|
|
1230
|
+
}
|
|
1231
|
+
await redirect.remove();
|
|
1232
|
+
await redirect.remove();
|
|
1233
|
+
assert.deepEqual(readdirSync(tempDir), []);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("openBrowser passes only the redirect file to the fixed detached opener", async () => {
|
|
1237
|
+
const tempDir = mkdtempSync(join(redirectRoot, "open-"));
|
|
1238
|
+
const fake = fakeSpawn();
|
|
1239
|
+
const opened = await openBrowser(SESSION_URL, { platform: "linux", env: {}, tempDir, files: fsPromises, spawn: fake.spawn });
|
|
1240
|
+
assert.ok(opened !== null);
|
|
1241
|
+
assert.equal(fake.calls.length, 1);
|
|
1242
|
+
const [call] = fake.calls;
|
|
1243
|
+
assert.equal(call.command, "xdg-open");
|
|
1244
|
+
assert.deepEqual(call.args, [opened.file]);
|
|
1245
|
+
assert.ok(!call.args.join(" ").includes("Abc_123-xyz"));
|
|
1246
|
+
assert.deepEqual(call.options, { shell: false, detached: true, stdio: "ignore", windowsHide: true });
|
|
1247
|
+
assert.ok(readFileSync(opened.file, "utf8").includes(SESSION_URL));
|
|
1248
|
+
await opened.remove();
|
|
1249
|
+
assert.deepEqual(readdirSync(tempDir), []);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test("openBrowser leaves no redirect file when there is no opener or it cannot start", async () => {
|
|
1253
|
+
const tempDir = mkdtempSync(join(redirectRoot, "fail-"));
|
|
1254
|
+
const none = fakeSpawn();
|
|
1255
|
+
assert.equal(await openBrowser(SESSION_URL, { platform: "freebsd", env: {}, tempDir, files: fsPromises, spawn: none.spawn }), null);
|
|
1256
|
+
assert.equal(none.calls.length, 0);
|
|
1257
|
+
assert.deepEqual(readdirSync(tempDir), []);
|
|
1258
|
+
|
|
1259
|
+
const throwing = fakeSpawn({ throws: true });
|
|
1260
|
+
assert.equal(await openBrowser(SESSION_URL, { platform: "linux", env: {}, tempDir, files: fsPromises, spawn: throwing.spawn }), null);
|
|
1261
|
+
assert.equal(throwing.calls.length, 1);
|
|
1262
|
+
assert.deepEqual(readdirSync(tempDir), []);
|
|
1263
|
+
|
|
1264
|
+
// A missing xdg-open fails asynchronously: the file is removed then.
|
|
1265
|
+
const missing = fakeSpawn();
|
|
1266
|
+
const opened = await openBrowser(SESSION_URL, { platform: "linux", env: {}, tempDir, files: fsPromises, spawn: missing.spawn });
|
|
1267
|
+
assert.ok(opened !== null && existsSync(opened.file));
|
|
1268
|
+
missing.children[0].emit("error", new Error("ENOENT"));
|
|
1269
|
+
await waitFor(() => readdirSync(tempDir).length === 0);
|
|
1270
|
+
|
|
1271
|
+
// A failed write never opens anything and leaves no directory behind.
|
|
1272
|
+
const unwritable = fakeSpawn();
|
|
1273
|
+
const files = { ...fsPromises, writeFile: async () => { throw new Error("EACCES"); } };
|
|
1274
|
+
assert.equal(await openBrowser(SESSION_URL, { platform: "linux", env: {}, tempDir, files, spawn: unwritable.spawn }), null);
|
|
1275
|
+
assert.equal(unwritable.calls.length, 0);
|
|
1276
|
+
assert.deepEqual(readdirSync(tempDir), []);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("onRedeemed runs once, only for a valid code, and a throwing hook never breaks the session", async () => {
|
|
1280
|
+
let redeemed = 0;
|
|
1281
|
+
const host = createInstallerServer({
|
|
1282
|
+
assetsDir,
|
|
1283
|
+
collectPlan: async () => collected(),
|
|
1284
|
+
runInstall: async () => ({ outcome: "ready", completed: [] }),
|
|
1285
|
+
onRedeemed: () => {
|
|
1286
|
+
redeemed += 1;
|
|
1287
|
+
throw new Error("cleanup failed");
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
1290
|
+
const { port, url } = await host.listen();
|
|
1291
|
+
try {
|
|
1292
|
+
assert.equal((await send(port, { path: "/session?code=wrong" })).status, 401);
|
|
1293
|
+
assert.equal(redeemed, 0);
|
|
1294
|
+
const path = new URL(url).pathname + new URL(url).search;
|
|
1295
|
+
assert.equal((await send(port, { path })).status, 200);
|
|
1296
|
+
assert.equal((await send(port, { path })).status, 401);
|
|
1297
|
+
assert.equal(redeemed, 1);
|
|
1298
|
+
} finally {
|
|
1299
|
+
await host.close("test");
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// S6: the Windows PNPM_HOME decision reaches the plan copy before consent.
|
|
1304
|
+
const W_DEFAULT = "C:\\Users\\m\\AppData\\Local\\pnpm";
|
|
1305
|
+
const W_PRIVATE = "C:\\Users\\m\\.pnpm";
|
|
1306
|
+
const weak = { check: "target-acl-mask", at: "C:\\Users\\m\\AppData\\Local", sid: "S-1-5-21-1-2-3-1002", account: "PC\\other", rights: "0x001301BF" };
|
|
1307
|
+
async function windowsView(pnpmHome: object, changes: Record<string, unknown> = {}) {
|
|
1308
|
+
const { host, port, login } = await start({ collect: async () => collected({ platform: "win32", go: { available: true, version: "1.26.0", usable: true },
|
|
1309
|
+
globalBin: { available: true, path: `${W_PRIVATE}\\bin`, writable: true, onPath: false }, pnpmHome, ...changes }) });
|
|
1310
|
+
try {
|
|
1311
|
+
return await plan(port, await login());
|
|
1312
|
+
} finally {
|
|
1313
|
+
await host.close("test");
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
const names = (text: string, parts: string[]) => { for (const part of parts) assert.ok(text.includes(part), `${part} in: ${text}`); };
|
|
1317
|
+
|
|
1318
|
+
test("/api/plan says before consent that a private PNPM_HOME replaces a default another account can change", async () => {
|
|
1319
|
+
const view = await windowsView({ available: true, path: W_PRIVATE, source: "private", rejected: { path: W_DEFAULT, ...weak } });
|
|
1320
|
+
assert.deepEqual(view.blockers, []);
|
|
1321
|
+
assert.equal(view.profileChange.changesProfile, true);
|
|
1322
|
+
names(view.profileChange.description, [W_DEFAULT, weak.at, "S-1-5-21-1-2-3-1002 (PC\\other)", "0x001301BF", W_PRIVATE, "`pnpm setup`",
|
|
1323
|
+
`PNPM_HOME=${W_PRIVATE}`, `${W_PRIVATE}\\bin`, "new terminals", "only you, SYSTEM and Administrators"]);
|
|
1324
|
+
assert.equal(view.persistence.pnpmHome, W_PRIVATE);
|
|
1325
|
+
// S13: the accepted residual risk of later pnpm commands, stated before consent.
|
|
1326
|
+
names(view.profileChange.description, ["pnpm commands you run later", "configuration, cache and state", "%LOCALAPPDATA%", W_DEFAULT]);
|
|
1327
|
+
// A passing default keeps the existing copy.
|
|
1328
|
+
const plain = await windowsView({ available: true, path: W_DEFAULT, source: "default" });
|
|
1329
|
+
assert.doesNotMatch(plain.profileChange.description, /private|%LOCALAPPDATA%/);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// S6 notice: reused tools in folders another account can change, before consent.
|
|
1333
|
+
test("/api/plan notes reused tools in folders another account can change, without blocking or changing the plan", async () => {
|
|
1334
|
+
const roaming = { check: "parent-acl-mask", at: "C:\\Users\\m\\AppData\\Roaming", sid: "S-1-5-21-1-2-3-1002", account: "PC\\other", rights: "0x001301BF" };
|
|
1335
|
+
const reused = { node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, pi: { available: true, version: "1.2.0", usable: true, external: true },
|
|
1336
|
+
pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true } };
|
|
1337
|
+
const view = await windowsView({ available: true, path: W_DEFAULT, source: "default" }, { ...reused,
|
|
1338
|
+
folders: { node: roaming, npm: roaming, pi: { check: "target-owner", at: "C:\\Users\\m\\AppData\\Roaming\\npm\\pi.cmd", sid: "S-1-5-21-9" } } });
|
|
1339
|
+
assert.deepEqual(view.blockers, []);
|
|
1340
|
+
assert.ok(view.actions.length > 0);
|
|
1341
|
+
names(view.sharedFolders.description, ["Node.js", "npm", "Pi", "S-1-5-21-1-2-3-1002 (PC\\other) can change C:\\Users\\m\\AppData\\Roaming", "0x001301BF",
|
|
1342
|
+
"C:\\Users\\m\\AppData\\Roaming\\npm\\pi.cmd is owned by S-1-5-21-9", "never creates or runs its own programs there", "accepted risk", "not a blocker"]);
|
|
1343
|
+
assert.deepEqual(view.sharedFolders.tools, ["node", "npm", "pi"]);
|
|
1344
|
+
// A1: a path the walk could not check says so, and still does not block.
|
|
1345
|
+
const unchecked = await windowsView({ available: true, path: W_DEFAULT, source: "default" }, { ...reused, folders: { node: { check: "unchecked", at: "C:\\nodejs\\node.exe" } } });
|
|
1346
|
+
assert.deepEqual(unchecked.blockers, []);
|
|
1347
|
+
names(unchecked.sharedFolders.description, ["Node.js: the permissions of C:\\nodejs\\node.exe could not be checked", "could not be checked", "not a blocker",
|
|
1348
|
+
"make sure your account can read the permissions of the paths that could not be checked"]);
|
|
1349
|
+
assert.doesNotMatch(unchecked.sharedFolders.description, /write access/, "nothing says an account can write there");
|
|
1350
|
+
assert.match(view.sharedFolders.description, /remove that account's write access/);
|
|
1351
|
+
assert.doesNotMatch(view.sharedFolders.description, /could not be checked/);
|
|
1352
|
+
// Nothing to note: no record.
|
|
1353
|
+
assert.equal((await windowsView({ available: true, path: W_DEFAULT, source: "default" }, reused)).sharedFolders, null);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test("/api/plan names the folder, principal, rights and remedy when PNPM_HOME is not private", async () => {
|
|
1357
|
+
const cases = [
|
|
1358
|
+
[{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, installed: true }, [W_DEFAULT, "already holds files", "never moves or deletes", "%USERPROFILE%\\.pnpm"]],
|
|
1359
|
+
[{ available: true, path: "D:\\pnpm", source: "user", untrusted: { ...weak, at: "D:\\pnpm" } }, ["PNPM_HOME is set to D:\\pnpm", "%USERPROFILE%\\.pnpm"]],
|
|
1360
|
+
[{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, private: { path: W_PRIVATE, foreign: true } }, [W_PRIVATE, "did not create"]],
|
|
1361
|
+
[{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, private: { path: W_PRIVATE, untrusted: { check: "target-owner", at: "C:\\Users\\m", sid: "S-1-5-21-9" } } },
|
|
1362
|
+
[W_PRIVATE, "C:\\Users\\m is owned by S-1-5-21-9"]],
|
|
1363
|
+
] as const;
|
|
1364
|
+
for (const [pnpmHome, parts] of cases) {
|
|
1365
|
+
const view = await windowsView(pnpmHome);
|
|
1366
|
+
assert.deepEqual(view.actions, []);
|
|
1367
|
+
assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["untrusted-pnpm-home", "pnpmHome"]]);
|
|
1368
|
+
names(view.blockers[0].guidance, [...parts, "S-1-5-21-1-2-3-1002 (PC\\other)", "0x001301BF"]);
|
|
1369
|
+
}
|
|
1370
|
+
const failed = await windowsView({ available: null, failed: true });
|
|
1371
|
+
assert.deepEqual(failed.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["unknown-tool", "pnpmHome"]]);
|
|
1372
|
+
assert.match(failed.blockers[0].guidance, /PNPM_HOME/);
|
|
1373
|
+
assert.ok(guidance.blocked["pnpm-home-changed"].length > 20);
|
|
1374
|
+
assert.ok(guidance.failed["prepare-pnpm-home"].includes("%USERPROFILE%\\.pnpm"));
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
// R2: an update's children (gentle-shell upgrade, its pnpm and postinstall) get the
|
|
1378
|
+
// same private-mode environment as the runner's children; otherwise it is unchanged.
|
|
1379
|
+
test("upgradeEnvironment isolates TEMP, TMP and pnpm's folders in a private Windows PNPM_HOME only", () => {
|
|
1380
|
+
const env = { USERPROFILE: "C:\\Users\\m", PNPM_HOME: W_PRIVATE, Path: "C:\\Windows", TEMP: "C:\\Users\\m\\AppData\\Local\\Temp" };
|
|
1381
|
+
const privateHome = { available: true, path: W_PRIVATE, source: "private", rejected: { path: W_DEFAULT, check: "target-acl-mask" } };
|
|
1382
|
+
const isolated = upgradeEnvironment({ platform: "win32", env, pnpmHome: privateHome, goPath: "C:\\go\\bin\\go.exe" });
|
|
1383
|
+
assert.deepEqual([isolated.TEMP, isolated.TMP, isolated.XDG_CACHE_HOME, isolated.PNPM_HOME], [`${W_PRIVATE}\\tmp`, `${W_PRIVATE}\\tmp`, `${W_PRIVATE}\\.cache`, W_PRIVATE]);
|
|
1384
|
+
assert.equal(isolated.Path.split(";")[0], "C:\\go\\bin");
|
|
1385
|
+
assert.deepEqual(upgradeEnvironment({ platform: "win32", env, pnpmHome: { available: true, path: W_PRIVATE, source: "user" } }), env);
|
|
1386
|
+
assert.deepEqual(upgradeEnvironment({ platform: "linux", env: { PATH: "/usr/bin" }, pnpmHome: null }), { PATH: "/usr/bin" });
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// T1c: the bundled plan's copy, before consent.
|
|
1390
|
+
const bundledDistribution = { status: "published", manifest: { shell: "4.1.0", pi: "1.0.2" }, lockfile: "lock" };
|
|
1391
|
+
const bundledLine = 'export PATH="/home/u/.gentle-shell/bin:$PATH" # gentle-shell bundled install';
|
|
1392
|
+
function bundledCollected(path: Record<string, unknown>, platform = "linux"): Collected {
|
|
1393
|
+
const layout = platform === "win32" ? prefixLayout({ platform, env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" }) : prefixLayout({ platform, env: {}, home: "/home/u" });
|
|
1394
|
+
return { inventory: { ...cleanInventory, platform }, plan: bundledPlan({ platform, distribution: bundledDistribution, layout, path }) as never };
|
|
1395
|
+
}
|
|
1396
|
+
test("/api/plan describes the bundled install: its own pinned runtimes, the user's tools untouched and npm's network settings respected", async () => {
|
|
1397
|
+
const { host, port, login } = await start({ collect: async () => bundledCollected({ kind: "profile", path: "/home/u/.bashrc", line: bundledLine, create: false }) });
|
|
1398
|
+
try {
|
|
1399
|
+
const view = await plan(port, await login());
|
|
1400
|
+
assert.equal(view.bundled, true);
|
|
1401
|
+
assert.deepEqual(view.actions.map((action: { id: string }) => action.id),
|
|
1402
|
+
["bundled-prefix", "bundled-runtime", "bundled-version", "bundled-activate", "bundled-path", "bundled-setup"]);
|
|
1403
|
+
const text = view.actions.map((action: { description: string }) => action.description).join("\n");
|
|
1404
|
+
assert.match(text, /\/home\/u\/\.gentle-shell/);
|
|
1405
|
+
assert.match(text, /Node\.js 24\.21\.0 with its npm and pnpm 11\.1\.1/);
|
|
1406
|
+
assert.match(text, /Gentle Shell 4\.1\.0 with Pi 1\.0\.2/);
|
|
1407
|
+
assert.match(text, /--frozen-lockfile/);
|
|
1408
|
+
assert.match(text, /\/home\/u\/\.bashrc/);
|
|
1409
|
+
assert.match(text, /gentle-shell setup/);
|
|
1410
|
+
assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm"]);
|
|
1411
|
+
assert.equal(view.persistence.pnpmHome, "/home/u/.gentle-shell");
|
|
1412
|
+
assert.match(view.persistence.description, /Your own Node\.js, npm, pnpm, Go and Pi are not used or changed/);
|
|
1413
|
+
assert.match(view.persistence.description, /npm registry, authentication and proxy settings/);
|
|
1414
|
+
assert.equal(view.profileChange.changesProfile, true);
|
|
1415
|
+
assert.equal(view.profileChange.command, null);
|
|
1416
|
+
assert.equal(view.profileChange.binDir, "/home/u/.gentle-shell/bin");
|
|
1417
|
+
assert.match(view.profileChange.description, /\/home\/u\/\.bashrc/);
|
|
1418
|
+
} finally {
|
|
1419
|
+
await host.close("test");
|
|
1420
|
+
}
|
|
1421
|
+
// T3: a migration's plan names the old package's removal with its own manager, last.
|
|
1422
|
+
const migrating = bundledCollected({ kind: "none" });
|
|
1423
|
+
migrating.plan = bundledPlan({ platform: "linux", distribution: bundledDistribution, layout: prefixLayout({ platform: "linux", env: {}, home: "/home/u" }),
|
|
1424
|
+
path: { kind: "none" }, migrate: { manager: "npm", removal: "plan" } }) as never;
|
|
1425
|
+
const server = await start({ collect: async () => migrating });
|
|
1426
|
+
try {
|
|
1427
|
+
const view = await plan(server.port, await server.login());
|
|
1428
|
+
assert.equal(view.actions.at(-1).id, "remove-global-shell");
|
|
1429
|
+
assert.match(view.actions.at(-1).description, /`npm uninstall -g gentle-pi`/);
|
|
1430
|
+
assert.match(view.actions.at(-1).description, /reports Gentle Shell 4\.1\.0 with Pi 1\.0\.2/);
|
|
1431
|
+
assert.match(view.actions.at(-1).description, /Your Pi and ~\/\.gentle-shell\/agent are not changed/);
|
|
1432
|
+
} finally {
|
|
1433
|
+
await server.host.close("test");
|
|
1434
|
+
}
|
|
1435
|
+
const cases: [Record<string, unknown>, RegExp, boolean, string?][] = [
|
|
1436
|
+
[{ kind: "none" }, /already finds/, false],
|
|
1437
|
+
[{ kind: "symlink", path: "/home/u/.local/bin/gentle-shell", target: "/home/u/.gentle-shell/bin/gentle-shell" }, /\/home\/u\/\.local\/bin\/gentle-shell/, true],
|
|
1438
|
+
[{ kind: "manual", path: "/home/u/.zshrc", line: bundledLine, reason: "symlink" }, /add this line to \/home\/u\/\.zshrc yourself/, false],
|
|
1439
|
+
[{ kind: "registry", key: "HKCU\\Environment", name: "Path", entry: "C:\\L\\gentle-shell\\bin" }, /user PATH/, true, "win32"],
|
|
1440
|
+
];
|
|
1441
|
+
for (const [path, description, changes, platform] of cases) {
|
|
1442
|
+
const server = await start({ collect: async () => bundledCollected(path, platform) });
|
|
1443
|
+
try {
|
|
1444
|
+
const view = await plan(server.port, await server.login());
|
|
1445
|
+
assert.match(view.profileChange.description, description, String(path.kind));
|
|
1446
|
+
assert.equal(view.profileChange.changesProfile, changes, String(path.kind));
|
|
1447
|
+
if (path.kind === "manual") assert.ok(view.profileChange.description.includes(bundledLine));
|
|
1448
|
+
if (platform === "win32") {
|
|
1449
|
+
assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm", "go"]);
|
|
1450
|
+
assert.match(view.actions[1].description, /Go 1\.25\.14/);
|
|
1451
|
+
}
|
|
1452
|
+
} finally {
|
|
1453
|
+
await server.host.close("test");
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
test("bundled outcomes: every bundled step has guidance, pnpm's error line is shown, and a manual PATH line is returned to copy", async () => {
|
|
1459
|
+
for (const step of bundledFailedSteps) assert.equal(typeof guidance.failed[step], "string", step);
|
|
1460
|
+
const cases: [Record<string, unknown>, (view: Record<string, unknown>) => void][] = [
|
|
1461
|
+
[{ outcome: "failed", failedStep: "install-version", completed: ["claim-prefix"], detail: "ERR_PNPM_FETCH_401 GET https://npm.acme.dev/x: Unauthorized" },
|
|
1462
|
+
(view) => { assert.equal(view.detail, "ERR_PNPM_FETCH_401 GET https://npm.acme.dev/x: Unauthorized"); assert.equal(view.guidance, guidance.failed["install-version"]); }],
|
|
1463
|
+
[{ outcome: "terminal-action-required", action: "add-path-line", pathLine: { file: "/home/u/.zshrc", line: bundledLine }, completed: [] },
|
|
1464
|
+
(view) => {
|
|
1465
|
+
assert.equal(view.action, "add-path-line");
|
|
1466
|
+
assert.deepEqual(view.pathLine, { file: "/home/u/.zshrc", line: bundledLine });
|
|
1467
|
+
assert.equal(view.guidance, guidance.outcomes["add-path-line"]);
|
|
1468
|
+
}],
|
|
1469
|
+
[{ outcome: "terminal-action-required", action: "add-path-line", pathLine: { file: "/home/u/.zshrc\u001b[2J", line: 7 }, completed: [] },
|
|
1470
|
+
(view) => { assert.equal("pathLine" in view, false); }],
|
|
1471
|
+
// T3: a failed removal keeps the install and names the exact command; anything else is not shown.
|
|
1472
|
+
[{ outcome: "terminal-action-required", action: "open-new-terminal", completed: [], removeGlobal: { status: "failed", command: "npm uninstall -g gentle-pi" } },
|
|
1473
|
+
(view) => {
|
|
1474
|
+
assert.equal(view.removeCommand, "npm uninstall -g gentle-pi");
|
|
1475
|
+
assert.match(String(view.guidance), /old global gentle-pi could not be removed/);
|
|
1476
|
+
}],
|
|
1477
|
+
[{ outcome: "ready", completed: [], removeGlobal: { status: "failed", command: "rm -rf ~" } }, (view) => { assert.equal("removeCommand" in view, false); }],
|
|
1478
|
+
[{ outcome: "ready", completed: [], removeGlobal: { status: "removed", command: "pnpm remove -g gentle-pi" } },
|
|
1479
|
+
(view) => { assert.equal("removeCommand" in view, false); assert.equal(view.guidance, guidance.outcomes.ready); }],
|
|
1480
|
+
[{ outcome: "failed", failedStep: "verify-launcher", completed: [], detail: "Error: no Pi" },
|
|
1481
|
+
(view) => { assert.equal(view.detail, "Error: no Pi"); assert.match(String(view.guidance), /old global Gentle Shell was kept/); }],
|
|
1482
|
+
// B3: a migration's failed bundled step shows its detail; a new install's sends none, so nothing changes there.
|
|
1483
|
+
[{ outcome: "failed", failedStep: "claim-prefix", completed: [], detail: "Unsafe bundled prefix: ~/.gentle-shell" },
|
|
1484
|
+
(view) => { assert.equal(view.detail, "Unsafe bundled prefix: ~/.gentle-shell"); assert.equal(view.guidance, guidance.failed["claim-prefix"]); }],
|
|
1485
|
+
[{ outcome: "failed", failedStep: "claim-prefix", completed: [] }, (view) => { assert.equal("detail" in view, false); }],
|
|
1486
|
+
];
|
|
1487
|
+
for (const [result, check] of cases) {
|
|
1488
|
+
const { host, port, login } = await start({ runInstall: async () => result as never });
|
|
1489
|
+
try {
|
|
1490
|
+
const cookie = await login();
|
|
1491
|
+
const { planId } = await plan(port, cookie);
|
|
1492
|
+
assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202);
|
|
1493
|
+
await waitFor(() => host.outcome() !== null);
|
|
1494
|
+
check(host.outcome() as Record<string, unknown>);
|
|
1495
|
+
} finally {
|
|
1496
|
+
await host.close("test");
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
// Simulates Linux against the real filesystem (profile files), so it runs on the POSIX runners only.
|
|
1502
|
+
test("wizardHandlers plans the bundled install only when the gate says so, and routes its plan to the bundled runner",
|
|
1503
|
+
{ skip: process.platform === "win32" ? "simulated Linux paths need a POSIX filesystem" : false }, async () => {
|
|
1504
|
+
const home = mkdtempSync(join(tmpdir(), "gentle-wizard-handlers-"));
|
|
1505
|
+
try {
|
|
1506
|
+
const asset = distributionAsset({ shell: requirements.shell, pi: "1.0.2" }, "lock");
|
|
1507
|
+
const urls: string[] = [];
|
|
1508
|
+
const download = (published: boolean) => async ({ url }: { url: string }) => {
|
|
1509
|
+
urls.push(url);
|
|
1510
|
+
if (!published) throw Object.assign(new Error("Download responded 404"), { status: 404 });
|
|
1511
|
+
return Buffer.from(url.endsWith(".json") ? asset : "lock");
|
|
1512
|
+
};
|
|
1513
|
+
const env = { HOME: home, PATH: "/usr/bin", SHELL: "/bin/bash" };
|
|
1514
|
+
const handlers = (published: boolean, inventory = cleanInventory) => wizardHandlers({ platform: "linux", arch: "x64", env, run: async () => ({}), fs: {},
|
|
1515
|
+
download: download(published), inventory: async () => structuredClone(inventory) });
|
|
1516
|
+
const bundled = await handlers(true).collectPlan("release");
|
|
1517
|
+
assert.equal(bundled.plan.bundled.shell, requirements.shell);
|
|
1518
|
+
assert.equal(bundled.plan.bundled.root, join(home, ".gentle-shell"));
|
|
1519
|
+
assert.equal(bundled.plan.bundled.path.kind, "profile");
|
|
1520
|
+
assert.deepEqual(urls, [`https://github.com/Gentleman-Programming/gentle-shell/releases/download/v${requirements.shell}/gentle-shell-distribution.json`,
|
|
1521
|
+
`https://github.com/Gentleman-Programming/gentle-shell/releases/download/v${requirements.shell}/gentle-shell-distribution-lock.yaml`]);
|
|
1522
|
+
// Without the assets (v4.0.0 today), on main, or with an existing Gentle Shell: the standard plan, unchanged.
|
|
1523
|
+
urls.length = 0;
|
|
1524
|
+
assert.deepEqual((await handlers(false).collectPlan("release")).plan, planPreflight(cleanInventory));
|
|
1525
|
+
assert.deepEqual((await handlers(true).collectPlan("main")).plan, planPreflight(cleanInventory, { channel: "main" }));
|
|
1526
|
+
// T3: an existing Gentle Shell that pnpm or npm owns moves to the bundled install and its old package is removed last.
|
|
1527
|
+
const existing = { ...cleanInventory, shell: { available: true, version: requirements.shell, usable: true, global: true, owner: "pnpm" } };
|
|
1528
|
+
const migration = (await handlers(true, existing).collectPlan("release")).plan;
|
|
1529
|
+
assert.deepEqual(migration.bundled.migrate, { manager: "pnpm", command: "pnpm remove -g gentle-pi", removal: "plan" });
|
|
1530
|
+
assert.equal(migration.actions.at(-1).id, "remove-global-shell");
|
|
1531
|
+
// An npm link (no owner) or a release without the assets: the standard plan, unchanged.
|
|
1532
|
+
const linked = { ...existing, shell: { available: true, version: requirements.shell, usable: true, global: true } };
|
|
1533
|
+
assert.deepEqual((await handlers(true, linked).collectPlan("release")).plan, planPreflight(linked));
|
|
1534
|
+
assert.deepEqual((await handlers(false, existing).collectPlan("release")).plan, planPreflight(existing));
|
|
1535
|
+
assert.equal(urls.length, 4, "only the release check of a new or migrating installation asks for the assets");
|
|
1536
|
+
// The bundled plan goes to the bundled runner, which needs consent like the standard one.
|
|
1537
|
+
const routed = handlers(true);
|
|
1538
|
+
const { plan: consented } = await routed.collectPlan("release");
|
|
1539
|
+
assert.deepEqual(await routed.runInstall({ plan: consented, consent: false }, () => {}), { outcome: "blocked", reason: "consent-required", completed: [] });
|
|
1540
|
+
} finally {
|
|
1541
|
+
rmSync(home, { recursive: true, force: true });
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
// Git is not needed to install; the plan notes a missing Git, never as a blocker,
|
|
1546
|
+
// for the standard and the bundled plan alike (both inventories carry the probe).
|
|
1547
|
+
const gitNoticeText = (install: string) => "Git was not found on this computer. Gentle Shell installs and starts without it, but code review, worktrees and other " +
|
|
1548
|
+
`repository features need it. Install it with ${install}, then open a new terminal. This is not a blocker.`;
|
|
1549
|
+
test("/api/plan notes a missing Git for the standard and the bundled plan, never as a blocker", async () => {
|
|
1550
|
+
const cases = [
|
|
1551
|
+
[collected({ git: { available: false } }), gitNoticeText("your package manager, for example `sudo apt-get install -y git` on Debian or Ubuntu")],
|
|
1552
|
+
[collected({ platform: "darwin", git: { available: false } }), gitNoticeText("`xcode-select --install` (Apple's Command Line Tools)")],
|
|
1553
|
+
[{ ...bundledCollected({ kind: "none" }), inventory: { ...bundledCollected({ kind: "none" }).inventory, git: { available: false } } }, gitNoticeText("your package manager, for example `sudo apt-get install -y git` on Debian or Ubuntu")],
|
|
1554
|
+
[collected({ git: { available: true } }), null],
|
|
1555
|
+
[collected({}), null],
|
|
1556
|
+
] as const;
|
|
1557
|
+
for (const [result, text] of cases) {
|
|
1558
|
+
const { host, port, login } = await start({ collect: async () => result });
|
|
1559
|
+
try {
|
|
1560
|
+
const view = await plan(port, await login());
|
|
1561
|
+
assert.equal(view.gitNotice?.description ?? null, text);
|
|
1562
|
+
assert.equal(view.blockers.some((blocker: { tool: string }) => blocker.tool === "git"), false);
|
|
1563
|
+
} finally {
|
|
1564
|
+
await host.close("test");
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
});
|