diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c0ca1d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,145 @@ +# CLI CI -- validation that runs on every PR (and as a safety net on push +# to main). Anything we'd want to catch BEFORE merge lives here; the +# release + publish workflows only do release/publish work. +# +# Checks: +# 1. Required files exist (RELEASE_NOTES.md, pyproject.toml, src/...). +# 2. pyproject.toml parses (TOML). +# 3. RELEASE_NOTES.md first heading is "## vX.Y.Z - ...", parseable. +# 4. RELEASE_NOTES.md version matches pyproject.toml `project.version`. +# (release.yml asserts the same thing, but doing it here means a +# mismatched PR fails before merge -- not after, when someone has +# to revert.) +# 5. Every src/wakemypc/*.py compiles (python syntax check). +# 6. Package builds cleanly (`python -m build`). +# 7. The built wheel installs and `wakemypc --help` runs (smoke test +# that the click entry point actually works after packaging). + +name: CLI CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +# CI runs are read-only; keep the token scoped accordingly. +permissions: + contents: read + +# A new push to a PR cancels the previous CI run for that PR. Push events +# to main don't cancel each other (different group key). +concurrency: + group: cli-ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Required files exist + run: | + set -euo pipefail + for f in RELEASE_NOTES.md pyproject.toml src/wakemypc/__init__.py src/wakemypc/main.py; do + if [ ! -e "$f" ]; then + echo "::error file=$f::missing required file" + exit 1 + fi + done + + - name: Parse pyproject.toml version + id: pyproject_version + run: | + set -euo pipefail + version=$(python -c " + import tomllib + with open('pyproject.toml', 'rb') as f: + print(tomllib.load(f)['project']['version']) + ") + echo "pyproject.toml says: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Parse release-notes version + id: notes_version + # Same first-line shape that release.yml expects: + # ## vX.Y.Z - OR ## vX.Y.Z — + # The version is the first capture group; we drop the leading "v". + run: | + set -euo pipefail + first_line=$(head -n 1 RELEASE_NOTES.md | tr -d '\r') + echo "First line: $first_line" + if [[ "$first_line" =~ ^##[[:space:]]+v([0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?) ]]; then + version="${BASH_REMATCH[1]}" + else + echo "::error file=RELEASE_NOTES.md::first line must look like '## vX.Y.Z ...'" + exit 1 + fi + echo "RELEASE_NOTES.md says: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Versions agree + # release.yml hard-fails on this anyway, but doing it in CI means + # the PR shows a red check instead of a successful merge that + # then fails the release workflow on main. + run: | + set -euo pipefail + notes="${{ steps.notes_version.outputs.version }}" + pkg="${{ steps.pyproject_version.outputs.version }}" + if [ "$notes" != "$pkg" ]; then + echo "::error::RELEASE_NOTES.md (v$notes) does not match pyproject.toml (v$pkg). Bump one of them so they agree." + exit 1 + fi + echo "Both report v$notes -- OK." + + - name: Python syntax-compile every src/wakemypc/*.py + run: | + set -euo pipefail + fail=0 + for f in src/wakemypc/*.py; do + [ -f "$f" ] || continue + if ! python -m py_compile "$f"; then + echo "::error file=$f::syntax error" + fail=1 + fi + done + [ "$fail" -eq 0 ] + + - name: Build wheel + sdist + # Same command release.yml runs. If `python -m build` fails here, + # release.yml would fail on main too -- catching it pre-merge is + # the whole point of having a CI workflow. + run: | + set -euo pipefail + python -m pip install --upgrade pip build + python -m build + ls -lh dist/ + + - name: Install built wheel and smoke-test entry point + # Catches packaging mistakes that compile but produce a broken + # wheel: missing `[project.scripts]`, mis-spelled module path, + # forgotten dependency, etc. `wakemypc --help` will exit non-zero + # if click can't load the cli group, so this is a tight check. + run: | + set -euo pipefail + wheel=$(ls dist/*.whl | head -n 1) + if [ -z "$wheel" ]; then + echo "::error::no wheel produced in dist/" + exit 1 + fi + python -m pip install --quiet "$wheel" + wakemypc --version + wakemypc --help > /dev/null + # The register command is the one that grew most recently + # (--oauth flow); make sure click can introspect it cleanly. + wakemypc register --help > /dev/null + echo "Smoke test passed." diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7fb3fe8..05dcbeb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,51 +1 @@ -## v1.2.1 — Browser sign-in for `register` (supports "Sign in with Google") - -Supersedes the released v1.2.0 token-in-URL design with a loopback -authorization-code exchange (modelled on the OAuth 2.0 native-app pattern in -RFC 8252; not a conformant OAuth 2.0 implementation — no registered client, -PKCE, or scopes). - -### New - -- **`wakemypc register --oauth`** — sign in via your browser instead of - passing `--username` / `--password`. The CLI opens `/dashboard/cli-auth`, - the website hands back a short-lived single-use **authorization code** on a - `127.0.0.1` loopback callback, and the CLI exchanges that code for a JWT - via `POST /api/jwtauth/cli-exchange/`. Because the website handles the - actual sign-in, any method the dashboard supports works — including - **Sign in with Google**. The JWT itself never appears in any URL. -- **`--no-browser`** — pair with `--oauth` on headless / SSH sessions; the - CLI prints the sign-in URL for you to paste into a browser elsewhere. -- **`--oauth-timeout`** (default `300s`) — how long to wait for you to finish - signing in before giving up. - -### Examples - -```bash -# Fresh registration via the browser -wakemypc register --api-url https://wakemypc.com --oauth - -# Rotate an existing Pico's token via the browser -wakemypc register --api-url https://wakemypc.com --oauth --rotate - -# Headless box: print the URL instead of launching a browser -wakemypc register --api-url https://wakemypc.com --oauth --no-browser -``` - -### Security notes - -- The loopback server binds to `127.0.0.1` only. -- The redirect URL carries a 5-minute single-use authorization code — never - the JWT. The token is delivered to the CLI in a backend POST response body - that is not stored in browser history. -- A CSRF `state` nonce is round-tripped through the browser and validated - before the code is exchanged. -- `--oauth` and `--token` are mutually exclusive (`--token` skips the server; - `--oauth` exists to talk to it). Username/password registration is - unchanged. - -### Server requirements - -This release expects the server to expose `/api/jwtauth/cli-issue-code/` and -`/api/jwtauth/cli-exchange/`, plus the `/dashboard/cli-auth` page. Against -older servers, fall back to `--username` / `--password`. +## v1.2.2 — Fix OAuth workflow API links diff --git a/pyproject.toml b/pyproject.toml index 8d61f9d..b1ddd4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "wakemypc" -version = "1.2.1" -description = "Companion CLI for wakemypc.com -- flash, provision, and manage Pico W transmitters from your terminal" +version = "1.2.2" +description = "Companion CLI for https://wakemypc.com (WakeMyPC) -- flash, provision, and manage Pico W transmitters from your terminal. WakeMyPC is a website that lets you turn on you PC remotely from anywhere in the world using Wake-on-Lan." readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" diff --git a/src/wakemypc/main.py b/src/wakemypc/main.py index 7d93ede..fa7849a 100644 --- a/src/wakemypc/main.py +++ b/src/wakemypc/main.py @@ -633,8 +633,8 @@ def provision(server_url, wifi_ssid, wifi_pass, port, add_new_wifi, clear_wifi, @cli.command() @click.option( "--api-url", - default=None, - help="Base URL of your Django server (e.g. https://example.com). Required unless --token is given.", + default="https://wakemypc.com", + help="Base URL of WakeMyPC Server (e.g. https://wakemypc.com). Required unless --token is given.", ) @click.option( "--username", diff --git a/src/wakemypc/register.py b/src/wakemypc/register.py index 510274f..a55b156 100644 --- a/src/wakemypc/register.py +++ b/src/wakemypc/register.py @@ -63,7 +63,7 @@ def oauth_login_via_browser(api_url, timeout=300, open_browser=True): HOW IT WORKS (loopback authorization-code exchange, modelled on RFC 8252) -------------------------------------------------------------------------- 1. We bind a one-shot HTTP server on http://127.0.0.1:. - 2. We open the user's browser at /dashboard/cli-auth?... passing + 2. We open the user's browser at /cli-auth?... passing our loopback URL as redirect_uri and a fresh CSRF state token. 3. The browser-side React page (frontend/src/pages/CliAuth.jsx) handles the actual sign-in flow (username/password OR "Sign in with Google"), @@ -149,7 +149,7 @@ def do_GET(self): redirect_uri = f"http://127.0.0.1:{port}/callback" auth_url = ( - f"{api_url}/dashboard/cli-auth?" + f"{api_url}/cli-auth?" f"redirect_uri={urllib.parse.quote(redirect_uri, safe='')}" f"&state={urllib.parse.quote(state, safe='')}" ) @@ -500,7 +500,7 @@ def register_and_provision( together the entire registration process: 1. Authenticate with the server. Either: - - use_oauth=True -> open the website's /dashboard/cli-auth page + - use_oauth=True -> open the website's /cli-auth page in a browser, capture the JWT via a local loopback callback (supports password OR "Sign in with Google").