studylife-cli 1.8.2__tar.gz → 1.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. studylife_cli-1.9.0/.github/ISSUE_TEMPLATE/bug_report.yml +24 -0
  2. studylife_cli-1.9.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
  3. studylife_cli-1.9.0/.github/ISSUE_TEMPLATE/feature_request.yml +16 -0
  4. studylife_cli-1.9.0/.github/PULL_REQUEST_TEMPLATE.md +9 -0
  5. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/.github/workflows/ci.yml +55 -9
  6. studylife_cli-1.9.0/.github/workflows/dependabot-auto-merge.yml +33 -0
  7. studylife_cli-1.9.0/.github/workflows/scorecard.yml +33 -0
  8. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/CHANGELOG.md +14 -0
  9. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/PKG-INFO +1 -1
  10. studylife_cli-1.9.0/SECURITY.md +13 -0
  11. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/_version.py +2 -2
  12. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/login.py +27 -6
  13. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/tests/test_login.py +12 -1
  14. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/.github/dependabot.yml +0 -0
  15. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/.gitignore +0 -0
  16. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/.releaserc.json +0 -0
  17. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/LICENSE +0 -0
  18. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/README.md +0 -0
  19. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/pyproject.toml +0 -0
  20. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/__init__.py +0 -0
  21. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/cli.py +0 -0
  22. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/client.py +0 -0
  23. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/credentials.py +0 -0
  24. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/models.py +0 -0
  25. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/src/studylife_cli/tui.py +0 -0
  26. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/tests/test_cli.py +0 -0
  27. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/tests/test_client.py +0 -0
  28. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/tests/test_tui.py +0 -0
  29. {studylife_cli-1.8.2 → studylife_cli-1.9.0}/uv.lock +0 -0
@@ -0,0 +1,24 @@
1
+ name: Bug report
2
+ description: Something does not work as documented
3
+ labels: [bug]
4
+ body:
5
+ - type: textarea
6
+ id: what
7
+ attributes:
8
+ label: What happened?
9
+ description: What did you do, what did you expect, what happened instead?
10
+ validations:
11
+ required: true
12
+ - type: input
13
+ id: version
14
+ attributes:
15
+ label: Version
16
+ description: Release or tag you are running (see the Releases page).
17
+ validations:
18
+ required: true
19
+ - type: textarea
20
+ id: logs
21
+ attributes:
22
+ label: Logs or screenshots
23
+ description: Relevant log lines or a screenshot. Please remove tokens and personal data.
24
+ render: text
@@ -0,0 +1,5 @@
1
+ blank_issues_enabled: true
2
+ contact_links:
3
+ - name: Security vulnerability
4
+ url: https://github.com/lukislp/studylife-cli/security/advisories/new
5
+ about: Please report security problems privately here, not as a public issue.
@@ -0,0 +1,16 @@
1
+ name: Feature request
2
+ description: Suggest an improvement
3
+ labels: [enhancement]
4
+ body:
5
+ - type: textarea
6
+ id: problem
7
+ attributes:
8
+ label: What problem would this solve?
9
+ validations:
10
+ required: true
11
+ - type: textarea
12
+ id: proposal
13
+ attributes:
14
+ label: Proposed solution
15
+ validations:
16
+ required: true
@@ -0,0 +1,9 @@
1
+ ## What and why
2
+
3
+ <!-- One or two sentences: what changes, and why. Link the issue if there is one. -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] Conventional Commit title (`fix:`, `feat:`, `ci:`, `docs:`, ...) - it decides whether a release is cut
8
+ - [ ] Tests or a manual verification note for behaviour changes
9
+ - [ ] No secrets, tokens or personal data in code, tests or logs
@@ -14,8 +14,8 @@ jobs:
14
14
  lint:
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
- - uses: actions/checkout@v7.0.1
18
- - uses: astral-sh/setup-uv@v10.0.1
17
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
19
19
  with:
20
20
  version: "0.5.8"
21
21
  cache-suffix: "lint"
@@ -27,8 +27,8 @@ jobs:
27
27
  test:
28
28
  runs-on: ubuntu-latest
29
29
  steps:
30
- - uses: actions/checkout@v7.0.1
31
- - uses: astral-sh/setup-uv@v10.0.1
30
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
31
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
32
32
  with:
33
33
  version: "0.5.8"
34
34
  cache-suffix: "test"
@@ -50,9 +50,32 @@ jobs:
50
50
  outputs:
51
51
  published: ${{ steps.semrel.outputs.new_release_published }}
52
52
  steps:
53
- - uses: actions/checkout@v7.0.1
53
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54
54
  with:
55
55
  fetch-depth: 0
