Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -12802,6 +12802,7 @@ FLAG basecamp setup --account type=string
FLAG basecamp setup --agent type=bool
FLAG basecamp setup --cache-dir type=string
FLAG basecamp setup --count type=bool
FLAG basecamp setup --customize type=bool
FLAG basecamp setup --help type=bool
FLAG basecamp setup --hints type=bool
FLAG basecamp setup --ids-only type=bool
Expand All @@ -12810,6 +12811,7 @@ FLAG basecamp setup --jq type=string
FLAG basecamp setup --json type=bool
FLAG basecamp setup --markdown type=bool
FLAG basecamp setup --md type=bool
FLAG basecamp setup --minimal type=bool
FLAG basecamp setup --no-hints type=bool
FLAG basecamp setup --no-stats type=bool
FLAG basecamp setup --profile type=string
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install

On Windows 11 with Smart App Control, see [Troubleshooting](#windows-smart-app-control-and-smartscreen) if the install is blocked.

That's it. You now have full access to Basecamp from your terminal.
On an interactive terminal, the installer opens Basecamp setup: approve OAuth in your browser and the CLI uses the account granted by OAuth, otherwise preserves an existing account or selects the first available. It saves that account globally, clears the global project default, and connects every detected coding agent. Directory-specific and environment project settings continue to apply. Use `basecamp setup --customize` to choose those settings instead.

<details>
<summary>Other installation methods</summary>
Expand Down Expand Up @@ -69,6 +69,11 @@ nix profile install github:basecamp/basecamp-cli
go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest
```

**mise:**
```bash
mise use --global github:basecamp/basecamp-cli@latest
```

**GitHub Release:** download from [Releases](https://github.com/basecamp/basecamp-cli/releases).

</details>
Expand All @@ -83,7 +88,7 @@ What happens depends on how the CLI was installed:

- **Installer script / tarball** (a binary under your home directory, e.g. `~/bin` or `~/.local/bin`): upgrades in place. The CLI downloads the release for your platform, verifies its Sigstore signature (the keyless `checksums.txt.bundle` published by the release pipeline, identity-pinned to the release workflow and tag) and SHA-256 checksum, swaps the executable transactionally, and confirms the installed binary reports the new version. On failure the previous binary is restored; in the worst case — restoration itself fails mid-swap — the error names the preserved backup file next to the binary so you can put it back by hand.
- **Homebrew / Scoop**: delegates to `brew upgrade --cask` / `scoop update`, then verifies the manager-installed binary actually reports the new version.
- **System packages** (apt/dnf/apk, AUR, Nix) and **`go install` builds**: never touched. `basecamp upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. `go install`; otherwise which package manager to use).
- **System packages** (apt/dnf/apk, AUR, Nix), **mise**, and **`go install` builds**: never touched. `basecamp upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. mise or `go install`; otherwise which package manager to use).

`basecamp upgrade` exits 0 only when there is no update, or the update was applied *and confirmed*. Every other outcome is a structured failure (`"ok": false` in JSON) with one of these codes:

Expand All @@ -96,6 +101,20 @@ What happens depends on how the CLI was installed:

The install scripts verify release signatures when `cosign` is available: cosign v3 verifies the published bundle format as-is, v2.6+ is driven with `--new-bundle-format=true`, and older versions skip signature verification with a warning (SHA-256 checksums are always verified).

## First-time setup

The first interactive `basecamp` run applies the recommended setup automatically after browser approval:

- Account granted by OAuth, otherwise the existing configured account or first available account, saved globally
- No global default project; directory-specific and environment project settings continue to apply
- Every detected Claude Code or Codex integration

Run the same setup directly with `basecamp setup`. To choose the account, default project, config scope, and agent integrations, run:

```bash
basecamp setup --customize
```

## Usage

```bash
Expand Down
172 changes: 171 additions & 1 deletion e2e/installer.bats
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

