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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 - <summary> OR ## vX.Y.Z — <summary>
# 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."
52 changes: 1 addition & 51 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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 `<api-url>/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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/wakemypc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions src/wakemypc/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<random-free-port>.
2. We open the user's browser at <api_url>/dashboard/cli-auth?... passing
2. We open the user's browser at <api_url>/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"),
Expand Down Expand Up @@ -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='')}"
)
Expand Down Expand Up @@ -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").
Expand Down
Loading