56
+ # Ruleset bypass for the release commit (2026-09-11 main-protection rollout): main is
57
+ # protected by a ruleset (pull request + green test stage) that the GitHub Actions app
58
+ # cannot be a bypass actor for on a personal repository, so semantic-release pushes over
59
+ # SSH as a write-enabled DEPLOY KEY instead, which can. No-op while the secret is unset -
60
+ # the push then goes through the GITHUB_TOKEN https remote exactly as before. Needed in
61
+ # get-version too: @semantic-release/git's verifyConditions runs `git push --dry-run`
62
+ # even in dry-run mode. Same mechanism as lukislp/studylife's ci-cd.yml.
63
+ - name: Push as the semantic-release deploy key (if configured)
64
+ env:
65
+ DEPLOY_KEY: ${{ secrets.SEMANTIC_RELEASE_DEPLOY_KEY }}
66
+ run: |
67
+ if [ -z "$DEPLOY_KEY" ]; then
68
+ echo "SEMANTIC_RELEASE_DEPLOY_KEY is not set - semantic-release pushes with GITHUB_TOKEN"
69
+ exit 0
70
+ fi
71
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
72
+ printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/semantic_release
73
+ chmod 600 ~/.ssh/semantic_release
74
+ # github.com's published ed25519 host key, pinned instead of ssh-keyscan'd at runtime:
75
+ # https://docs.github.com/en/authentication/keychain/githubs-ssh-key-fingerprints
76
+ echo "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" >> ~/.ssh/known_hosts
77
+ git remote set-url origin "git@github.com:${{ github.repository }}.git"
78
+ echo "GIT_SSH_COMMAND=ssh -i ~/.ssh/semantic_release -o IdentitiesOnly=yes" >> "$GITHUB_ENV"
56
79
  - name: Sync to the current tip of main
57
80
  run: |
58
81
  git fetch origin main
@@ -80,9 +103,32 @@ jobs:
80
103
  issues: write
81
104
  pull-requests: write
82
105
  steps:
83
- - uses: actions/checkout@v7.0.1
106
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
84
107
  with:
85
108
  fetch-depth: 0
109
+ # Ruleset bypass for the release commit (2026-09-11 main-protection rollout): main is
110
+ # protected by a ruleset (pull request + green test stage) that the GitHub Actions app
111
+ # cannot be a bypass actor for on a personal repository, so semantic-release pushes over
112
+ # SSH as a write-enabled DEPLOY KEY instead, which can. No-op while the secret is unset -
113
+ # the push then goes through the GITHUB_TOKEN https remote exactly as before. Needed in
114
+ # get-version too: @semantic-release/git's verifyConditions runs `git push --dry-run`
115
+ # even in dry-run mode. Same mechanism as lukislp/studylife's ci-cd.yml.
116
+ - name: Push as the semantic-release deploy key (if configured)
117
+ env:
118
+ DEPLOY_KEY: ${{ secrets.SEMANTIC_RELEASE_DEPLOY_KEY }}
119
+ run: |
120
+ if [ -z "$DEPLOY_KEY" ]; then
121
+ echo "SEMANTIC_RELEASE_DEPLOY_KEY is not set - semantic-release pushes with GITHUB_TOKEN"
122
+ exit 0
123
+ fi
124
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
125
+ printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/semantic_release
126
+ chmod 600 ~/.ssh/semantic_release
127
+ # github.com's published ed25519 host key, pinned instead of ssh-keyscan'd at runtime:
128
+ # https://docs.github.com/en/authentication/keychain/githubs-ssh-key-fingerprints
129
+ echo "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" >> ~/.ssh/known_hosts
130
+ git remote set-url origin "git@github.com:${{ github.repository }}.git"
131
+ echo "GIT_SSH_COMMAND=ssh -i ~/.ssh/semantic_release -o IdentitiesOnly=yes" >> "$GITHUB_ENV"
86
132
  - id: semrel
87
133
  uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0
88
134
  with:
@@ -124,14 +170,14 @@ jobs:
124
170
  permissions:
125
171
  id-token: write # trusted publishing (OIDC) - no PyPI token/secret needed
126
172
  steps:
127
- - uses: actions/checkout@v7.0.1
173
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
128
174
  with:
129
175
  ref: main
130
176
  fetch-depth: 0 # full history + tags, so hatch-vcs can find the tag just created
131
- - uses: astral-sh/setup-uv@v10.0.1
177
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
132
178
  with:
133
179
  version: "0.5.8"
134
180
  cache-suffix: "publish"
135
181
  - run: uv python install 3.12
136
182
  - run: uv build
