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())