From 23203d391dab48880a19b3fd948bf47f68977187 Mon Sep 17 00:00:00 2001 From: Bruno <281018252+bruno-archastro@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:23:29 +0000 Subject: [PATCH] Require an explicit choice before installing ArchDev hooks --- .github/workflows/installer-smoke-test.yml | 33 ++++++--- README.md | 16 +++-- install.ps1 | 46 ++++++++----- install.sh | 28 ++++---- scripts/test-installer-prompts.py | 80 +++++++++++++++++++--- scripts/test-installer-scope.ps1 | 53 ++++++++++++++ 6 files changed, 194 insertions(+), 62 deletions(-) create mode 100644 scripts/test-installer-scope.ps1 diff --git a/.github/workflows/installer-smoke-test.yml b/.github/workflows/installer-smoke-test.yml index 73e651c..407e05f 100644 --- a/.github/workflows/installer-smoke-test.yml +++ b/.github/workflows/installer-smoke-test.yml @@ -54,7 +54,7 @@ jobs: ARCHDEV_INSTALL_DIR: ${{ runner.temp }}/home/.local/bin run: | mkdir -p "$HOME" - ./install.sh + ./install.sh --skip-login --skip-setup test "$("$HOME/.local/bin/archdev" --version)" = 0.31.0 test -f "$HOME/${{ matrix.completion_file }}" - name: Install a release without a completion command @@ -66,10 +66,10 @@ jobs: # still succeed and must not leave an empty completion file. mkdir -p "$HOME" scripts/create-unix-fixtures.sh --output-dir "$RUNNER_TEMP/release-nocompletion" --version 0.31.0 --no-completion - ./install.sh --version 0.31.0 --base-url "file://$RUNNER_TEMP/release-nocompletion" --install-dir "$HOME/.local/bin" + ./install.sh --version 0.31.0 --base-url "file://$RUNNER_TEMP/release-nocompletion" --install-dir "$HOME/.local/bin" --skip-login --skip-setup test "$("$HOME/.local/bin/archdev" --version)" = 0.31.0 test ! -e "$HOME/${{ matrix.completion_file }}" - - name: Setup installs globally without a terminal + - name: Unattended setup requires an explicit scope env: ARCHDEV_RELEASE_BASE_URL: http://127.0.0.1:8123 ARCHDEV_VERSION: 0.31.0 @@ -77,11 +77,13 @@ jobs: SHELL: /bin/${{ matrix.shell_name }} ARCHDEV_INSTALL_DIR: ${{ runner.temp }}/home-setup/.local/bin run: | - # The documented one-liner: the script arrives on stdin and there is - # no terminal, so the installer must not prompt. The checkout is a - # Git repository, and the unasked placement question means global. + # Neither a missing terminal nor --yes grants global placement. mkdir -p "$HOME" - cat install.sh | bash >"$RUNNER_TEMP/unattended.log" 2>&1 + if cat install.sh | bash >"$RUNNER_TEMP/missing-scope.log" 2>&1; then exit 1; fi + grep -F -- '--scope user or --scope repository' "$RUNNER_TEMP/missing-scope.log" + if cat install.sh | bash -s -- --yes >"$RUNNER_TEMP/yes.log" 2>&1; then exit 1; fi + grep -F -- '--scope user or --scope repository' "$RUNNER_TEMP/yes.log" + cat install.sh | bash -s -- --scope user >"$RUNNER_TEMP/unattended.log" 2>&1 grep -F 'Skill and hooks installed for this user' "$RUNNER_TEMP/unattended.log" grep -F 'Set up this repository with ArchDev.' "$RUNNER_TEMP/unattended.log" cat install.sh | bash -s -- --scope repository >"$RUNNER_TEMP/repository.log" 2>&1 @@ -191,6 +193,9 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Placement consent contracts + shell: pwsh + run: ./scripts/test-installer-scope.ps1 - name: Build fixture release shell: pwsh run: | @@ -230,12 +235,18 @@ jobs: if ($fallback -notmatch "Installing the TypeScript CLI \(archdev-old\) instead") { throw "Missing the fallback notice: $fallback" } $oldVersion = & "$env:RUNNER_TEMP\bin-old\archdev.exe" --version if ($oldVersion.Trim() -ne "0.30.0") { throw "archdev-old fallback version mismatch: $oldVersion" } - ./install.ps1 -Version 0.31.0 -BaseUrl http://127.0.0.1:8123/rust -InstallDir "$env:RUNNER_TEMP\bin" -SkipPathUpdate + ./install.ps1 -Version 0.31.0 -BaseUrl http://127.0.0.1:8123/rust -InstallDir "$env:RUNNER_TEMP\bin" -SkipPathUpdate -Scope user $cliVersion = & "$env:RUNNER_TEMP\bin\archdev.exe" --version if ($cliVersion.Trim() -ne "0.31.0") { throw "archdev.exe version mismatch" } - # No terminal in CI, so the installer must not prompt. The checkout - # is a Git repository, and the unasked placement question means global. - $unattended = (./install.ps1 -Version 0.31.0 -BaseUrl http://127.0.0.1:8123/rust -InstallDir "$env:RUNNER_TEMP\bin" -SkipPathUpdate *>&1 | Out-String) + # Neither a missing terminal nor -Yes grants global placement. + foreach ($yesFlag in @($false, $true)) { + $missingScope = $null + try { + ./install.ps1 -Version 0.31.0 -BaseUrl http://127.0.0.1:8123/rust -InstallDir "$env:RUNNER_TEMP\bin" -SkipPathUpdate -Yes:$yesFlag *>&1 | Out-Null + } catch { $missingScope = "$_" } + if ($missingScope -notmatch "explicit -Scope user or -Scope repository") { throw "Missing scope did not fail: $missingScope" } + } + $unattended = (./install.ps1 -Version 0.31.0 -BaseUrl http://127.0.0.1:8123/rust -InstallDir "$env:RUNNER_TEMP\bin" -SkipPathUpdate -Scope user *>&1 | Out-String) if ($unattended -notmatch "Skill and hooks installed for this user") { throw "Setup did not install globally: $unattended" } if ($unattended -notmatch "Set up this repository with ArchDev\.") { throw "Missing the agent prompt: $unattended" } $repository = (./install.ps1 -Version 0.31.0 -BaseUrl http://127.0.0.1:8123/rust -InstallDir "$env:RUNNER_TEMP\bin" -SkipPathUpdate -Scope repository *>&1 | Out-String) diff --git a/README.md b/README.md index 6910529..24466a6 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,12 @@ The installer does three things in order: 1. Installs the `archdev` CLI and verifies its SHA-256 against the release. 2. Signs you in with GitHub in your browser. 3. Runs `archdev setup` to install the ArchDev skill and session hooks. - Inside a Git repository it asks where they go; anywhere else they are - installed globally: + It asks where they go before installing hooks, even outside a Git repository + or when Node.js is missing: - **Globally:** personal setup across every repository you work in. - - **This repository:** shareable setup files for your teammates. + - **This repository:** shareable setup files for your teammates. Run from + inside a Git repository with Node.js on PATH. If either is missing, setup + explains the prerequisite and stops; it never switches to global hooks. Session hooks report activity and findings to your organization's shared stream, visible to its members; the installer says so before setup runs. Each @@ -43,9 +45,10 @@ When it finishes, go to a repository you work in and tell your coding agent: Set up this repository with ArchDev. ``` -Without a terminal (CI, or a coding agent running the command) the installer -never prompts: once signed in (`ARCHDEV_TOKEN`), it installs globally. Pass -`--scope repository` to install into the current repository instead: +Without a terminal (CI, or a coding agent running the command), the installer +never prompts and requires an explicit scope before setup. `--yes` does not +choose a scope. Once signed in (`ARCHDEV_TOKEN`), pass `--scope user` for global +hooks or `--scope repository` for the current repository. For example: ```sh curl -fsSL https://archdev.ai/install.sh | bash -s -- --scope repository @@ -55,6 +58,7 @@ curl -fsSL https://archdev.ai/install.sh | bash -s -- --scope repository $env:ARCHDEV_INSTALL_SCOPE = "repository"; irm https://archdev.ai/install.ps1 | iex ``` +Use `--skip-setup` (PowerShell: `-SkipSetup`) to install the CLI without hooks. `bash -s -- --help` lists every option. Homebrew users can run `brew install ArchAstro/tools/archdev` and then `archdev setup`. diff --git a/install.ps1 b/install.ps1 index 10a3650..c53f098 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,14 +7,13 @@ param( [switch]$SkipPathUpdate, [switch]$SkipVerify, # Where `archdev setup` installs the skill and hooks: user (globally) or - # repository. Asked inside a Git repository; otherwise user. + # repository. Always asked interactively; required without a terminal. [string]$Scope = $env:ARCHDEV_INSTALL_SCOPE, # Deprecated: reporting is always enabled. `disabled` declines setup. [string]$Reporting = $env:ARCHDEV_INSTALL_REPORTING, [switch]$SkipLogin, [switch]$SkipSetup, - # Never prompt. Sign-in then needs ARCHDEV_TOKEN, and setup installs - # globally unless -Scope says otherwise. + # Never prompt. Sign-in then needs ARCHDEV_TOKEN, and setup requires -Scope. [switch]$Yes ) @@ -27,6 +26,30 @@ $Owner = "ArchAstro" $Repo = "archdev" $AgentPrompt = "Set up this repository with ArchDev." function Test-Truthy([string]$Value) { return @("1", "true", "yes", "on") -contains "$Value".ToLowerInvariant() } + +# Keep placement consent independent of repository/Node availability. Validate +# project prerequisites only after selection; never fall back to global hooks. +function Resolve-SetupScope([string]$RequestedScope, [bool]$CanPrompt, [string]$RepositoryRoot, [bool]$NodeAvailable) { + if ([string]::IsNullOrWhiteSpace($RequestedScope)) { + if (-not $CanPrompt) { + throw "Setup needs an explicit -Scope user or -Scope repository. Choose where to install hooks, or use -SkipSetup to install only the CLI." + } + Write-Host "Where should ArchDev install its skill and hooks?" + Write-Host " 1) Globally (every repository you work in)" + $Hint = if ($RepositoryRoot) { $RepositoryRoot } else { "run from a Git repository" } + Write-Host " 2) This repository ($Hint)" + do { + $Choice = (Read-Host "Choose [1]").Trim() + } while (@("", "1", "2") -notcontains $Choice) + $RequestedScope = if ($Choice -eq "2") { "repository" } else { "user" } + } + if (@("user", "repository") -notcontains $RequestedScope) { throw "Unknown -Scope: $RequestedScope (use user or repository)" } + if ($RequestedScope -eq "repository") { + if (-not $RepositoryRoot) { throw "-Scope repository needs a Git repository. Run the installer from inside the repository." } + if (-not $NodeAvailable) { throw "Repository hooks need Node.js on PATH. Install Node.js, or use -Scope user." } + } + return $RequestedScope +} if (Test-Truthy $env:ARCHDEV_INSTALL_SKIP_LOGIN) { $SkipLogin = $true } if (Test-Truthy $env:ARCHDEV_INSTALL_SKIP_SETUP) { $SkipSetup = $true } if (Test-Truthy $env:ARCHDEV_INSTALL_NONINTERACTIVE) { $Yes = $true } @@ -236,22 +259,7 @@ if ($SkipSetup) { $RepoRoot = Get-RepositoryRoot $HasNode = [bool](Get-Command node -ErrorAction SilentlyContinue) - # The only question: inside a repository, the skill and hooks can live there - # instead of globally. Everywhere else, and without a terminal, they are - # global. Repository hooks need Node.js, so without it the answer is global. - if ([string]::IsNullOrWhiteSpace($Scope)) { - $Scope = "user" - if ($Interactive -and $RepoRoot -and $HasNode) { - Write-Host "Where should ArchDev install its skill and hooks?" - Write-Host " 1) Globally (every repository you work in)" - Write-Host " 2) This repository ($RepoRoot)" - if ((Read-Host "Choose [1]").Trim() -eq "2") { $Scope = "repository" } - } - } - if ($Scope -eq "repository") { - if (-not $RepoRoot) { throw "-Scope repository needs a Git repository. Run the installer from inside the repository." } - if (-not $HasNode) { throw "Repository hooks need Node.js on PATH. Install Node.js, or use -Scope user." } - } + $Scope = Resolve-SetupScope $Scope $Interactive $RepoRoot $HasNode Write-Host "Setup posts a one-time installation announcement, and session hooks report activity and findings to your organization's shared stream, visible to its members." if ($Scope -eq "repository") { Push-Location $RepoRoot } diff --git a/install.sh b/install.sh index 509404b..b831aa9 100755 --- a/install.sh +++ b/install.sh @@ -4,10 +4,9 @@ # # curl -fsSL https://archdev.ai/install.sh | bash # -# Installs the archdev CLI, signs you in, and runs `archdev setup`. Inside a -# Git repository it asks whether to install the skill and hooks there or -# globally; anywhere else it installs them globally. Pass flags after -# `bash -s --`: +# Installs the archdev CLI, signs you in, and runs `archdev setup` after an +# explicit global or repository-local placement choice. Unattended setup +# requires --scope. Pass flags after `bash -s --`: # # curl -fsSL https://archdev.ai/install.sh | bash -s -- --help # @@ -66,14 +65,14 @@ Install options: Sign-in and setup options: --scope Where `archdev setup` installs the skill and hooks: globally for you (user) or in this repository. - Asked inside a Git repository; otherwise user. + Always asked interactively; required without a terminal. --reporting Deprecated: reporting is always enabled. `disabled` declines setup. --skip-login Install only; do not sign in --skip-setup Do not run `archdev setup` -y, --yes Never prompt. Sign-in then needs ARCHDEV_TOKEN, and - setup installs globally unless --scope says otherwise. + setup requires --scope user or --scope repository. Other: --dry-run Print the resolved install plan without downloading @@ -1005,17 +1004,14 @@ run_setup() { repo_root="$(repository_root)" - # The only question: inside a repository, the skill and hooks can live there - # instead of globally. Everywhere else, and without a terminal, they are - # global. Repository hooks need Node.js, so without it the answer is global. + # Missing prerequisites must never turn project-local intent into global hooks. + # --yes suppresses prompts; it is not consent to a particular placement. if [ -z "$scope" ]; then - scope="user" - if [ "$INTERACTIVE" = true ] && [ -n "$repo_root" ] && have node; then - choose "Where should ArchDev install its skill and hooks?" 0 \ - "Globally|every repository you work in" \ - "This repository|$(pretty_path "$repo_root")" - if [ "$CHOICE" -eq 1 ]; then scope="repository"; fi - fi + [ "$INTERACTIVE" = true ] || die "Setup needs an explicit --scope user or --scope repository." "Choose where to install hooks, or use --skip-setup to install only the CLI." + choose "Where should ArchDev install its skill and hooks?" 0 \ + "Globally|every repository you work in" \ + "This repository|${repo_root:-run from a Git repository}" + if [ "$CHOICE" -eq 1 ]; then scope="repository"; else scope="user"; fi fi if [ "$scope" = repository ]; then diff --git a/scripts/test-installer-prompts.py b/scripts/test-installer-prompts.py index 7dcdbd2..b066919 100644 --- a/scripts/test-installer-prompts.py +++ b/scripts/test-installer-prompts.py @@ -79,20 +79,24 @@ def setUp(self): for directory in (self.home, self.state, self.tools, self.release): directory.mkdir() self.build_release() - # Repository placement is offered only inside a Git repository with - # Node.js on PATH; the hooks themselves never run here. + # Repository placement needs Node.js; the hooks themselves never run here. (self.tools / "node").write_text("#!/bin/sh\nexit 0\n") (self.tools / "node").chmod(0o755) self.repo = self.root / "repo" self.repo.mkdir() + self.outside = self.root / "outside" + self.outside.mkdir() subprocess.run(["git", "init", "-q", str(self.repo)], check=True) self.env = { "HOME": str(self.home), + "TMPDIR": str(self.root), "PATH": f"{self.tools}:/usr/bin:/bin", "SHELL": "/bin/bash", "TERM": "xterm-256color", "LANG": "C.UTF-8", "FAKE_STATE": str(self.state), + # TMPDIR may itself be inside another repository. + "GIT_CEILING_DIRECTORIES": str(self.root), } def build_release(self): @@ -128,7 +132,7 @@ def start(self, *args, cwd=None, **env): ) pid, fd = pty.fork() if pid == 0: - os.chdir(cwd or self.root) + os.chdir(cwd or self.outside) os.execve("/bin/bash", ["bash", "-c", command, "installer", *args], dict(self.env, **env)) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0)) self.pid, self.fd, self.output, self.seen = pid, fd, b"", 0 @@ -209,16 +213,15 @@ def setup_call(self): # -- scenarios ---------------------------------------------------------- def test_sign_in_then_global_setup_outside_a_repository(self): - # The full first-run path outside a repository: browser sign-in, then - # setup installs globally without asking anything else. + # Even outside a repository, global hooks require a scope decision. self.start() self.answer("Sign in now?", ENTER) self.expect("Paste the code") os.write(self.fd, b"good\r") self.expect("Signed in as dev@example.test") + self.answer("Where should ArchDev install its skill and hooks?", ENTER) self.assertEqual(self.finish(), 0) - self.assertNotIn("Where should ArchDev install", self.text()) - self.assertEqual(self.setup_call(), ["setup --scope user --reporting enabled", str(self.root)]) + self.assertEqual(self.setup_call(), ["setup --scope user --reporting enabled", str(self.outside)]) self.assertIn("Skill and hooks installed for this user", self.text()) self.assertIn("is ready", self.text()) self.assertIn("Set up this repository with ArchDev.", self.text()) @@ -235,6 +238,7 @@ def test_a_release_without_the_onboarding_flag_signs_in_without_it(self): self.expect("Paste the code") os.write(self.fd, b"good\r") self.expect("Signed in as dev@example.test") + self.answer("Where should ArchDev install its skill and hooks?", ENTER) self.assertEqual(self.finish(), 0) self.assertEqual(self.login_call(), "auth login") @@ -262,15 +266,63 @@ def test_a_digit_jumps_to_that_option(self): self.assertEqual(self.setup_call()[0], "setup --scope repository --reporting enabled") @unittest.skipIf(shutil.which("node", path="/usr/bin:/bin"), "a system node is on the test PATH") - def test_without_node_a_repository_installs_globally_without_asking(self): - # Repository hooks need Node.js; with no node on PATH there is no choice. + def test_without_node_project_choice_fails_without_global_fallback(self): + (self.tools / "node").unlink() + (self.state / "signed-in").touch() + self.start(cwd=self.repo) + self.answer("Where should ArchDev install its skill and hooks?", DOWN, ENTER) + self.assertEqual(self.finish(), 1) + self.assertIn("Repository hooks need Node.js", self.text()) + self.assertIsNone(self.setup_call()) + + @unittest.skipIf(shutil.which("node", path="/usr/bin:/bin"), "a system node is on the test PATH") + def test_without_node_global_choice_still_works(self): (self.tools / "node").unlink() (self.state / "signed-in").touch() self.start(cwd=self.repo) + self.answer("Where should ArchDev install its skill and hooks?", ENTER) self.assertEqual(self.finish(), 0) - self.assertNotIn("Where should ArchDev install", self.text()) self.assertEqual(self.setup_call()[0], "setup --scope user --reporting enabled") + def test_project_choice_outside_git_does_not_install_global_hooks(self): + (self.state / "signed-in").touch() + self.start() + self.answer("Where should ArchDev install its skill and hooks?", DOWN, ENTER) + self.assertEqual(self.finish(), 1) + self.assertIn("needs a Git repository", self.text()) + self.assertIsNone(self.setup_call()) + + def run_unattended(self, *args): + return subprocess.run( + ["/bin/bash", str(ROOT / "install.sh"), "--base-url", f"file://{self.release}", + "--version", VERSION, "--install-dir", str(self.home / ".local/bin"), *args], + cwd=self.repo, env=self.env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30, + ) + + def test_unattended_setup_requires_scope_even_with_yes(self): + (self.state / "signed-in").touch() + for args in ((), ("--yes",)): + with self.subTest(args=args): + result = self.run_unattended(*args) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("--scope user or --scope repository", result.stdout) + self.assertIsNone(self.setup_call()) + + def test_explicit_unattended_scope_is_preserved(self): + (self.state / "signed-in").touch() + for scope, cwd in (("user", self.repo), ("repository", self.repo)): + with self.subTest(scope=scope): + result = self.run_unattended("--yes", "--scope", scope) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.setup_call(), [f"setup --scope {scope} --reporting enabled", str(cwd)]) + + def test_skip_setup_does_not_require_scope(self): + (self.state / "signed-in").touch() + result = self.run_unattended("--yes", "--skip-setup") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIsNone(self.setup_call()) + def test_skipping_sign_in_skips_setup(self): self.start() self.answer("Sign in now?", DOWN, ENTER) @@ -291,6 +343,7 @@ def test_failed_sign_in_still_installs(self): def test_failed_setup_shows_the_error_and_exits_nonzero(self): (self.state / "signed-in").touch() self.start(FAKE_SETUP_FAIL="1") + self.answer("Where should ArchDev install its skill and hooks?", ENTER) self.assertEqual(self.finish(), 1) self.assertIn("hooks directory is read-only", self.text()) self.assertIn("then run: archdev setup --scope user", self.text()) @@ -300,6 +353,13 @@ def test_interrupt_at_a_prompt_restores_the_terminal(self): self.answer("Sign in now?", b"\x03") self.assertEqual(self.finish(), 130) + def test_interrupt_at_scope_prompt_does_not_install_hooks(self): + (self.state / "signed-in").touch() + self.start() + self.answer("Where should ArchDev install its skill and hooks?", b"\x03") + self.assertEqual(self.finish(), 130) + self.assertIsNone(self.setup_call()) + def test_plain_terminal_uses_numbered_prompts(self): self.start(TERM="dumb") self.expect(r"Sign in now\?") diff --git a/scripts/test-installer-scope.ps1 b/scripts/test-installer-scope.ps1 new file mode 100644 index 0000000..177d9f4 --- /dev/null +++ b/scripts/test-installer-scope.ps1 @@ -0,0 +1,53 @@ +# Execute the actual placement resolver without downloading/installing anything. +$ErrorActionPreference = "Stop" +$tokens = $null +$errors = $null +$installer = Join-Path $PSScriptRoot "../install.ps1" +$ast = [Management.Automation.Language.Parser]::ParseFile($installer, [ref]$tokens, [ref]$errors) +if ($errors.Count -ne 0) { throw "Installer parse errors: $errors" } +$resolver = $ast.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq "Resolve-SetupScope" +}, $true) +if (-not $resolver) { throw "Missing Resolve-SetupScope" } +Invoke-Expression $resolver.Extent.Text + +$script:Answers = [Collections.Generic.Queue[string]]::new() +$script:PromptCount = 0 +function Read-Host([string]$Prompt) { + $script:PromptCount++ + if ($script:Answers.Count -eq 0) { throw "Unexpected prompt: $Prompt" } + return $script:Answers.Dequeue() +} +function Assert-Equal($Actual, $Expected) { + if ($Actual -ne $Expected) { throw "Expected '$Expected', got '$Actual'" } +} +function Assert-Rejected([scriptblock]$Call, [string]$Message) { + $failure = $null + try { & $Call | Out-Null } catch { $failure = "$_" } + if (-not $failure -or $failure -notlike "*$Message*") { throw "Expected rejection containing '$Message', got '$failure'" } +} + +# Interactive choice is independent of whether project-local is available. +$script:Answers.Enqueue("1") +Assert-Equal (Resolve-SetupScope "" $true "" $false) "user" +$script:Answers.Enqueue("2") +Assert-Rejected { Resolve-SetupScope "" $true "" $true } "needs a Git repository" +$script:Answers.Enqueue("2") +Assert-Rejected { Resolve-SetupScope "" $true "C:\repo" $false } "need Node.js" +$script:Answers.Enqueue("2") +Assert-Equal (Resolve-SetupScope "" $true "C:\repo" $true) "repository" +# Invalid input must not silently approve global placement. +$script:Answers.Enqueue("invalid") +$script:Answers.Enqueue("2") +Assert-Equal (Resolve-SetupScope "" $true "C:\repo" $true) "repository" +Assert-Equal $script:PromptCount 6 + +# No terminal/--yes is not consent, while explicit scopes remain usable. +$before = $script:PromptCount +Assert-Rejected { Resolve-SetupScope "" $false "C:\repo" $true } "-Scope user or -Scope repository" +Assert-Equal (Resolve-SetupScope "user" $false "" $false) "user" +Assert-Equal (Resolve-SetupScope "repository" $false "C:\repo" $true) "repository" +Assert-Rejected { Resolve-SetupScope "repository" $false "" $true } "needs a Git repository" +Assert-Rejected { Resolve-SetupScope "repository" $false "C:\repo" $false } "need Node.js" +Assert-Equal $script:PromptCount $before +Write-Host "Installer scope contracts passed"