diff --git a/.gitignore b/.gitignore
index 0df7a29b8..13adce038 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,9 @@ out/
*.secret
#Sdkman
.sdkmanrc
+
+# setup wizard outputs (gitignored to protect credentials)
+box-config.properties
+box-jwt-config.json
+__pycache__/
+*.py[cod]
diff --git a/WIZARD_FLOWS.md b/WIZARD_FLOWS.md
new file mode 100644
index 000000000..0809e60ef
--- /dev/null
+++ b/WIZARD_FLOWS.md
@@ -0,0 +1,178 @@
+# Box Java SDK — Setup Wizard Quick Reference
+
+A visual reference for the optional setup wizard added by the
+`./scripts/setup` script. The wizard is **opt-in** — the SDK works
+fine without it; this just streamlines first-time onboarding.
+
+---
+
+## At a glance
+
+| Command | What it does |
+| -------------------------------------- | ------------------------------------------------------------------------- |
+| `./scripts/setup` | First-run + repair wizard. Picker-driven, idempotent. |
+| `./scripts/doctor` | Read-only audit. Re-runs each step's check function and prints results. |
+| `./scripts/setup` then pick `recommended` | Set up Java/Gradle preflight, capture auth credentials, print a smoke snippet. |
+| `WIZARD_PACE_MS=0 ./scripts/setup` | Disable pacing for CI / scripted onboarding. |
+| `WIZARD_PACE_MS=400 ./scripts/setup` | Slow pacing for first-time users who want extra read time. |
+
+The wizard never modifies SDK source files. It writes one file at the
+repo root: **`box-config.properties`** (gitignored).
+
+---
+
+## Decision tree
+
+```mermaid
+flowchart TD
+ Start([./scripts/setup]) --> Banner[Print banner +
3-step picker]
+ Banner --> Picker{Picker}
+ Picker -->|recommended
or `all`| AllSet[Run steps 1, 2, 3]
+ Picker -->|1| OnlyPre[Step 1 only]
+ Picker -->|2| OnlyAuth[Step 2 only]
+ Picker -->|3| OnlySmoke[Step 3 only]
+ Picker -->|none| Exit([Exit cleanly])
+
+ AllSet --> S1[Step 1
Java + Gradle preflight]
+ OnlyPre --> S1
+ S1 --> S1Check{java >= 8 AND
./gradlew works?}
+ S1Check -->|yes| S2[Step 2
Auth credentials]
+ S1Check -->|no| S1Hint[Print install hint
continue anyway]
+ S1Hint --> S2
+
+ OnlyAuth --> S2
+ S2 --> ModePicker{Auth mode?}
+ ModePicker -->|1| DevToken[Mint Developer Token
at app.box.com/developers/console
capture via getpass]
+ ModePicker -->|2| JWT[Capture JWT config.json
path]
+ ModePicker -->|3| CCG[Capture client ID/secret/
EID-or-UID]
+ ModePicker -->|4| OAuth[Capture client ID/secret/
redirect URI]
+
+ DevToken --> WriteConfig[Write box-config.properties]
+ JWT --> WriteConfig
+ CCG --> WriteConfig
+ OAuth --> WriteConfig
+
+ WriteConfig --> S3[Step 3
Smoke test snippet]
+ OnlySmoke --> S3
+ S3 --> SnippetCheck{box-config.properties
exists?}
+ SnippetCheck -->|yes| Print4Line[Print 4-line Java
snippet keyed to chosen mode]
+ SnippetCheck -->|no| HintGoBack[Hint: pick step 2 first]
+
+ Print4Line --> Footer[Print completion footer +
doctor + docs links]
+ HintGoBack --> Footer
+ Footer --> Done([User runs ./scripts/doctor
to verify])
+```
+
+---
+
+## Steps
+
+| # | Step | Recommended | Check | Repair | Writes |
+| - | --------------------- | ----------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------- |
+| 1 | Java + Gradle preflight | yes | `java -version` >= 8, `./gradlew --version` succeeds | print install hint pointing at adoptium.net or `brew install gradle` | nothing |
+| 2 | Auth credentials | yes | `box-config.properties` exists with `box.auth.mode` set | inner picker for Developer Token / JWT / CCG / OAuth, then capture creds | `box-config.properties` |
+| 3 | Smoke test snippet | yes | (none — printing-only step) | print a copy-pasteable Java program keyed to the chosen auth mode | nothing |
+
+---
+
+## Picker grammar
+
+| Input | Means |
+| ------------- | ------------------------------------------------ |
+| `1,2,3` | comma-separated indices |
+| `1-3` | a range (inclusive) |
+| `all` | every step |
+| `recommended` | every `[R]` step (the default if you press Enter) |
+| `none` | exit without running anything |
+
+---
+
+## Auth modes
+
+| Mode | When to pick | What the wizard collects | Where it lands in `box-config.properties` |
+| ----------------------- | ------------------------------------------- | --------------------------------------------------------- | ----------------------------------------- |
+| Developer Token | Local dev, testing on your own account | One token (60-min TTL) | `box.auth.token` |
+| JWT | Server-to-server, no user impersonation | Path to JWT config JSON | `box.jwt.config.path` |
+| Client Credentials (CCG) | Server-to-server, modern flow | Client ID, secret, enterprise OR user ID | `box.ccg.*` |
+| OAuth 2.0 | Multi-user, end-user-facing app | Client ID, secret, redirect URI | `box.oauth.*` |
+
+The wizard always sets `box.auth.mode` to one of `developer_token`,
+`jwt`, `ccg`, or `oauth`. The doctor reads it to decide which
+mode-specific check function should run.
+
+---
+
+## Cross-cutting features
+
+### Pacing
+Output is paced so multi-paragraph blocks become readable. Override
+with `WIZARD_PACE_MS=` (`0` disables, `80` is default, `400` is a
+slow ramp). Pacing auto-disables when stdout isn't a TTY.
+
+### Self-contained credential capture
+The wizard reads `box-config.properties` itself when needed and never
+asks you to "first source X" or "first export Y". Cloning the repo and
+running `./scripts/setup` is the only prerequisite.
+
+### Doctor-symmetric checks
+Every wizard step has a check function in `setup/checks.py` that the
+doctor calls. Re-running `./scripts/doctor` after a wizard run prints
+all results in one shot, no prompts, exit code 0 when everything's OK.
+
+### Hidden token entry
+Tokens, secrets, and client secrets are read with `getpass.getpass` so
+they never echo to the terminal or land in shell history. The wizard
+prints the first 8 characters back later as a sanity-check preview.
+
+### Owner-only secrets file
+`box-config.properties` is chmod 0600 on POSIX systems. Best-effort on
+Windows / unusual filesystems.
+
+---
+
+## Common recovery paths
+
+| Symptom | What to try |
+| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
+| `[FAIL] java: not on PATH` | Install JDK 8+ from https://adoptium.net/temurin/releases/, then re-run. |
+| `[FAIL] gradle: ...` | Restore `./gradlew` (`git checkout gradlew gradle/`) or `brew install gradle`. |
+| `[WARN] box-config: not found` | `./scripts/setup` and pick step 2. |
+| `[FAIL] developer-token: token empty` | Mint a fresh one at https://app.box.com/developers/console, re-run step 2. |
+| `[FAIL] jwt-config: not at ` | Verify the path; the developer console downloads as `config.json`. |
+| Picker rejected my input | Use commas, ranges, `all`, `recommended`, or `none`. No spaces inside numbers. |
+| Smoke snippet 401's when I run it | Token expired (Developer Tokens last 60 min). Re-run step 2 for a fresh one. |
+
+---
+
+## Verifying success
+
+```bash
+./scripts/doctor # Should report all OK.
+./gradlew test # Run the SDK's own tests
+```
+
+For a real end-to-end check, paste the snippet from the wizard's step 3
+into a Java file with `com.box:box-java-sdk` on the classpath, run it,
+and confirm it prints your name.
+
+---
+
+## What this wizard does NOT do
+
+- **Compile a smoke binary**: adding a smoke task to `build.gradle` is
+ a maintainer decision, not setup-script scope. The wizard prints a
+ snippet for you to integrate however you want.
+- **Run the OAuth callback flow**: OAuth needs an HTTP server to
+ receive the redirect, which is application-specific. The wizard
+ captures credentials; running the flow is on you.
+- **Refresh tokens**: Developer Tokens expire after 60 minutes. The
+ doctor reminds you of that but doesn't auto-refresh.
+- **Manage multiple environments**: one repo = one
+ `box-config.properties`. If you need profiles, layer that on top.
+
+---
+
+Built using
+[`setup-wizard-squared`](https://github.com/NatalieNobile/setup-wizard-squared).
+See its `reference/patterns.md` for the seven design principles that
+shape this wizard.
diff --git a/box-config.properties.example b/box-config.properties.example
new file mode 100644
index 000000000..0842c3adc
--- /dev/null
+++ b/box-config.properties.example
@@ -0,0 +1,28 @@
+# box-config.properties — generated by ./scripts/setup
+#
+# This file is gitignored. Do not commit a real credentials file.
+# Run `./scripts/setup` and pick the auth-credentials step to create
+# the live `box-config.properties`. Re-running is safe and idempotent.
+
+# ---- common ----
+# One of: developer_token, jwt, ccg, oauth
+box.auth.mode=developer_token
+
+# ---- developer_token ----
+# Mint at https://app.box.com/developers/console — expires after 60 min.
+box.auth.token=YOUR_DEVELOPER_TOKEN_HERE
+
+# ---- jwt ----
+# Absolute path to the config.json downloaded from the developer console.
+# box.jwt.config.path=/absolute/path/to/box-jwt-config.json
+
+# ---- ccg ----
+# box.ccg.client.id=YOUR_CLIENT_ID
+# box.ccg.client.secret=YOUR_CLIENT_SECRET
+# box.ccg.subject.kind=enterprise
+# box.ccg.subject.id=YOUR_ENTERPRISE_OR_USER_ID
+
+# ---- oauth ----
+# box.oauth.client.id=YOUR_CLIENT_ID
+# box.oauth.client.secret=YOUR_CLIENT_SECRET
+# box.oauth.redirect.uri=http://localhost:8080/callback
diff --git a/scripts/doctor b/scripts/doctor
new file mode 100755
index 000000000..d2848cc22
--- /dev/null
+++ b/scripts/doctor
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Doctor launcher. Run from the target repo root.
+#
+# Forwards all args to setup/doctor.py. Read-only; safe to run anytime.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+exec python3 -m setup.doctor "$@"
diff --git a/scripts/setup b/scripts/setup
new file mode 100755
index 000000000..0f7537fb3
--- /dev/null
+++ b/scripts/setup
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Setup wizard launcher. Run from the target repo root.
+#
+# Forwards all args to setup/wizard.py. Adjust PYTHONPATH if your
+# setup package lives somewhere other than the repo root.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+exec python3 -m setup.wizard "$@"
diff --git a/setup/__init__.py b/setup/__init__.py
new file mode 100644
index 000000000..677c1fb87
--- /dev/null
+++ b/setup/__init__.py
@@ -0,0 +1,6 @@
+"""``setup`` — first-run wizard + doctor scaffold (setup-wizard-squared).
+
+Customize ``wizard.py`` and ``checks.py`` per repo. The package layout
+is fixed so ``python3 -m setup.wizard`` and ``python3 -m setup.doctor``
+both resolve cleanly.
+"""
diff --git a/setup/checks.py b/setup/checks.py
new file mode 100644
index 000000000..3cc6cbcff
--- /dev/null
+++ b/setup/checks.py
@@ -0,0 +1,432 @@
+"""Health checks shared by the wizard and doctor.
+
+Every check returns one or more :class:`CheckResult`. The same functions
+are called from the wizard (per-step verification) and the doctor (full
+report) so validation logic lives in exactly one place. This is the
+"doctor-symmetric" principle from ``reference/patterns.md``.
+
+When porting:
+
+1. Replace the example checks at the bottom with your repo's checks.
+2. Add a category grouping if you have many checks (the bq_ranger
+ reference splits `core` vs `extras` so the doctor can group output).
+3. Each check that talks to a network service should accept an optional
+ ``timeout`` kwarg — default 10-15s. Long-hung probes break the
+ doctor's "fast read-only audit" contract.
+
+Conventions:
+ * ``fix_hint`` is required on every ``fail``. The hint must name a
+ concrete next action, not generic advice.
+ * Checks never prompt. If you need user input, that's a wizard step.
+ * Checks are pure read-only audits — they never write files or
+ mutate state.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from typing import Literal
+
+Severity = Literal["ok", "warn", "fail"]
+
+
+@dataclass
+class CheckResult:
+ """One row in the doctor's report.
+
+ ``fix_hint`` is required on every ``fail`` (enforced by convention,
+ not the type system). The hint must name the next concrete action —
+ "re-run ``./scripts/setup``", not "fix your config".
+ """
+
+ name: str
+ severity: Severity
+ message: str
+ fix_hint: str | None = None
+
+ @property
+ def ok(self) -> bool:
+ return self.severity == "ok"
+
+
+@dataclass
+class DoctorReport:
+ """Aggregated checks plus optional structured evidence.
+
+ Add fields here when the wizard needs to consume *typed* values from
+ the doctor (e.g. the resolved current-user name, the active project
+ id) instead of just severity rows. Keep the field count small —
+ most wizards never need more than a half-dozen.
+ """
+
+ results: list[CheckResult] = field(default_factory=list)
+
+ @property
+ def all_ok(self) -> bool:
+ """True iff no check failed (warnings tolerated)."""
+ return all(r.severity != "fail" for r in self.results)
+
+ @property
+ def any_failures(self) -> bool:
+ return any(r.severity == "fail" for r in self.results)
+
+ def add(self, *results: CheckResult) -> None:
+ self.results.extend(results)
+
+
+# ---- generic helpers ----------------------------------------------------
+
+
+def _run(
+ args: list[str],
+ *,
+ timeout: int = 15,
+ input_text: str | None = None,
+ env: dict[str, str] | None = None,
+) -> tuple[int, str, str]:
+ """Run a subprocess and capture ``(returncode, stdout, stderr)``.
+
+ Returns ``(127, "", str(exc))`` if the binary is missing — same
+ convention as ``which`` so callers don't need to special-case
+ ``FileNotFoundError`` separately from non-zero exit codes. Returns
+ ``(124, "", "timed out after Ns")`` on timeout.
+
+ ``env`` lets callers layer dotenv-derived values onto a copy of
+ ``os.environ`` so checks against tools that read tokens from env
+ (jira-cli, gh, gcloud) work without forcing the user to pre-source.
+ """
+ try:
+ proc = subprocess.run(
+ args,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ input=input_text,
+ check=False,
+ env=env,
+ )
+ return proc.returncode, proc.stdout, proc.stderr
+ except FileNotFoundError as exc:
+ return 127, "", str(exc)
+ except subprocess.TimeoutExpired:
+ return 124, "", f"timed out after {timeout}s"
+
+
+def _on_path(name: str) -> bool:
+ """True if ``name`` resolves to an executable on the user's PATH."""
+ return shutil.which(name) is not None
+
+
+def is_ssl_trust_failure(message: str) -> bool:
+ """True if ``message`` looks like a TLS trust-store error.
+
+ SSL verification failures aren't credential / config / network
+ problems — re-prompting the user for tokens or rerunning the check
+ would waste their time. Branch on this so the wizard can route to
+ a "fix your trust store" remediation block instead of treating it
+ as a generic auth failure.
+
+ Patterns cover every flavor of "I couldn't verify the server cert"
+ surfaced by urllib, requests, curl, openssl on macOS / Linux. We
+ don't detect *valid* certs rejected for other reasons (expiry,
+ hostname mismatch) — those are real network problems and a generic
+ "check VPN" hint is correct.
+ """
+ return (
+ "CERTIFICATE_VERIFY_FAILED" in message
+ or "[SSL:" in message
+ or "SSL certificate problem" in message
+ or "unable to get local issuer certificate" in message
+ or "self-signed certificate" in message
+ )
+
+
+def has_ssl_trust_failure(results: Iterable["CheckResult"]) -> bool:
+ """True iff any non-OK result in ``results`` looks like an SSL trust error."""
+ return any(
+ r.severity != "ok" and is_ssl_trust_failure(r.message)
+ for r in results
+ )
+
+
+# ---- box-java-sdk-specific checks ---------------------------------------
+
+
+import re
+from pathlib import Path
+
+from . import config as cfg
+
+
+_JAVA_VERSION_RE = re.compile(r'(?:openjdk|java) version "([\d._]+)"', re.IGNORECASE)
+
+
+def _parse_java_major(version_output: str) -> int | None:
+ """Parse the major version out of ``java -version`` output.
+
+ Java's version scheme has one quirk: pre-9 versions were ``1.X.0``
+ (e.g. ``1.8.0_362`` for Java 8), so the major is the *second*
+ component. Java 9+ flattened to ``X.Y.Z`` (e.g. ``17.0.5``).
+ Both shapes appear here in the wild on the same machine.
+ """
+ m = _JAVA_VERSION_RE.search(version_output)
+ if not m:
+ return None
+ raw = m.group(1)
+ parts = raw.split(".")
+ if not parts:
+ return None
+ try:
+ first = int(parts[0])
+ except ValueError:
+ return None
+ if first == 1 and len(parts) >= 2:
+ try:
+ return int(parts[1])
+ except ValueError:
+ return None
+ return first
+
+
+def check_java() -> list[CheckResult]:
+ """Java compiler/runtime is on PATH and meets the minimum version."""
+ if not _on_path("java"):
+ return [
+ CheckResult(
+ "java",
+ "fail",
+ "not on PATH",
+ fix_hint=(
+ "install JDK "
+ f"{cfg.MIN_JAVA_MAJOR}+ — see https://adoptium.net/temurin/releases/."
+ ),
+ )
+ ]
+ # `java -version` writes to stderr in older JDKs and stdout in newer.
+ # We capture both and search the merged blob.
+ rc, out, err = _run(["java", "-version"], timeout=5)
+ blob = (out or "") + "\n" + (err or "")
+ if rc != 0:
+ return [
+ CheckResult(
+ "java",
+ "fail",
+ f"`java -version` exited {rc}: {blob.strip()[:200]}",
+ fix_hint="reinstall JDK from https://adoptium.net/temurin/releases/.",
+ )
+ ]
+ major = _parse_java_major(blob)
+ if major is None:
+ return [
+ CheckResult(
+ "java",
+ "warn",
+ "could not parse java version; check manually",
+ fix_hint=f"`java -version` should report {cfg.MIN_JAVA_MAJOR}+.",
+ )
+ ]
+ if major < cfg.MIN_JAVA_MAJOR:
+ return [
+ CheckResult(
+ "java",
+ "fail",
+ f"java {major} found; SDK requires {cfg.MIN_JAVA_MAJOR}+",
+ fix_hint=(
+ f"install a newer JDK (>= {cfg.MIN_JAVA_MAJOR}) from "
+ "https://adoptium.net/temurin/releases/."
+ ),
+ )
+ ]
+ version_line = next(
+ (line for line in blob.splitlines() if "version" in line.lower()),
+ "",
+ ).strip()
+ detail = f"JDK {major} ({version_line})" if version_line else f"JDK {major}"
+ return [CheckResult("java", "ok", detail)]
+
+
+def check_gradle() -> list[CheckResult]:
+ """Gradle wrapper resolves and reports a version.
+
+ Prefers the bundled ``./gradlew`` over a system-wide ``gradle`` —
+ the wrapper pins the version build.gradle expects.
+ """
+ gradlew = cfg.REPO_ROOT / "gradlew"
+ if gradlew.exists() and gradlew.is_file():
+ rc, out, err = _run(
+ [str(gradlew), "--version"], timeout=60
+ )
+ if rc != 0:
+ return [
+ CheckResult(
+ "gradle",
+ "fail",
+ f"`./gradlew --version` exited {rc}: {(err or out).strip()[:200]}",
+ fix_hint="check ./gradlew permissions; ensure JAVA_HOME points at a JDK.",
+ )
+ ]
+ version_line = next(
+ (line for line in out.splitlines() if line.lower().startswith("gradle ")),
+ "(version not parsed)",
+ )
+ return [CheckResult("gradle", "ok", version_line.strip())]
+ if not _on_path("gradle"):
+ return [
+ CheckResult(
+ "gradle",
+ "fail",
+ "no ./gradlew wrapper and no system gradle on PATH",
+ fix_hint="install gradle (`brew install gradle`) or restore the wrapper.",
+ )
+ ]
+ rc, out, err = _run(["gradle", "--version"], timeout=30)
+ if rc != 0:
+ return [
+ CheckResult(
+ "gradle",
+ "fail",
+ f"`gradle --version` exited {rc}: {(err or out).strip()[:200]}",
+ fix_hint="reinstall gradle (`brew install gradle`).",
+ )
+ ]
+ version_line = next(
+ (line for line in out.splitlines() if line.lower().startswith("gradle ")),
+ "(version not parsed)",
+ )
+ return [CheckResult("gradle", "ok", f"system gradle: {version_line.strip()}")]
+
+
+def check_box_config() -> list[CheckResult]:
+ """``box-config.properties`` exists and declares an auth mode."""
+ if not cfg.BOX_CONFIG_FILE.exists():
+ return [
+ CheckResult(
+ "box-config",
+ "warn",
+ f"{cfg.BOX_CONFIG_FILE.name} not found",
+ fix_hint="run `./scripts/setup` and pick the auth-credentials step.",
+ )
+ ]
+ values = cfg.read_dotenv(cfg.BOX_CONFIG_FILE)
+ mode = values.get("box.auth.mode", "").strip()
+ if not mode:
+ return [
+ CheckResult(
+ "box-config",
+ "warn",
+ f"{cfg.BOX_CONFIG_FILE.name} present but `box.auth.mode` not set",
+ fix_hint="re-run `./scripts/setup` and pick the auth-credentials step.",
+ )
+ ]
+ if mode not in cfg.ALL_AUTH_MODES:
+ return [
+ CheckResult(
+ "box-config",
+ "fail",
+ f"unknown auth mode `{mode}` (expected one of {cfg.ALL_AUTH_MODES})",
+ fix_hint="re-run `./scripts/setup` to pick a supported mode.",
+ )
+ ]
+ return [
+ CheckResult(
+ "box-config",
+ "ok",
+ f"auth mode = {mode}",
+ )
+ ]
+
+
+def check_developer_token() -> list[CheckResult]:
+ """When mode=developer_token, the token is set somewhere reachable.
+
+ No-op for other modes (returns ``[OK]`` so the doctor stays quiet).
+ Developer tokens have a 60-min TTL so freshness, not just presence,
+ matters — but we can't detect expiry without a network call.
+ """
+ if not cfg.BOX_CONFIG_FILE.exists():
+ return [CheckResult("developer-token", "ok", "n/a (no config yet)")]
+ values = cfg.read_dotenv(cfg.BOX_CONFIG_FILE)
+ if values.get("box.auth.mode", "").strip() != cfg.AUTH_MODE_DEV_TOKEN:
+ return [CheckResult("developer-token", "ok", "n/a (other auth mode)")]
+ token = values.get("box.auth.token", "").strip()
+ if not token:
+ return [
+ CheckResult(
+ "developer-token",
+ "fail",
+ "mode=developer_token but `box.auth.token` is empty",
+ fix_hint=(
+ "re-run `./scripts/setup` and pick auth-credentials. "
+ f"Mint a fresh token at {cfg.DEV_CONSOLE_URL}."
+ ),
+ )
+ ]
+ return [
+ CheckResult(
+ "developer-token",
+ "ok",
+ f"token set ({len(token)} chars). Note: tokens expire after 60 min.",
+ )
+ ]
+
+
+def check_jwt_config() -> list[CheckResult]:
+ """When mode=jwt, the JWT config JSON exists and looks parseable."""
+ if not cfg.BOX_CONFIG_FILE.exists():
+ return [CheckResult("jwt-config", "ok", "n/a (no config yet)")]
+ values = cfg.read_dotenv(cfg.BOX_CONFIG_FILE)
+ if values.get("box.auth.mode", "").strip() != cfg.AUTH_MODE_JWT:
+ return [CheckResult("jwt-config", "ok", "n/a (other auth mode)")]
+ path_str = values.get("box.jwt.config.path", "").strip()
+ if not path_str:
+ return [
+ CheckResult(
+ "jwt-config",
+ "fail",
+ "mode=jwt but `box.jwt.config.path` not set",
+ fix_hint="re-run `./scripts/setup` and pick auth-credentials.",
+ )
+ ]
+ p = Path(path_str).expanduser()
+ if not p.exists():
+ return [
+ CheckResult(
+ "jwt-config",
+ "fail",
+ f"JWT config not at {p}",
+ fix_hint=(
+ "verify the path; the developer console downloads it as "
+ "config.json and you can place it anywhere."
+ ),
+ )
+ ]
+ import json
+
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ return [
+ CheckResult(
+ "jwt-config",
+ "fail",
+ f"JWT config at {p} is not valid JSON: {exc}",
+ fix_hint=(
+ "re-download the JWT app config from the developer console."
+ ),
+ )
+ ]
+ expected_keys = {"boxAppSettings", "enterpriseID"}
+ missing = expected_keys - set(data.keys())
+ if missing:
+ return [
+ CheckResult(
+ "jwt-config",
+ "warn",
+ f"JWT config at {p} missing keys: {sorted(missing)}",
+ fix_hint="re-download the JWT app config from the developer console.",
+ )
+ ]
+ return [CheckResult("jwt-config", "ok", f"valid JWT config at {p}")]
diff --git a/setup/config.py b/setup/config.py
new file mode 100644
index 000000000..fd7b9a7af
--- /dev/null
+++ b/setup/config.py
@@ -0,0 +1,206 @@
+"""Paths and small config helpers shared by the wizard and doctor.
+
+Discovers the target repo root relative to this file so the package
+works no matter where the user invokes it from. Per-user state lives
+in ``~/./`` (overridable via ``$WIZARD_STATE_DIR``) so the
+wizard never writes per-machine state into the git checkout.
+
+Customization checklist when porting to a new repo:
+
+1. Update ``PROJECT_NAME`` to whatever short name you want for the
+ ``~/./`` state dir.
+2. Decide where ``parents[N]`` should land — the scaffold assumes the
+ ``setup/`` package sits one directory below the repo root. If you
+ nest deeper (e.g. ``scripts/setup/``), bump the index.
+3. Add module-level constants for any well-known config files the
+ wizard creates (``BOX_CONFIG_FILE``, ``LOCAL_PROPERTIES``, etc.).
+ Mirror the ``ZD_ENV_FILE`` / ``PAT_ENV_FILE`` shape from bq_ranger:
+ accept an env-var override so tests can swap paths.
+4. Drop any constants you don't need. There's no charge for empty
+ space here — the file should be small.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+# ---- repo + state -------------------------------------------------------
+
+PROJECT_NAME: str = "box-java-sdk"
+
+# REPO_ROOT discovery. Adjust the parents[N] depth to match where you
+# install the setup package. Default: setup/ sits at /setup/, so
+# parents[1] resolves to .
+REPO_ROOT: Path = Path(__file__).resolve().parents[1]
+
+# Per-user state directory (created lazily, never at import time so
+# read-only callers don't trigger a filesystem mutation just by
+# importing this module).
+_STATE_DIR_ENV = f"{PROJECT_NAME.upper().replace('-', '_')}_STATE_DIR"
+STATE_DIR: Path = Path(
+ os.environ.get(_STATE_DIR_ENV, str(Path.home() / f".{PROJECT_NAME}"))
+).expanduser()
+
+
+def ensure_state_dir() -> Path:
+ """Create ``STATE_DIR`` if missing and return it.
+
+ Lazy so read-only callers (the doctor, ad-hoc imports in CI or
+ sandboxed environments without a writable ``$HOME``) don't trigger
+ a filesystem mutation just by importing the package. Idempotent.
+ """
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ return STATE_DIR
+
+
+# ---- Box-specific config files ------------------------------------------
+#
+# The wizard writes one ``box-config.properties`` at the repo root with
+# the user's chosen auth mode and credentials. Format is plain
+# ``KEY=VALUE`` so the wizard's ``read_dotenv``/``write_dotenv_key``
+# helpers handle it transparently and a smoke-test Java program can
+# parse it with java.util.Properties.
+#
+# JWT auth uses an additional JSON config file (the one the developer
+# console exports). The wizard records its absolute path in
+# box-config.properties under ``box.jwt.config.path``.
+#
+# All three accept env-var overrides so tests can substitute paths.
+
+BOX_CONFIG_FILE: Path = Path(
+ os.environ.get("BOX_SDK_CONFIG", str(REPO_ROOT / "box-config.properties"))
+).expanduser()
+
+BOX_CONFIG_EXAMPLE: Path = REPO_ROOT / "box-config.properties.example"
+
+DEFAULT_JWT_CONFIG_PATH: Path = Path(
+ os.environ.get("BOX_JWT_CONFIG", str(REPO_ROOT / "box-jwt-config.json"))
+).expanduser()
+
+# URLs printed in token-entry prompts so the user knows where to mint
+# each credential. Centralized so doc + wizard cite the same canonical
+# pages.
+DEV_CONSOLE_URL: str = "https://app.box.com/developers/console"
+JWT_AUTH_DOCS_URL: str = (
+ "https://developer.box.com/guides/authentication/jwt/jwt-setup/"
+)
+OAUTH_DOCS_URL: str = (
+ "https://developer.box.com/guides/authentication/oauth2/"
+)
+CCG_DOCS_URL: str = (
+ "https://developer.box.com/guides/authentication/client-credentials/"
+)
+
+# Minimum required Java version. The SDK targets Java 8; bump if you
+# pin newer in build.gradle.
+MIN_JAVA_MAJOR: int = 8
+
+# Auth mode constants. Stored verbatim in box-config.properties under
+# ``box.auth.mode`` and consumed by the smoke-test step.
+AUTH_MODE_DEV_TOKEN: str = "developer_token"
+AUTH_MODE_JWT: str = "jwt"
+AUTH_MODE_CCG: str = "ccg"
+AUTH_MODE_OAUTH: str = "oauth"
+
+ALL_AUTH_MODES: tuple[str, ...] = (
+ AUTH_MODE_DEV_TOKEN,
+ AUTH_MODE_JWT,
+ AUTH_MODE_CCG,
+ AUTH_MODE_OAUTH,
+)
+
+
+# ---- dotenv helpers -----------------------------------------------------
+
+
+def read_dotenv(path: Path) -> dict[str, str]:
+ """Parse a tiny .env-style file into a dict.
+
+ Handles ``KEY=value`` and ``KEY="value"`` / ``KEY='value'``. Ignores
+ blank lines and ``#`` comments. Does NOT support multiline values,
+ backslash escapes, or shell expansion — kept intentionally minimal
+ so we don't pull in python-dotenv as a dependency.
+ """
+ out: dict[str, str] = {}
+ if not path.exists():
+ return out
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ k = k.strip()
+ v = v.strip()
+ if (len(v) >= 2) and (v[0] == v[-1]) and v[0] in ("'", '"'):
+ v = v[1:-1]
+ out[k] = v
+ return out
+
+
+def write_dotenv_key(path: Path, key: str, value: str) -> None:
+ """Idempotently set ``key=value`` in a tiny .env file.
+
+ Creates the file if missing, replaces an existing key in place, or
+ appends if the key isn't there yet. Quotes the value with double
+ quotes if it contains a space, ``#``, or ``=`` so the same parser
+ that wrote it can read it back.
+
+ Sets owner-only (0600) permissions on POSIX systems — best-effort
+ on Windows / unusual filesystems where chmod isn't supported.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ needs_quote = any(c in value for c in (" ", "#", "="))
+ safe_value = value.replace('"', r"\"") if needs_quote else value
+ rendered = f'{key}="{safe_value}"' if needs_quote else f"{key}={value}"
+
+ if not path.exists():
+ path.write_text(rendered + "\n", encoding="utf-8")
+ chmod_owner_only(path)
+ return
+
+ lines = path.read_text(encoding="utf-8").splitlines()
+ replaced = False
+ for i, line in enumerate(lines):
+ stripped = line.lstrip()
+ if stripped.startswith(f"{key}="):
+ lines[i] = rendered
+ replaced = True
+ break
+ if not replaced:
+ lines.append(rendered)
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ chmod_owner_only(path)
+
+
+def chmod_owner_only(path: Path) -> None:
+ """Best-effort owner-only (0600) permissions for local secrets.
+
+ Windows and some network filesystems don't support POSIX modes.
+ The write still succeeded — keep setup flowing and lean on the
+ docs to call out platform-specific hardening if needed.
+ """
+ try:
+ path.chmod(0o600)
+ except OSError:
+ pass
+
+
+def atomic_write_text(path: Path, content: str, *, mode: int = 0o644) -> None:
+ """Write ``content`` to ``path`` atomically.
+
+ Writes to a sibling tempfile, then ``os.replace`` to swap it in.
+ Avoids leaving a half-written secrets file if the process is killed
+ mid-write. ``mode`` is best-effort (skipped on filesystems without
+ POSIX permissions).
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(content, encoding="utf-8")
+ try:
+ tmp.chmod(mode)
+ except OSError:
+ pass
+ os.replace(tmp, path)
diff --git a/setup/doctor.py b/setup/doctor.py
new file mode 100644
index 000000000..52a549f42
--- /dev/null
+++ b/setup/doctor.py
@@ -0,0 +1,92 @@
+"""``doctor`` — read-only environment audit.
+
+Calls every registered check function and prints results. Same
+``CheckResult`` rendering as the wizard, so the user gets the same
+``[OK]`` / ``[WARN]`` / ``[FAIL]`` glyphs from both surfaces.
+
+Exits non-zero only on hard failures. Warnings are tolerated — they
+mean "optional thing isn't set up" rather than "your environment is
+broken". Tune ``DOCTOR_FAIL_ON_WARN`` per repo if your audience needs
+strict exit codes.
+"""
+
+from __future__ import annotations
+
+import sys
+from collections.abc import Callable
+
+from . import checks
+from .checks import CheckResult, DoctorReport
+from .prompts import banner, paced_print, print_results
+
+# ---- customize per repo -------------------------------------------------
+
+DOCTOR_TITLE: str = "Box Java SDK doctor"
+DOCTOR_TAGLINE: str = (
+ "Read-only audit. Re-runs each setup step's check function so you can\n"
+ "see what's wired up and what isn't, without rerunning the wizard."
+)
+
+# Whether to exit non-zero on warnings. Default False — warnings here
+# usually mean "auth mode picked but credential file path doesn't exist
+# yet", which is not a hard failure.
+DOCTOR_FAIL_ON_WARN: bool = False
+
+CheckFn = Callable[[], list[CheckResult]]
+DOCTOR_CHECKS: list[CheckFn] = [
+ checks.check_java,
+ checks.check_gradle,
+ checks.check_box_config,
+ checks.check_developer_token,
+ checks.check_jwt_config,
+]
+
+
+# ---- runner -------------------------------------------------------------
+
+
+def run_all_checks() -> DoctorReport:
+ """Call every registered check and assemble a ``DoctorReport``."""
+ report = DoctorReport()
+ for check in DOCTOR_CHECKS:
+ try:
+ results = check()
+ except Exception as exc: # noqa: BLE001 - keep the doctor robust
+ results = [
+ CheckResult(
+ name=check.__name__,
+ severity="fail",
+ message=f"check raised {type(exc).__name__}: {exc}",
+ fix_hint="report this as a doctor bug.",
+ )
+ ]
+ report.add(*results)
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Entry point. Prints banner, runs all checks, returns exit code."""
+ argv = list(argv if argv is not None else sys.argv[1:])
+
+ banner(DOCTOR_TITLE)
+ paced_print(DOCTOR_TAGLINE, after_ms=300)
+ print()
+
+ report = run_all_checks()
+ print_results(report.results, show_ok=True, show_fix=True)
+
+ print()
+ fails = sum(1 for r in report.results if r.severity == "fail")
+ warns = sum(1 for r in report.results if r.severity == "warn")
+ oks = sum(1 for r in report.results if r.severity == "ok")
+ paced_print(f" Summary: {oks} OK, {warns} WARN, {fails} FAIL", after_ms=150)
+
+ if fails > 0:
+ return 1
+ if DOCTOR_FAIL_ON_WARN and warns > 0:
+ return 2
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/setup/prompts.py b/setup/prompts.py
new file mode 100644
index 000000000..f92f9ff03
--- /dev/null
+++ b/setup/prompts.py
@@ -0,0 +1,393 @@
+"""TTY helpers for the wizard and doctor.
+
+Stdlib-only. Centralized here so the wizard and doctor render identically —
+the user gets the same ``[OK]`` / ``[WARN]`` / ``[FAIL]`` glyphs and the
+same fix-hint formatting from both tools.
+
+This is part of the ``setup-wizard-squared`` scaffold. Drop the file
+into a target repo's ``setup/`` package and customize ``wizard.py``;
+``prompts.py`` itself shouldn't usually need editing per repo.
+
+Tunables:
+ ``WIZARD_PACE_MS=`` — per-line pause when pacing framing text.
+ Default 80ms. ``0`` disables pacing. Auto-disabled when stdout
+ isn't a TTY.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+import sys
+import threading
+import time
+from collections.abc import Iterable, Iterator
+from typing import Literal
+
+# Import readline so input() supports arrow-key line editing and history
+# instead of inserting raw escape codes (^[[A / ^[[B). On macOS, Python's
+# stdlib readline links against libedit rather than GNU readline; on
+# Windows the module isn't shipped — input() still works, just without
+# the line editor.
+try:
+ import readline # noqa: F401 -- side-effect import (enables line editing)
+except ImportError:
+ pass
+
+from .checks import CheckResult
+
+Severity = Literal["ok", "warn", "fail"]
+
+_GLYPH = {"ok": "[OK] ", "warn": "[WARN]", "fail": "[FAIL]"}
+
+# ---- pacing -------------------------------------------------------------
+#
+# Sectional pacing: small inter-line pauses inside framing blocks
+# (banners, step intros, completion summaries) plus a longer settle
+# pause AFTER each block. Status rows, prompts, and command output stay
+# instant so the wizard never feels frozen.
+#
+# Tuning: WIZARD_PACE_MS= overrides the default per-line ms.
+# 0 -> pacing disabled (same as non-TTY).
+# 80 -> default; calm but not slow.
+# 200+ -> deliberate / dramatic for live demos or first-time users.
+# Auto-disabled when stdout isn't a TTY (CI, piped output, capture).
+
+_DEFAULT_PACE_MS = 80
+_PACE_ENV_VAR = "WIZARD_PACE_MS"
+
+
+def _pace_ms() -> int:
+ """Resolve effective per-line pause in ms; 0 disables pacing."""
+ if not sys.stdout.isatty():
+ return 0
+ raw = os.environ.get(_PACE_ENV_VAR)
+ if raw is None:
+ return _DEFAULT_PACE_MS
+ try:
+ v = int(raw)
+ except ValueError:
+ return _DEFAULT_PACE_MS
+ return max(0, v)
+
+
+def paced_print(*lines: str, pause_ms: int | None = None, after_ms: int = 0) -> None:
+ """Print each line with the per-line pause between, then sleep ``after_ms``.
+
+ Use for FRAMING text — banners, step intros, completion summaries.
+ Don't pace status rows, command output, or prompt text: pacing
+ those makes the wizard feel slow without aiding reading.
+
+ ``pause_ms`` overrides the global per-line rate FOR THIS CALL ONLY.
+ Use it to slow-roll a long prose block ("Star Wars crawl" feel) so
+ a multi-paragraph explainer scrolls in one breath instead of dumping
+ all at once. Typical values:
+
+ None -> use global WIZARD_PACE_MS (default 80ms, snappy)
+ 150 -> slower scroll for paragraph prose
+ 250 -> deliberate "read this carefully" rhythm
+
+ The global kill switch (``WIZARD_PACE_MS=0`` or non-TTY) still wins —
+ passing ``pause_ms`` does NOT force pacing on when it would otherwise
+ be off.
+
+ ``after_ms`` is the sectional settle pause that separates this block
+ from whatever prints next (a prompt, a status row, the next framing
+ block). Typical values:
+
+ 0 -> no settle (block flows directly into the next line)
+ 200 -> short settle (after a step completion line)
+ 400 -> banner settle (give the user a beat to read it)
+ """
+ pace = _pace_ms()
+ if pace and pause_ms is not None:
+ pace = max(0, pause_ms)
+ last = len(lines) - 1
+ for i, line in enumerate(lines):
+ print(line)
+ if pace and i < last:
+ time.sleep(pace / 1000)
+ if pace and after_ms > 0:
+ time.sleep(after_ms / 1000)
+
+
+def paced_pause(ms: int) -> None:
+ """Sleep ``ms`` milliseconds when pacing is enabled; no-op otherwise."""
+ pace = _pace_ms()
+ if pace and ms > 0:
+ time.sleep(ms / 1000)
+
+
+# ---- progress watchdog --------------------------------------------------
+#
+# Wrap subprocess calls (or any potentially-slow block) so the user gets
+# a "current action: " status line if the wrapped block goes silent
+# for more than a few seconds. Without this, long silent stretches during
+# `brew install`, `npm install -g`, dependency syncs, etc. look exactly
+# like a hung wizard.
+#
+# Don't wrap commands that have their own interactive prompts (e.g.
+# `gh auth login`'s device-flow prompts, CLIs that ask for tokens) —
+# the watcher line interleaves with the active prompt and confuses
+# the user. Use this only for silent or progress-streaming subprocesses.
+
+_PROGRESS_THRESHOLD_MS = 5000
+
+
+@contextlib.contextmanager
+def progress_watch(
+ description: str,
+ *,
+ threshold_ms: int = _PROGRESS_THRESHOLD_MS,
+) -> Iterator[None]:
+ """Print ``current action: `` if the block runs >threshold_ms.
+
+ Use as a context manager around subprocess calls that can stall::
+
+ with progress_watch("installing jira-cli (brew)"):
+ rc = _run_interactive(["brew", "install", "jira-cli"])
+
+ A daemon thread sleeps for ``threshold_ms``; if the block hasn't
+ exited by then it prints the heads-up line once. On exit, if the
+ threshold was crossed, also prints ``done in Ns`` so the user knows
+ the silent stretch ended.
+
+ No-op for non-TTY (CI / piped output / capture). Re-entrant: each
+ nested level announces independently with its own watcher thread.
+ """
+ if not sys.stdout.isatty():
+ yield
+ return
+
+ done = threading.Event()
+ crossed = threading.Event()
+
+ def _watch() -> None:
+ if not done.wait(threshold_ms / 1000):
+ crossed.set()
+ print(f" current action: {description}")
+
+ t = threading.Thread(target=_watch, daemon=True)
+ t.start()
+ start = time.monotonic()
+ try:
+ yield
+ finally:
+ done.set()
+ if crossed.is_set():
+ elapsed = time.monotonic() - start
+ print(f" → done in {elapsed:.0f}s")
+
+
+# ---- framing ------------------------------------------------------------
+
+
+def banner(title: str, *, after_ms: int = 400) -> None:
+ """Print a thick bordered title line with a settle pause after."""
+ bar = "=" * (len(title) + 4)
+ paced_print(bar, f"= {title} =", bar, after_ms=after_ms)
+
+
+def step(num: int, total: int, title: str) -> None:
+ """Print ``--- Step N/T: title ---`` with a leading blank + settle pause."""
+ print()
+ paced_print(f"--- Step {num}/{total}: {title} ---", after_ms=200)
+
+
+# ---- input prompts ------------------------------------------------------
+
+
+def yes_no(prompt: str, default: bool = True) -> bool:
+ """Prompt for yes/no. Empty input picks ``default``.
+
+ Treats EOF / Ctrl-D as "decline" so piping ``echo "" |`` to the
+ wizard doesn't accidentally agree to destructive actions.
+ """
+ suffix = "Y/n" if default else "y/N"
+ try:
+ raw = input(f"{prompt} ({suffix}): ").strip().lower()
+ except EOFError:
+ return False
+ if not raw:
+ return default
+ return raw in {"y", "yes"}
+
+
+def ask(label: str, *, hint: str | None = None, default: str | None = None) -> str:
+ """Single-line free-text prompt with optional hint and default."""
+ if hint:
+ print(f" ({hint})")
+ suffix = f" [{default}]" if default else ""
+ try:
+ raw = input(f" {label}{suffix}: ").strip()
+ except EOFError:
+ return default or ""
+ return raw or (default or "")
+
+
+def ask_secret(label: str, *, hint: str | None = None) -> str:
+ """Hidden-input prompt for a token / password / API key.
+
+ Wraps ``getpass.getpass`` so the value doesn't echo to the terminal
+ or land in shell history. EOF returns an empty string (caller should
+ detect and re-prompt or skip).
+ """
+ from getpass import getpass
+
+ if hint:
+ print(f" ({hint})")
+ try:
+ return getpass(f" {label} (input hidden): ").strip()
+ except EOFError:
+ return ""
+
+
+# ---- step picker --------------------------------------------------------
+#
+# Render a numbered "what would you like to set up?" menu and parse
+# the user's response into the set of step ids to run. Lets users
+# pick a subset up front instead of being walked through every
+# component sequentially.
+
+PickerItem = tuple[str, str, bool]
+"""A single menu item: ``(step_id, label, recommended)``.
+
+``step_id`` is the caller's stable identifier (returned in the selection
+list). ``label`` is what the user sees on the menu line.
+``recommended=True`` tags the item with ``[R]`` and includes it in the
+default selection when the user just hits Enter.
+"""
+
+
+def _parse_picker_input(
+ raw: str,
+ items: list[PickerItem],
+ default_ids: list[str],
+) -> tuple[list[str] | None, str | None]:
+ """Parse picker input into a list of selected step ids.
+
+ Returns ``(selected_ids, None)`` on success or ``(None, error_msg)``
+ on parse failure so the caller can re-prompt.
+
+ Accepts:
+ - empty string -> ``default_ids``
+ - ``"all"`` -> every item id
+ - ``"none"`` -> empty list (skip everything)
+ - ``"recommended"`` -> ids of items tagged recommended
+ - comma-separated 1-based numbers: ``"1,3,5"``
+ - inclusive ranges: ``"1-3"``
+ - mixed: ``"1-3,5,7"``
+
+ Selected ids are returned in menu order, not input order, so the
+ caller can iterate the items list once and run picked entries in
+ their canonical sequence.
+ """
+ raw = raw.strip().lower()
+ if not raw:
+ return list(default_ids), None
+ if raw == "all":
+ return [iid for iid, _, _ in items], None
+ if raw == "none":
+ return [], None
+ if raw == "recommended":
+ return [iid for iid, _, recommended in items if recommended], None
+
+ selected_indices: set[int] = set()
+ for token in raw.replace(" ", "").split(","):
+ if not token:
+ continue
+ if "-" in token:
+ try:
+ lo_s, hi_s = token.split("-", 1)
+ lo, hi = int(lo_s), int(hi_s)
+ except ValueError:
+ return None, f"bad range `{token}`"
+ if lo < 1 or hi > len(items) or lo > hi:
+ return None, f"range `{token}` out of bounds (1-{len(items)})"
+ selected_indices.update(range(lo, hi + 1))
+ else:
+ try:
+ n = int(token)
+ except ValueError:
+ return None, f"not a number: `{token}`"
+ if n < 1 or n > len(items):
+ return None, f"`{n}` out of range (1-{len(items)})"
+ selected_indices.add(n)
+
+ return [iid for i, (iid, _, _) in enumerate(items, 1) if i in selected_indices], None
+
+
+def pick_steps(
+ items: list[PickerItem],
+ *,
+ title: str = "What would you like to set up?",
+ indent: str = " ",
+) -> list[str]:
+ """Render a numbered picker; return selected step ids in menu order.
+
+ Each entry's ``recommended`` flag shows as a leading ``[R]`` tag and
+ determines the default selection when the user just hits Enter. The
+ caller iterates its own item list and runs any step whose id is in
+ the returned list — this helper doesn't invoke step bodies itself.
+
+ Empty selection (``none`` or no recommended items) is valid and
+ returns an empty list; callers should handle that gracefully (e.g.,
+ print "skipping all extras" and exit cleanly).
+
+ EOF / Ctrl-D returns the default selection so non-interactive
+ invocations (CI, ``echo "" | ...``) don't accidentally pick or skip
+ everything.
+ """
+ default_ids = [iid for iid, _, recommended in items if recommended]
+
+ paced_print(title, after_ms=150)
+ print()
+ for i, (_, label, recommended) in enumerate(items, 1):
+ tag = "[R]" if recommended else " "
+ print(f"{indent}{i}. {tag} {label}")
+ print()
+ print(f"{indent}Pick: comma-separated numbers (1,3,5), ranges (1-3),")
+ print(f"{indent} `all`, `recommended`, or `none`. [R] = recommended.")
+
+ while True:
+ try:
+ raw = input(f"{indent}Choice [recommended]: ")
+ except EOFError:
+ return list(default_ids)
+
+ selected, err = _parse_picker_input(raw, items, default_ids)
+ if selected is None:
+ print(f"{indent} invalid input: {err}. Try again.")
+ continue
+ return selected
+
+
+# ---- result rendering ---------------------------------------------------
+
+
+def print_results(
+ results: Iterable[CheckResult],
+ *,
+ show_ok: bool = True,
+ show_fix: bool = True,
+ indent: str = " ",
+) -> None:
+ """Render a sequence of CheckResult.
+
+ By default prints all severities so the doctor's full report is
+ visible; set ``show_ok=False`` in the wizard to suppress noise from
+ successful steps and only surface the warnings/failures.
+
+ Set ``show_fix=False`` at call sites where the wizard immediately
+ follows up with its own multi-line remediation block, so the user
+ doesn't see the same fix command twice (once as the terse one-line
+ ``fix:`` hint, then again in the prose block). The doctor leaves
+ this at the default so its standalone report is fully self-contained.
+ """
+ for r in results:
+ if r.severity == "ok" and not show_ok:
+ continue
+ glyph = _GLYPH[r.severity]
+ print(f"{indent}{glyph} {r.name}: {r.message}")
+ if r.fix_hint and r.severity != "ok" and show_fix:
+ print(f"{indent} fix: {r.fix_hint}")
diff --git a/setup/wizard.py b/setup/wizard.py
new file mode 100644
index 000000000..300850b49
--- /dev/null
+++ b/setup/wizard.py
@@ -0,0 +1,425 @@
+"""``setup-wizard`` — first-run / repair wizard for the Box Java SDK.
+
+Picker-driven, idempotent. Three steps:
+
+1. **Java + Gradle preflight** — verify the JDK and Gradle wrapper.
+2. **Auth credentials** — pick one of Developer Token / JWT / Client
+ Credentials / OAuth, then capture the credentials it needs and write
+ them to ``box-config.properties`` at the repo root.
+3. **Smoke test snippet** — print a 4-line Java program the user can run
+ to verify their setup talks to Box (``client.users.getUserMe()``).
+
+Re-running is safe. The wizard writes only ``box-config.properties``
+(gitignored) and never touches SDK source files.
+
+Run from the repo root::
+
+ ./scripts/setup
+ ./scripts/doctor # read-only audit afterwards
+
+Built using `setup-wizard-squared
+`_ — see that
+repo's ``reference/patterns.md`` for design rationale.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from collections.abc import Callable
+from pathlib import Path
+
+from . import checks
+from . import config as cfg
+from .prompts import (
+ ask,
+ ask_secret,
+ banner,
+ paced_print,
+ pick_steps,
+ print_results,
+ step,
+ yes_no,
+)
+
+WIZARD_TITLE: str = "Box Java SDK setup"
+WIZARD_TAGLINE: str = (
+ "Walks you through verifying Java + Gradle, picking an auth mode,\n"
+ "and capturing credentials so the SDK can talk to Box. Re-running\n"
+ "is always safe — every step skips work that's already done."
+)
+
+COMPLETION_FOOTER_LINES: list[str] = [
+ " Health check: ./scripts/doctor",
+ " Auth docs: docs/authentication.md",
+ " Wizard flows: WIZARD_FLOWS.md",
+ f" Mint a token: {cfg.DEV_CONSOLE_URL}",
+]
+
+
+# ---- subprocess runners -------------------------------------------------
+
+
+def _run_interactive(
+ args: list[str],
+ *,
+ timeout: int | None = None,
+ env: dict[str, str] | None = None,
+) -> int:
+ """Run a command with the user's TTY connected.
+
+ ``env`` overrides subprocess environment when provided. Build it by
+ copying ``os.environ`` and layering ``box-config.properties`` values
+ on top so child commands see ``BOX_*`` variables even if the user
+ never sourced the config in their shell.
+ """
+ print(f" $ {' '.join(args)}")
+ try:
+ return subprocess.run(args, check=False, timeout=timeout, env=env).returncode
+ except FileNotFoundError:
+ print(f" !! {args[0]} not found on PATH; skipping.")
+ return 127
+
+
+# ---- step 1: preflight --------------------------------------------------
+
+
+def step_preflight() -> bool:
+ """Verify Java >= 8 + Gradle wrapper resolves."""
+ step(1, len(WIZARD_STEPS), "Java + Gradle preflight")
+ print(" Verifies the JDK is installed and the Gradle wrapper works.")
+ print()
+ java = checks.check_java()
+ gradle = checks.check_gradle()
+ print_results(java + gradle, show_ok=True)
+ return all(r.severity != "fail" for r in java + gradle)
+
+
+# ---- step 2: auth credentials -------------------------------------------
+
+
+_AUTH_MODE_LABELS: dict[str, str] = {
+ cfg.AUTH_MODE_DEV_TOKEN: "Developer Token",
+ cfg.AUTH_MODE_JWT: "JWT",
+ cfg.AUTH_MODE_CCG: "Client Credentials Grant (CCG)",
+ cfg.AUTH_MODE_OAUTH: "OAuth 2.0",
+}
+
+
+def _pick_auth_mode() -> str | None:
+ """Inline mini-picker for auth mode. Returns mode string or None."""
+ paced_print(
+ " Which auth mode are you setting up?",
+ "",
+ " 1. Developer Token — fastest; 60-min TTL; testing only",
+ " 2. JWT — server-to-server; needs config.json from devconsole",
+ " 3. Client Credentials — server-to-server; client ID + secret",
+ " 4. OAuth 2.0 — end-user app; client ID + secret + redirect URI",
+ after_ms=200,
+ )
+ map_choice = {
+ "1": cfg.AUTH_MODE_DEV_TOKEN,
+ "2": cfg.AUTH_MODE_JWT,
+ "3": cfg.AUTH_MODE_CCG,
+ "4": cfg.AUTH_MODE_OAUTH,
+ }
+ while True:
+ raw = ask("Choice", default="1").strip()
+ if raw in map_choice:
+ return map_choice[raw]
+ if raw == "":
+ return None
+ print(f" Pick 1, 2, 3, or 4 (got {raw!r}). Try again, or empty to skip.")
+
+
+def _collect_developer_token() -> bool:
+ """Mode = developer_token: prompt for the token and persist."""
+ paced_print(
+ " Mint a Developer Token:",
+ f" {cfg.DEV_CONSOLE_URL}",
+ " → Open your app's configuration page → 'Generate Developer Token'",
+ " → copy the value (it's shown once).",
+ "",
+ " Tokens expire after 60 minutes; re-run this step for a fresh one.",
+ after_ms=250,
+ )
+ token = ask_secret("Developer Token", hint="not echoed; ~64-char alphanumeric")
+ if not token:
+ print(" Skipped — no token entered.")
+ return False
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.auth.mode", cfg.AUTH_MODE_DEV_TOKEN)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.auth.token", token)
+ paced_print(f" → wrote {cfg.BOX_CONFIG_FILE.name}", after_ms=150)
+ return True
+
+
+def _collect_jwt_config() -> bool:
+ """Mode = jwt: capture the JWT config JSON path."""
+ paced_print(
+ " JWT auth reads a JSON config the developer console exports.",
+ f" Setup guide: {cfg.JWT_AUTH_DOCS_URL}",
+ "",
+ " → In the developer console, open your JWT-enabled app →",
+ " Configuration → Add and Manage Public Keys → Generate Keypair.",
+ " The browser downloads config.json. Save it somewhere durable",
+ " (outside the repo if you can — it's a private key).",
+ after_ms=300,
+ )
+ default = str(cfg.DEFAULT_JWT_CONFIG_PATH)
+ raw = ask("Path to JWT config JSON", default=default)
+ p = Path(raw).expanduser()
+ if not p.exists():
+ print(f" [WARN] {p} doesn't exist yet. Saving the path anyway —")
+ print(" re-run ./scripts/doctor once the file's in place to validate.")
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.auth.mode", cfg.AUTH_MODE_JWT)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.jwt.config.path", str(p))
+ paced_print(f" → wrote {cfg.BOX_CONFIG_FILE.name}", after_ms=150)
+ return True
+
+
+def _collect_ccg_creds() -> bool:
+ """Mode = ccg: client ID + secret + (enterprise|user) ID."""
+ paced_print(
+ " Client Credentials Grant uses client ID + secret to mint tokens.",
+ f" Setup: {cfg.CCG_DOCS_URL}",
+ "",
+ " CCG can act as the Service Account (enterpriseID) OR a specific",
+ " user (userID), depending on what the app's authorized for.",
+ after_ms=250,
+ )
+ client_id = ask("Client ID", hint="from devconsole → Configuration").strip()
+ client_secret = ask_secret("Client Secret", hint="not echoed")
+ if not (client_id and client_secret):
+ print(" Skipped — client ID or secret empty.")
+ return False
+ subject_kind = ask(
+ "Subject", hint="`enterprise` for service account, `user` for user-based",
+ default="enterprise",
+ ).strip().lower()
+ if subject_kind not in {"enterprise", "user"}:
+ print(f" Unrecognized subject `{subject_kind}`; defaulting to enterprise.")
+ subject_kind = "enterprise"
+ subject_id = ask(
+ f"{subject_kind.capitalize()} ID",
+ hint="numeric (EID for enterprise, UID for user)",
+ ).strip()
+ if not subject_id:
+ print(" Skipped — subject ID empty.")
+ return False
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.auth.mode", cfg.AUTH_MODE_CCG)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.ccg.client.id", client_id)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.ccg.client.secret", client_secret)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.ccg.subject.kind", subject_kind)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.ccg.subject.id", subject_id)
+ paced_print(f" → wrote {cfg.BOX_CONFIG_FILE.name}", after_ms=150)
+ return True
+
+
+def _collect_oauth_creds() -> bool:
+ """Mode = oauth: client ID + secret + redirect URI."""
+ paced_print(
+ " OAuth 2.0 is for apps that ask end-users to grant Box access.",
+ f" Setup: {cfg.OAUTH_DOCS_URL}",
+ "",
+ " You'll need client ID, client secret, and a redirect URI",
+ " registered in the developer console.",
+ after_ms=250,
+ )
+ client_id = ask("Client ID", hint="from devconsole → Configuration").strip()
+ client_secret = ask_secret("Client Secret", hint="not echoed")
+ if not (client_id and client_secret):
+ print(" Skipped — client ID or secret empty.")
+ return False
+ redirect = ask(
+ "Redirect URI",
+ hint="must match what's registered in the devconsole",
+ default="http://localhost:8080/callback",
+ ).strip()
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.auth.mode", cfg.AUTH_MODE_OAUTH)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.oauth.client.id", client_id)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.oauth.client.secret", client_secret)
+ cfg.write_dotenv_key(cfg.BOX_CONFIG_FILE, "box.oauth.redirect.uri", redirect)
+ paced_print(f" → wrote {cfg.BOX_CONFIG_FILE.name}", after_ms=150)
+ return True
+
+
+def step_auth_credentials() -> bool:
+ """Pick an auth mode and capture credentials. Mode lives in box.auth.mode."""
+ step(2, len(WIZARD_STEPS), "Auth credentials")
+ print(" Picks an auth mode and captures the credentials it needs.")
+ print()
+ existing = cfg.read_dotenv(cfg.BOX_CONFIG_FILE) if cfg.BOX_CONFIG_FILE.exists() else {}
+ current_mode = existing.get("box.auth.mode", "").strip()
+ if current_mode:
+ label = _AUTH_MODE_LABELS.get(current_mode, current_mode)
+ paced_print(f" Existing auth mode: {label}", after_ms=150)
+ if not yes_no("Re-collect / change auth mode?", default=False):
+ return True
+ print()
+
+ mode = _pick_auth_mode()
+ if mode is None:
+ print(" Skipped.")
+ return True
+ print()
+
+ if mode == cfg.AUTH_MODE_DEV_TOKEN:
+ return _collect_developer_token()
+ if mode == cfg.AUTH_MODE_JWT:
+ return _collect_jwt_config()
+ if mode == cfg.AUTH_MODE_CCG:
+ return _collect_ccg_creds()
+ if mode == cfg.AUTH_MODE_OAUTH:
+ return _collect_oauth_creds()
+ return False
+
+
+# ---- step 3: smoke test snippet -----------------------------------------
+
+
+def step_smoke_test() -> bool:
+ """Print a copy-pasteable Java snippet that calls users.getUserMe()."""
+ step(3, len(WIZARD_STEPS), "Smoke test snippet")
+ print(" A 4-line Java program that authenticates and prints your name.")
+ print()
+
+ if not cfg.BOX_CONFIG_FILE.exists():
+ paced_print(
+ " [WARN] no box-config.properties yet — pick step 2 (Auth credentials)",
+ " first, then re-run setup and pick step 3.",
+ after_ms=200,
+ )
+ return True
+
+ values = cfg.read_dotenv(cfg.BOX_CONFIG_FILE)
+ mode = values.get("box.auth.mode", "").strip()
+ label = _AUTH_MODE_LABELS.get(mode, "(unknown)")
+ paced_print(
+ f" Auth mode in {cfg.BOX_CONFIG_FILE.name}: {label}",
+ "",
+ " Drop this into a Java file with the SDK on its classpath:",
+ "",
+ after_ms=150,
+ )
+
+ if mode == cfg.AUTH_MODE_DEV_TOKEN:
+ token = values.get("box.auth.token", "")
+ preview = token[:8] + "…" if len(token) > 8 else ""
+ print(' BoxDeveloperTokenAuth auth =')
+ print(f' new BoxDeveloperTokenAuth("{preview}");')
+ print(" BoxClient client = new BoxClient(auth);")
+ print(' System.out.println("Hello, " + client.users.getUserMe().getName());')
+ print()
+ print(f" (full token preview hidden; the real value is in {cfg.BOX_CONFIG_FILE.name})")
+ elif mode == cfg.AUTH_MODE_JWT:
+ path = values.get("box.jwt.config.path", "")
+ print(f' JWTConfig config = JWTConfig.fromConfigFile("{path}");')
+ print(" BoxJWTAuth auth = new BoxJWTAuth(config);")
+ print(" BoxClient client = new BoxClient(auth);")
+ print(' System.out.println("Hello, " + client.users.getUserMe().getName());')
+ elif mode == cfg.AUTH_MODE_CCG:
+ print(" CCGConfig config = new CCGConfig.CCGConfigBuilder(")
+ print(f' "{values.get("box.ccg.client.id", "")}",')
+ print(f' "{values.get("box.ccg.client.secret", "")[:8]}…")')
+ kind = values.get("box.ccg.subject.kind", "enterprise")
+ sid = values.get("box.ccg.subject.id", "")
+ if kind == "enterprise":
+ print(f' .enterpriseId("{sid}").build();')
+ else:
+ print(f' .userId("{sid}").build();')
+ print(" BoxCCGAuth auth = new BoxCCGAuth(config);")
+ print(" BoxClient client = new BoxClient(auth);")
+ print(' System.out.println("Hello, " + client.users.getUserMe().getName());')
+ elif mode == cfg.AUTH_MODE_OAUTH:
+ print(" OAuthConfig config = new OAuthConfig.OAuthConfigBuilder(")
+ print(f' "{values.get("box.oauth.client.id", "")}",')
+ print(f' "{values.get("box.oauth.client.secret", "")[:8]}…").build();')
+ print(" BoxOAuth auth = new BoxOAuth(config);")
+ print(" // Then run the OAuth flow — see docs/authentication.md.")
+ else:
+ print(" (see docs/authentication.md for the snippet matching your mode)")
+
+ print()
+ paced_print(
+ " Run it with the SDK on classpath. From this repo's root:",
+ " ./gradlew --console=plain run (if you've set up a Smoke main class)",
+ " Or place the snippet in any project that depends on com.box:box-java-sdk:10.+.",
+ "",
+ " Note: this wizard does not modify build.gradle. Adding a 'smoke'",
+ " task is a maintainer decision; the snippet above keeps the wizard",
+ " scope-bounded.",
+ after_ms=200,
+ )
+ return True
+
+
+# ---- step registry ------------------------------------------------------
+
+WizardStepFn = Callable[[], bool]
+WIZARD_STEPS: list[tuple[str, str, bool, WizardStepFn]] = [
+ ("preflight", "Java + Gradle preflight", True, step_preflight),
+ ("auth", "Auth credentials (Developer Token / JWT / CCG / OAuth)", True, step_auth_credentials),
+ ("smoke", "Smoke test snippet", True, step_smoke_test),
+]
+
+
+# ---- runner -------------------------------------------------------------
+
+
+def run_steps(selected_ids: list[str]) -> int:
+ """Run the picked steps in canonical order."""
+ if not selected_ids:
+ paced_print(" Nothing selected. Exiting.", after_ms=200)
+ return 0
+
+ paced_print(
+ f" → Will set up: {', '.join(sid for sid in selected_ids)}",
+ after_ms=200,
+ )
+
+ failures: list[str] = []
+ for sid, _label, _recommended, fn in WIZARD_STEPS:
+ if sid not in selected_ids:
+ continue
+ try:
+ ok = fn()
+ except KeyboardInterrupt:
+ print("\n Interrupted by user.")
+ return 130
+ except Exception as exc: # noqa: BLE001 - wizard must stay friendly
+ print(f" [FAIL] step `{sid}` raised {type(exc).__name__}: {exc}")
+ failures.append(sid)
+ continue
+ if not ok:
+ failures.append(sid)
+
+ print()
+ banner(f"{WIZARD_TITLE} complete")
+ for line in COMPLETION_FOOTER_LINES:
+ paced_print(line, after_ms=80)
+
+ if failures:
+ print()
+ print(f" {len(failures)} step(s) reported issues: {', '.join(failures)}")
+ print(" Re-run `./scripts/setup` and pick those steps to retry.")
+ return 1
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Entry point. Render banner + picker, run selected steps, print footer."""
+ argv = list(argv if argv is not None else sys.argv[1:])
+
+ banner(WIZARD_TITLE)
+ paced_print(*WIZARD_TAGLINE.splitlines(), after_ms=300)
+ print()
+
+ picker_items = [(sid, label, recommended) for sid, label, recommended, _ in WIZARD_STEPS]
+ selected = pick_steps(picker_items)
+ print()
+
+ return run_steps(selected)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())