137
- - uses: pypa/gh-action-pypi-publish@release/v1
183
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
@@ -0,0 +1,33 @@
1
+ name: Dependabot auto-merge
2
+
3
+ # Merges Dependabot patch and minor updates once every required check is green; majors stay
4
+ # manual. Runs on pull_request_target so the base branch's workflow definition and the repo
5
+ # secrets apply (a Dependabot-triggered pull_request run gets neither). Merges with
6
+ # AUTOMERGE_TOKEN, a fine-grained PAT (contents + pull requests: write): a merge performed with
7
+ # GITHUB_TOKEN would never start the release workflow, because GitHub does not run workflows for
8
+ # events caused by GITHUB_TOKEN. Deliberately no checkout - PR code is never executed here.
9
+ on:
10
+ pull_request_target:
11
+ types: [opened, synchronize, reopened]
12
+
13
+ permissions:
14
+ contents: write
15
+ pull-requests: write
16
+
17
+ jobs:
18
+ auto-merge:
19
+ if: github.actor == 'dependabot[bot]'
20
+ runs-on: ubuntu-latest
21
+ env:
22
+ HAS_TOKEN: ${{ secrets.AUTOMERGE_TOKEN != '' }}
23
+ steps:
24
+ - id: meta
25
+ uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
26
+ with:
27
+ github-token: ${{ secrets.GITHUB_TOKEN }}
28
+ - if: env.HAS_TOKEN == 'true' && (steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor')
29
+ env:
30
+ GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }}
31
+ run: gh pr merge --auto --merge "${{ github.event.pull_request.html_url }}"
32
+ - if: env.HAS_TOKEN != 'true'
33
+ run: echo "AUTOMERGE_TOKEN is not set - leaving this PR for a manual merge"
@@ -0,0 +1,33 @@
1
+ name: OpenSSF Scorecard
2
+
3
+ # Weekly supply-chain posture check (branch protection, pinned dependencies, token permissions,
4
+ # vulnerabilities, ...). Results land in the Security tab as code-scanning alerts and on
5
+ # https://securityscorecards.dev/viewer/?uri=github.com/lukislp/studylife-cli. Read-only, non-blocking.
6
+ on:
7
+ branch_protection_rule:
8
+ schedule:
9
+ - cron: "17 4 * * 1"
10
+ push:
11
+ branches: [main]
12
+ workflow_dispatch:
13
+
14
+ permissions: read-all
15
+
16
+ jobs:
17
+ analysis:
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ security-events: write
21
+ id-token: write
22
+ steps:
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
+ with:
25
+ persist-credentials: false
26
+ - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
27
+ with:
28
+ results_file: results.sarif
29
+ results_format: sarif
30
+ publish_results: true
31
+ - uses: github/codeql-action/upload-sarif@faaca9a8f6edddba5725ffe5adefdab6669a2eca # v3.38.0
32
+ with:
33
+ sarif_file: results.sarif
@@ -1,3 +1,17 @@
1
+ # [1.9.0](https://github.com/lukislp/studylife-cli/compare/v1.8.3...v1.9.0) (2026-09-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **login:** PKCE for the StudyLife connect round trip ([de702d8](https://github.com/lukislp/studylife-cli/commit/de702d8b4ecff987cc9e6901f1a30a93435a8629))
7
+
8
+ ## [1.8.3](https://github.com/lukislp/studylife-cli/compare/v1.8.2...v1.8.3) (2026-09-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **ci:** push release commits as a deploy key so the default branch can be ruleset-protected ([deb7440](https://github.com/lukislp/studylife-cli/commit/deb7440b07cd58380deda44711e0bce342968c42))
14
+
1
15
  ## [1.8.2](https://github.com/lukislp/studylife-cli/compare/v1.8.1...v1.8.2) (2026-09-04)
2
16
 
3
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: studylife-cli
3
- Version: 1.8.2
3
+ Version: 1.9.0
4
4
  Summary: Command-line client for StudyLife (self-hosted study organizer) - notes, sessions, course goals, and webhooks from your terminal.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -0,0 +1,13 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report vulnerabilities privately through GitHub's private vulnerability reporting:
6
+ https://github.com/lukislp/studylife-cli/security/advisories/new
7
+
8
+ Do not open a public issue for security problems. You will get an acknowledgement within a few
9
+ days and a fix or mitigation plan before any public disclosure.
10
+
11
+ ## Supported versions
12
+
13
+ Only the latest release receives security fixes.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.8.2'
22
- __version_tuple__ = version_tuple = (1, 8, 2)
21
+ __version__ = version = '1.9.0'
22
+ __version_tuple__ = version_tuple = (1, 9, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -16,6 +16,8 @@ identity-contract-v1 §2 round trip this generalizes), with two real differences
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import base64
20
+ import hashlib
19
21
  import hmac
20
22
  import secrets
21
23
  import sys