setup() {
# The installer contract keys off these; a leaked value would skew results.
unset BASECAMP_SKIP_SETUP BASECAMP_SETUP_AGENT
unset BASECAMP_SKIP_SETUP BASECAMP_NONINTERACTIVE BASECAMP_SETUP_AGENT

INSTALL_SH="${BATS_TEST_DIRNAME}/../scripts/install.sh"
INSTALL_PS1="${BATS_TEST_DIRNAME}/../scripts/install.ps1"
Expand Down Expand Up @@ -95,6 +95,68 @@ run_post_install_setup() {
[[ "$output" != *"BASH_SOURCE[0]: unbound variable"* ]]
}

@test "install.sh gives piped first-time setup the controlling terminal" {
run grep -F 'run_first_time_setup "$BIN_DIR/$binary_name" </dev/tty' "$INSTALL_SH"
[[ "$status" -eq 0 ]]
run grep -F '[[ -t 1 ]] && [[ -t 2 ]] && { : </dev/tty; } 2>/dev/null' "$INSTALL_SH"
[[ "$status" -eq 0 ]]
}

@test "install.sh matches the CLI non-interactive truthy values" {
run bash -c "
set -euo pipefail
source '$INSTALL_SH'
for value in 1 true TRUE True; do
env_value_is_true \"\$value\" || exit 1
done
for value in 0 false FALSE yes ''; do
if env_value_is_true \"\$value\"; then exit 1; fi
done
"
[[ "$status" -eq 0 ]]
}

@test "install.sh rejects a present but unusable controlling terminal" {
[[ "$(uname -s)" == "Linux" ]] || skip "setsid reproduction requires Linux"
command -v setsid >/dev/null 2>&1 || skip "setsid is required"
command -v script >/dev/null 2>&1 || skip "script is required"

cat > "$STUB_DIR/tty-probe" <<EOF
#!/usr/bin/env bash
source '$INSTALL_SH'
if can_run_first_time_setup; then
echo SETUP_CAN_RUN
else
echo SETUP_CANNOT_RUN
fi
EOF
chmod +x "$STUB_DIR/tty-probe"

run script -qec "setsid -f -w '$STUB_DIR/tty-probe'" /dev/null
[[ "$status" -eq 0 ]]
[[ "$output" == *"SETUP_CANNOT_RUN"* ]]
[[ "$output" != *"SETUP_CAN_RUN"* ]]
}

@test "install.sh keeps installation successful when first-time setup fails" {
cat > "$STUB_DIR/setup-fails" <<'EOF'
#!/usr/bin/env bash
exit 23
EOF
chmod +x "$STUB_DIR/setup-fails"

run bash -c "
set -euo pipefail
source '$INSTALL_SH'
run_first_time_setup '$STUB_DIR/setup-fails'
echo install-survived
"
[[ "$status" -eq 0 ]]
[[ "$output" == *"First-time setup did not finish"* ]]
[[ "$output" == *"basecamp setup"* ]]
[[ "$output" == *"install-survived"* ]]
}

@test "new binary: post_install_setup dispatches to 'setup agents', never 'setup claude'" {
run_post_install_setup
[[ "$status" -eq 0 ]]
Expand Down Expand Up @@ -218,6 +280,114 @@ run_post_install_setup() {
[[ "$output" == *"nk=1 skill install"* ]]
}

@test "install.ps1 honors non-interactive values and tolerates setup failure" {
if ! command -v pwsh >/dev/null 2>&1; then
if [[ -n "${CI:-}" ]]; then
echo "pwsh is required in CI for install.ps1 setup coverage" >&2
return 1
fi
skip "pwsh not installed"
fi

cat > "$STUB_DIR/ps-setup-fails" <<'EOF'
#!/usr/bin/env bash
exit 23
EOF
chmod +x "$STUB_DIR/ps-setup-fails"

cat > "$STUB_DIR/first-time-driver.ps1" <<'EOF'
$ErrorActionPreference = 'Stop'
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" }
foreach ($name in @('Test-TruthyEnvironmentValue', 'Invoke-FirstTimeSetup')) {
$fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name }, $true)
if (-not $fn) { throw "$name not found in install.ps1" }
. ([scriptblock]::Create($fn.Extent.Text))
}
function Warn([string]$Message) { $script:WarningMessage = $Message }
foreach ($value in @('1', 'true', 'TRUE', 'True')) {
if (-not (Test-TruthyEnvironmentValue $value)) { throw "truthy value rejected: $value" }
}
foreach ($value in @('0', 'false', 'yes', '')) {
if (Test-TruthyEnvironmentValue $value) { throw "falsey value accepted: $value" }
}
Invoke-FirstTimeSetup $env:PS_SETUP_STUB
"WARN:$script:WarningMessage"
'install-survived'
EOF

run bash -c "
set -euo pipefail
export INSTALL_PS1_PATH='$INSTALL_PS1' PS_SETUP_STUB='$STUB_DIR/ps-setup-fails'
pwsh -NoProfile -File '$STUB_DIR/first-time-driver.ps1'
"
[[ "$status" -eq 0 ]]
[[ "$output" == *"WARN:First-time setup did not finish"* ]]
[[ "$output" == *"install-survived"* ]]
grep -qF 'Test-TruthyEnvironmentValue $env:BASECAMP_NONINTERACTIVE' "$INSTALL_PS1"
}

@test "install.ps1 first-time setup preserves terminal streams and visible output" {
[[ "$(uname -s)" == "Linux" ]] || skip "PTY stream reproduction requires Linux"
command -v pwsh >/dev/null 2>&1 || skip "pwsh not installed"
command -v script >/dev/null 2>&1 || skip "script is required"

PS_TTY_LOG="$STUB_DIR/ps-setup-tty.log"
cat > "$STUB_DIR/ps-setup-probe" <<'EOF'
#!/usr/bin/env bash
stdin=redirected
stdout=redirected
stderr=redirected
[[ -t 0 ]] && stdin=tty
[[ -t 1 ]] && stdout=tty
[[ -t 2 ]] && stderr=tty
printf 'stdin=%s stdout=%s stderr=%s\n' "$stdin" "$stdout" "$stderr" > "$PS_TTY_LOG"
echo SETUP_OUTPUT_VISIBLE
EOF
chmod +x "$STUB_DIR/ps-setup-probe"

cat > "$STUB_DIR/ps-first-time-tty-driver.ps1" <<'EOF'
$ErrorActionPreference = 'Stop'
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" }
foreach ($name in @('Warn', 'Invoke-FirstTimeSetup')) {
$fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name }, $true)
if (-not $fn) { throw "$name not found in install.ps1" }
. ([scriptblock]::Create($fn.Extent.Text))
}
Invoke-FirstTimeSetup $env:PS_SETUP_STUB
EOF

run script -qec "INSTALL_PS1_PATH='$INSTALL_PS1' PS_SETUP_STUB='$STUB_DIR/ps-setup-probe' PS_TTY_LOG='$PS_TTY_LOG' pwsh -NoProfile -File '$STUB_DIR/ps-first-time-tty-driver.ps1'" /dev/null
[[ "$status" -eq 0 ]]
[[ "$output" == *"SETUP_OUTPUT_VISIBLE"* ]]
[[ "$(cat "$PS_TTY_LOG")" == "stdin=tty stdout=tty stderr=tty" ]]
run grep -F '[void](Invoke-FirstTimeSetup' "$INSTALL_PS1"
[[ "$status" -ne 0 ]]
}

@test "install.ps1 treats redirected stderr as non-interactive" {
[[ "$(uname -s)" == "Linux" ]] || skip "PTY stream reproduction requires Linux"
command -v pwsh >/dev/null 2>&1 || skip "pwsh not installed"
command -v script >/dev/null 2>&1 || skip "script is required"

cat > "$STUB_DIR/ps-interactive-driver.ps1" <<'EOF'
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:INSTALL_PS1_PATH, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count -gt 0) { throw "install.ps1 parse errors: $($parseErrors -join '; ')" }
$fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Test-InteractiveSession' }, $true)
if (-not $fn) { throw 'Test-InteractiveSession not found in install.ps1' }
. ([scriptblock]::Create($fn.Extent.Text))
"INTERACTIVE:$(Test-InteractiveSession)"
EOF

run script -qec "INSTALL_PS1_PATH='$INSTALL_PS1' pwsh -NoProfile -File '$STUB_DIR/ps-interactive-driver.ps1' 2>'$STUB_DIR/ps-stderr.log'" /dev/null
[[ "$status" -eq 0 ]]
[[ "$output" == *"INTERACTIVE:False"* ]]
}

# The Windows canary can never prove the ps1 belt — Credential Manager works
# headlessly with or without it — so pin the behavior here. The function under
# test is extracted from install.ps1's AST and evaluated alone: Main never
Expand Down
46 changes: 41 additions & 5 deletions e2e/setup.bats
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env bats
# setup.bats - `basecamp setup` refuses to prompt when nothing can answer it.
# setup.bats - `basecamp setup` refuses when first-time setup cannot run safely.
#
# The wizard is prompts end to end, and huh runs them as a bubbletea program.
# Redirecting stdin does not make that program fail: bubbletea sees a
# non-terminal stdin and opens /dev/tty instead, so the prompt waits on the real
# terminal — `basecamp setup --json < /dev/null` hung forever.
# Recommended setup opens browser OAuth, while `--customize` also uses huh
# prompts. Redirecting stdin does not make a bubbletea prompt fail: it can open
# /dev/tty instead and wait on the real terminal. The setup gate keeps both
Comment thread
robzolkos marked this conversation as resolved.
# modes out of contexts that cannot complete them.
#
# Every case runs under a timeout, and the timeout is the assertion: exit 124 is
# the bug reproducing. A unit test with a fake reader cannot catch this, because
Expand Down Expand Up @@ -68,6 +68,42 @@ assert_refused() {
assert_refused
}

@test "setup --customize refuses under --json with redirected stdin" {
create_credentials
create_global_config '{"account_id": 99999}'

run_guarded "basecamp setup --customize --json < /dev/null"
assert_refused
}

@test "setup --minimal refuses under --json with redirected stdin" {
create_credentials
create_global_config '{"account_id": 99999}'

run_guarded "basecamp setup --minimal --json < /dev/null"
assert_refused
}

@test "setup --project gives non-interactive config guidance" {
create_credentials
create_global_config '{"account_id": 99999}'

run_guarded "basecamp setup --project 123 --json < /dev/null"
assert_not_timed_out
assert_failure
assert_json_value '.code' 'usage'
assert_json_value '.hint | contains("basecamp config set project_id <id>")' 'true'
assert_json_value '.hint | contains("--customize")' 'false'
}

@test "setup rejects an unknown subcommand before onboarding" {
run_guarded "basecamp setup codxe --json < /dev/null"
assert_not_timed_out
assert_failure
assert_json_value '.error | contains("unknown command")' 'true'
assert_json_value '.error | contains("interactive terminal")' 'false'
}

@test "setup without --json and stdin closed refuses instead of hanging" {
create_credentials
create_global_config '{"account_id": 99999}'
Expand Down
Loading