@@ -164,13 +166,22 @@ def run_login(
164
166
  LoginError on any failure - callers decide how to present that (CLI prints and exits 1)."""
165
167
  base_url = instance_url.rstrip("/")
166
168
  state_token = secrets.token_urlsafe(32)
169
+ # PKCE (RFC 7636): the assertion comes back through a browser redirect, and StudyLife only
170
+ # redeems it together with the verifier matching this challenge - so a copy of the redirect
171
+ # URL alone (browser history, a proxy log) is worthless to anyone but this process.
172
+ code_verifier, code_challenge = _new_pkce_pair()
167
173
 
168
174
  callback_server = _bind_first_free_port(candidate_ports)
169
175
  redirect_uri = f"http://127.0.0.1:{callback_server.port}/callback"
170
- connect_url = (
171
- f"{base_url}/connect/client/{client_id}?"
172
- f"{urlencode({'redirect_uri': redirect_uri, 'state': state_token})}"
176
+ connect_query = urlencode(
177
+ {
178
+ "redirect_uri": redirect_uri,
179
+ "state": state_token,
180
+ "code_challenge": code_challenge,
181
+ "code_challenge_method": "S256",
182
+ }
173
183
  )
184
+ connect_url = f"{base_url}/connect/client/{client_id}?{connect_query}"
174
185
 
175
186
  print(f"Opening your browser to log in to StudyLife:\n {connect_url}")
176
187
  print("Waiting for you to finish logging in and approving the connection...")
@@ -197,12 +208,22 @@ def run_login(
197
208
  "been denied."
198
209
  )
199
210
 
200
- user_id, api_key = _exchange_assertion(base_url, client_id, result.assertion)
211
+ user_id, api_key = _exchange_assertion(base_url, client_id, result.assertion, code_verifier)
201
212
  del user_id # not needed locally - kept for symmetry with the server's response shape
202
213
  return Credentials(instance_url=base_url, client_id=client_id, api_key=api_key)
203
214
 
204
215
 
205
- def _exchange_assertion(base_url: str, client_id: str, assertion: str) -> tuple[int, str]:
216
+ def _new_pkce_pair() -> tuple[str, str]:
217
+ """(code_verifier, code_challenge): 43 unreserved characters and the base64url SHA-256 of
218
+ them without padding, exactly the S256 shape StudyLife's connect endpoint validates."""
219
+ verifier = secrets.token_urlsafe(32)
220
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
221
+ return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
222
+
223
+
224
+ def _exchange_assertion(
225
+ base_url: str, client_id: str, assertion: str, code_verifier: str
226
+ ) -> tuple[int, str]:
206
227
  """Server-to-server exchange of the single-use assertion for the user id and a freshly
207
228
  issued, per-installation API key (generic flow - AuthController.10.OAuthClients.cs). No
208
229
  X-Api-Key is sent: this endpoint is [AllowAnonymous] by design, the assertion itself is the
@@ -210,7 +231,7 @@ def _exchange_assertion(base_url: str, client_id: str, assertion: str) -> tuple[
210
231
  try:
211
232
  response = httpx.post(
212
233
  f"{base_url}/api/auth/assertion-exchange",
213
- json={"clientId": client_id, "assertion": assertion},
234
+ json={"clientId": client_id, "assertion": assertion, "codeVerifier": code_verifier},
214
235
  timeout=10.0,
215
236
  )
216
237
  except httpx.HTTPError as exc:
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import hashlib
3
5
  import threading
4
6
  from urllib.parse import parse_qs, urlencode, urlparse
5
7
  from urllib.request import urlopen
@@ -83,7 +85,16 @@ def test_run_login_completes_full_round_trip(monkeypatch: pytest.MonkeyPatch) ->
83
85
  return True
84
86
 
85
87
  def fake_post(url: str, json: dict[str, object], timeout: float) -> httpx.Response:
86
- assert json == {"clientId": "studylife-cli", "assertion": "the-assertion"}
88
+ assert url == "https://studylife.example.com/api/auth/assertion-exchange"
89
+ assert json["clientId"] == "studylife-cli"
90
+ assert json["assertion"] == "the-assertion"
91
+ # PKCE: the verifier sent here must hash to the challenge the browser was sent with.
92
+ query = parse_qs(urlparse(opened_urls[0]).query)
93
+ assert query["code_challenge_method"] == ["S256"]
94
+ digest = hashlib.sha256(str(json["codeVerifier"]).encode("ascii")).digest()
95
+ assert query["code_challenge"] == [
96
+ base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
97
+ ]
87
98
  return httpx.Response(200, json={"userId": 1, "apiKey": "the-api-key"})
88
99
 
89
100
  monkeypatch.setattr("studylife_cli.login.httpx.post", fake_post)
File without changes
File without changes
File without changes
File without changes