stml-cli 0.1.8__tar.gz → 0.1.9__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.
- {stml_cli-0.1.8 → stml_cli-0.1.9}/PKG-INFO +7 -1
- {stml_cli-0.1.8 → stml_cli-0.1.9}/README.md +6 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/__init__.py +1 -1
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/__main__.py +10 -2
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/commands.py +11 -1
- stml_cli-0.1.9/stml_cli/config.py +110 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/http.py +4 -12
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/packaging.py +9 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/paths.py +9 -0
- stml_cli-0.1.9/stml_cli/update_check.py +115 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/PKG-INFO +7 -1
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/SOURCES.txt +3 -0
- stml_cli-0.1.9/tests/test_sessions.py +76 -0
- stml_cli-0.1.9/tests/test_update_check.py +95 -0
- stml_cli-0.1.8/stml_cli/config.py +0 -64
- {stml_cli-0.1.8 → stml_cli-0.1.9}/pyproject.toml +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/setup.cfg +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/oauth.py +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli/refs.py +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/dependency_links.txt +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/entry_points.txt +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/requires.txt +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/stml_cli.egg-info/top_level.txt +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_disambiguation.py +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_init.py +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_paths.py +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_refs.py +0 -0
- {stml_cli-0.1.8 → stml_cli-0.1.9}/tests/test_whoami_expiry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stml-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: The stml partner CLI — pull, edit, push, and publish stml apps and libraries.
|
|
5
5
|
Author: stml
|
|
6
6
|
Project-URL: Homepage, https://stml.io
|
|
@@ -35,6 +35,12 @@ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-s
|
|
|
35
35
|
Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
|
|
36
36
|
platform-specific beyond the filesystem and HTTPS.
|
|
37
37
|
|
|
38
|
+
**Staying current**: after a command finishes, the CLI prints a one-line
|
|
39
|
+
notice when a newer release exists on PyPI (checked at most once a day,
|
|
40
|
+
cached, quiet when stderr isn't a terminal, never blocks or fails a command).
|
|
41
|
+
Update with `pipx upgrade stml-cli`. Set `STML4_NO_UPDATE_CHECK=1` to turn
|
|
42
|
+
the check off entirely.
|
|
43
|
+
|
|
38
44
|
## Quickstart
|
|
39
45
|
|
|
40
46
|
```sh
|
|
@@ -25,6 +25,12 @@ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-s
|
|
|
25
25
|
Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
|
|
26
26
|
platform-specific beyond the filesystem and HTTPS.
|
|
27
27
|
|
|
28
|
+
**Staying current**: after a command finishes, the CLI prints a one-line
|
|
29
|
+
notice when a newer release exists on PyPI (checked at most once a day,
|
|
30
|
+
cached, quiet when stderr isn't a terminal, never blocks or fails a command).
|
|
31
|
+
Update with `pipx upgrade stml-cli`. Set `STML4_NO_UPDATE_CHECK=1` to turn
|
|
32
|
+
the check off entirely.
|
|
33
|
+
|
|
28
34
|
## Quickstart
|
|
29
35
|
|
|
30
36
|
```sh
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
11
|
|
|
12
|
-
__version__ = "0.1.
|
|
12
|
+
__version__ = "0.1.9"
|
|
13
13
|
|
|
14
14
|
# TLS trust: framework/python.org builds have no OpenSSL CA path, so a real
|
|
15
15
|
# https backend fails with "unable to get local issuer certificate". certifi's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""`stml` CLI entry point — argparse dispatch (024 §CLI surface).
|
|
2
2
|
|
|
3
|
-
stml login | logout | whoami
|
|
3
|
+
stml login | logout | whoami | token
|
|
4
4
|
stml list [--workspace <ws>]
|
|
5
5
|
stml pull <app-url> [<dir>]
|
|
6
6
|
stml push [<app-url>] <dir>
|
|
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from . import __version__, commands, http
|
|
22
|
+
from . import __version__, commands, http, update_check
|
|
23
23
|
from .config import DEFAULT_BACKEND
|
|
24
24
|
|
|
25
25
|
|
|
@@ -32,6 +32,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
32
32
|
sub.add_parser("login", help="Authenticate in the browser and cache a session").set_defaults(func=commands.cmd_login)
|
|
33
33
|
sub.add_parser("logout", help="Delete the cached session").set_defaults(func=commands.cmd_logout)
|
|
34
34
|
sub.add_parser("whoami", help="Show the resolved user + backend").set_defaults(func=commands.cmd_whoami)
|
|
35
|
+
sub.add_parser(
|
|
36
|
+
"token",
|
|
37
|
+
help="Print a fresh access token for the backend (for scripts: STML4_TOKEN=$(stml token))",
|
|
38
|
+
).set_defaults(func=commands.cmd_token)
|
|
35
39
|
|
|
36
40
|
ls = sub.add_parser(
|
|
37
41
|
"list",
|
|
@@ -146,6 +150,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
146
150
|
print(e.code, file=sys.stderr)
|
|
147
151
|
return 1
|
|
148
152
|
return e.code or 0
|
|
153
|
+
finally:
|
|
154
|
+
# Post-command so the (at most daily) PyPI round-trip lands at exit,
|
|
155
|
+
# never ahead of a command's own output. Silent in scripts/CI.
|
|
156
|
+
update_check.maybe_notify()
|
|
149
157
|
return 0
|
|
150
158
|
|
|
151
159
|
|
|
@@ -47,7 +47,17 @@ def cmd_login(args) -> None:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def cmd_logout(args) -> None:
|
|
50
|
-
|
|
50
|
+
if config.clear_session(args.backend):
|
|
51
|
+
_log(f"✓ Logged out of {args.backend}")
|
|
52
|
+
else:
|
|
53
|
+
_log(f"Not logged in to {args.backend}.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_token(args) -> None:
|
|
57
|
+
# stdout carries ONLY the token, so `STML4_TOKEN=$(stml token)` (and the
|
|
58
|
+
# monorepo publish wrappers) can substitute it cleanly; any chatter a
|
|
59
|
+
# refresh produces goes to stderr.
|
|
60
|
+
print(http.resolve_token(args.backend))
|
|
51
61
|
|
|
52
62
|
|
|
53
63
|
def cmd_whoami(args) -> None:
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Per-OS config dir + the cached login sessions (024 §OS & runtime support §4).
|
|
2
|
+
|
|
3
|
+
One session file PER BACKEND (``session-<host[-port]>.json``), so signing in to
|
|
4
|
+
a second backend (a dev stack next to prod, say) never clobbers the first —
|
|
5
|
+
a partner with a single backend still sees exactly one file. The refresh token
|
|
6
|
+
lives here between commands; access tokens are minted per call and never
|
|
7
|
+
persisted. Files are written user-only where the OS supports it (``chmod`` is
|
|
8
|
+
a no-op on Windows, which relies on the per-user profile dir).
|
|
9
|
+
|
|
10
|
+
Installs older than 0.1.9 kept a single ``session.json``; it is adopted into
|
|
11
|
+
the per-backend file on first read against the backend it was minted for.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.parse
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
DEFAULT_BACKEND = os.environ.get("STML4_BACKEND", "https://api.stml.io")
|
|
24
|
+
|
|
25
|
+
_LEGACY_SESSION_FILE = "session.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_dir() -> Path:
|
|
29
|
+
"""OS-appropriate config directory for stml (created on demand)."""
|
|
30
|
+
if sys.platform == "darwin":
|
|
31
|
+
base = Path.home() / "Library" / "Application Support"
|
|
32
|
+
elif os.name == "nt":
|
|
33
|
+
base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
|
|
34
|
+
else: # Linux / other POSIX
|
|
35
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
36
|
+
return base / "stml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _session_filename(backend: str) -> str:
|
|
40
|
+
"""``session-<host[-port]>.json`` for the backend URL — the per-backend key."""
|
|
41
|
+
u = urllib.parse.urlsplit(backend)
|
|
42
|
+
host = (u.hostname or "").lower()
|
|
43
|
+
if not host: # not URL-shaped; sanitize the whole string rather than guess
|
|
44
|
+
return f"session-{re.sub(r'[^A-Za-z0-9._-]+', '-', backend.strip().lower())}.json"
|
|
45
|
+
return f"session-{host}-{u.port}.json" if u.port else f"session-{host}.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _session_path(backend: str) -> Path:
|
|
49
|
+
return config_dir() / _session_filename(backend)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_json(p: Path) -> dict | None:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_session(backend: str) -> dict | None:
|
|
60
|
+
"""Return the cached session for this backend, or None if not logged in.
|
|
61
|
+
|
|
62
|
+
Falls back to the pre-0.1.9 single ``session.json`` when it was minted for
|
|
63
|
+
this same backend, migrating it to the per-backend file as a side effect.
|
|
64
|
+
A legacy file for a DIFFERENT backend is left alone — it gets adopted when
|
|
65
|
+
a command targets that backend.
|
|
66
|
+
"""
|
|
67
|
+
p = _session_path(backend)
|
|
68
|
+
if p.exists():
|
|
69
|
+
return _read_json(p)
|
|
70
|
+
|
|
71
|
+
legacy = config_dir() / _LEGACY_SESSION_FILE
|
|
72
|
+
if legacy.exists():
|
|
73
|
+
sess = _read_json(legacy)
|
|
74
|
+
if sess and (sess.get("backend") or "").rstrip("/") == backend.rstrip("/"):
|
|
75
|
+
write_session(sess)
|
|
76
|
+
legacy.unlink(missing_ok=True)
|
|
77
|
+
return sess
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_session(session: dict) -> None:
|
|
82
|
+
"""Persist the session dict (keyed by its ``backend``) with user-only perms."""
|
|
83
|
+
backend = session.get("backend") or ""
|
|
84
|
+
if not backend:
|
|
85
|
+
raise ValueError("session is missing 'backend' — cannot pick a session file")
|
|
86
|
+
d = config_dir()
|
|
87
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
p = _session_path(backend)
|
|
89
|
+
p.write_text(json.dumps(session, indent=2), encoding="utf-8")
|
|
90
|
+
try: # best-effort; POSIX only
|
|
91
|
+
os.chmod(p, 0o600)
|
|
92
|
+
except OSError: # pragma: no cover
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def clear_session(backend: str) -> bool:
|
|
97
|
+
"""Delete the cached session for this backend (the legacy single file too,
|
|
98
|
+
when it belongs to it). Returns True if anything was removed."""
|
|
99
|
+
removed = False
|
|
100
|
+
p = _session_path(backend)
|
|
101
|
+
if p.exists():
|
|
102
|
+
p.unlink()
|
|
103
|
+
removed = True
|
|
104
|
+
legacy = config_dir() / _LEGACY_SESSION_FILE
|
|
105
|
+
if legacy.exists():
|
|
106
|
+
sess = _read_json(legacy)
|
|
107
|
+
if sess and (sess.get("backend") or "").rstrip("/") == backend.rstrip("/"):
|
|
108
|
+
legacy.unlink()
|
|
109
|
+
removed = True
|
|
110
|
+
return removed
|
|
@@ -140,19 +140,11 @@ def resolve_token(backend: str) -> str:
|
|
|
140
140
|
if override:
|
|
141
141
|
return override.strip()
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
# Sessions are cached per backend (config._session_filename), so the token
|
|
144
|
+
# we read here was minted by exactly the backend this command targets.
|
|
145
|
+
sess = config.read_session(backend)
|
|
144
146
|
if not sess or not sess.get("refresh_token"):
|
|
145
|
-
raise SystemExit("Not logged in. Run: stml login (or set STML4_TOKEN)")
|
|
146
|
-
|
|
147
|
-
# The cached session is minted by ONE backend; sending its token elsewhere
|
|
148
|
-
# yields confusing server errors (and whoami would claim a login that isn't
|
|
149
|
-
# valid there). Call the mismatch out instead.
|
|
150
|
-
sess_backend = (sess.get("backend") or "").rstrip("/")
|
|
151
|
-
if sess_backend and sess_backend != backend.rstrip("/"):
|
|
152
|
-
raise SystemExit(
|
|
153
|
-
f"Logged in to {sess_backend}, but this command targets {backend}.\n"
|
|
154
|
-
f"Run: stml --backend {backend} login (a session is valid for one backend)"
|
|
155
|
-
)
|
|
147
|
+
raise SystemExit(f"Not logged in to {backend}. Run: stml login (or set STML4_TOKEN)")
|
|
156
148
|
|
|
157
149
|
now = int(time.time())
|
|
158
150
|
if not sess.get("expires_at") or int(sess["expires_at"]) - now < 60:
|
|
@@ -30,6 +30,13 @@ EXCLUDE_FILES = {"uv.lock", "poetry.lock", "Pipfile.lock", ".DS_Store"}
|
|
|
30
30
|
EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _is_env_file(name: str) -> bool:
|
|
34
|
+
"""SECURITY: `.env` / `.env.<anything>` hold local credentials and must
|
|
35
|
+
never be packaged (`.env.example` is the secret-free template and may
|
|
36
|
+
ship). Matches paths._is_env_file (the push path)."""
|
|
37
|
+
return (name == ".env" or name.startswith(".env.")) and name != ".env.example"
|
|
38
|
+
|
|
39
|
+
|
|
33
40
|
def build_tarball(lib_dir: Path) -> bytes:
|
|
34
41
|
buf = io.BytesIO()
|
|
35
42
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
@@ -41,6 +48,8 @@ def build_tarball(lib_dir: Path) -> bytes:
|
|
|
41
48
|
continue
|
|
42
49
|
if path.name in EXCLUDE_FILES or path.suffix in EXCLUDE_SUFFIXES:
|
|
43
50
|
continue
|
|
51
|
+
if _is_env_file(path.name):
|
|
52
|
+
continue
|
|
44
53
|
if not path.is_file():
|
|
45
54
|
continue
|
|
46
55
|
arcname = f"{lib_dir.name}/{path.relative_to(lib_dir).as_posix()}"
|
|
@@ -57,6 +57,13 @@ _EXCLUDE_NAMES = {".DS_Store"}
|
|
|
57
57
|
_EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _is_env_file(name: str) -> bool:
|
|
61
|
+
"""SECURITY: `.env` / `.env.<anything>` hold local credentials and must
|
|
62
|
+
never be pushed (`.env.example` is the secret-free template and may ship).
|
|
63
|
+
Matches packaging._is_env_file (the publish path)."""
|
|
64
|
+
return (name == ".env" or name.startswith(".env.")) and name != ".env.example"
|
|
65
|
+
|
|
66
|
+
|
|
60
67
|
def to_posix_key(rel: Path) -> str:
|
|
61
68
|
"""A native relative path → the POSIX overlay key (forward slashes)."""
|
|
62
69
|
return PurePosixPath(*rel.parts).as_posix()
|
|
@@ -87,6 +94,8 @@ def read_tree(root: Path) -> list[dict]:
|
|
|
87
94
|
continue
|
|
88
95
|
if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
|
|
89
96
|
continue
|
|
97
|
+
if _is_env_file(p.name):
|
|
98
|
+
continue
|
|
90
99
|
if not p.is_file():
|
|
91
100
|
continue
|
|
92
101
|
# Top-level assets/ is binary, bucket-backed media (written by pull's
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""New-release notice (024 §Distributing the partner CLI).
|
|
2
|
+
|
|
3
|
+
After a command finishes, print a one-line stderr notice when a newer
|
|
4
|
+
``stml-cli`` release exists on PyPI — the way ``gh`` does it. Designed to
|
|
5
|
+
never get in the way:
|
|
6
|
+
|
|
7
|
+
- PyPI is asked at most once per day; the answer is cached in the config dir
|
|
8
|
+
(``update-check.json``) and reused between runs.
|
|
9
|
+
- The fetch has a short timeout and every failure is swallowed (a run must
|
|
10
|
+
never fail or hang because PyPI is unreachable).
|
|
11
|
+
- Quiet when stderr is not a TTY (scripts, CI, cron) and when
|
|
12
|
+
``STML4_NO_UPDATE_CHECK`` is set.
|
|
13
|
+
- Only plain ``X.Y.Z`` versions are compared; anything else (pre-releases,
|
|
14
|
+
odd tags) never triggers a notice.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
|
|
26
|
+
from . import __version__, config
|
|
27
|
+
|
|
28
|
+
PYPI_JSON_URL = "https://pypi.org/pypi/stml-cli/json"
|
|
29
|
+
CHECK_INTERVAL_S = 24 * 3600
|
|
30
|
+
FETCH_TIMEOUT_S = 1.5
|
|
31
|
+
|
|
32
|
+
_STATE_FILE = "update-check.json"
|
|
33
|
+
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse(version: str) -> tuple[int, int, int] | None:
|
|
37
|
+
m = _VERSION_RE.match((version or "").strip())
|
|
38
|
+
return (int(m[1]), int(m[2]), int(m[3])) if m else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_newer(latest: str, current: str) -> bool:
|
|
42
|
+
"""True only when both parse as plain X.Y.Z and latest > current."""
|
|
43
|
+
lat, cur = _parse(latest), _parse(current)
|
|
44
|
+
return bool(lat and cur and lat > cur)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _state_path():
|
|
48
|
+
return config.config_dir() / _STATE_FILE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_state() -> dict:
|
|
52
|
+
try:
|
|
53
|
+
state = json.loads(_state_path().read_text(encoding="utf-8"))
|
|
54
|
+
return state if isinstance(state, dict) else {}
|
|
55
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_state(state: dict) -> None:
|
|
60
|
+
try:
|
|
61
|
+
d = config.config_dir()
|
|
62
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
_state_path().write_text(json.dumps(state), encoding="utf-8")
|
|
64
|
+
except OSError: # read-only home, etc. — the notice is best-effort anyway
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fetch_latest() -> str | None:
|
|
69
|
+
"""The newest version on PyPI, or None on any failure."""
|
|
70
|
+
try:
|
|
71
|
+
req = urllib.request.Request(
|
|
72
|
+
PYPI_JSON_URL, headers={"User-Agent": f"stml-cli/{__version__}"}
|
|
73
|
+
)
|
|
74
|
+
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_S) as resp:
|
|
75
|
+
info = json.loads(resp.read().decode("utf-8")).get("info") or {}
|
|
76
|
+
return info.get("version") or None
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _interactive() -> bool:
|
|
82
|
+
"""Notice-worthy terminal? Scripts and CI (non-TTY stderr) stay quiet."""
|
|
83
|
+
try:
|
|
84
|
+
return sys.stderr.isatty()
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def maybe_notify(now: float | None = None) -> None:
|
|
90
|
+
"""Refresh the cached latest version (at most daily) and print the notice.
|
|
91
|
+
|
|
92
|
+
Called once per invocation, after the command ran — so the one-time fetch
|
|
93
|
+
latency lands at exit, never before a command's output.
|
|
94
|
+
"""
|
|
95
|
+
if os.environ.get("STML4_NO_UPDATE_CHECK"):
|
|
96
|
+
return
|
|
97
|
+
if not _interactive():
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
now = time.time() if now is None else now
|
|
101
|
+
state = _read_state()
|
|
102
|
+
if now - float(state.get("checked_at") or 0) >= CHECK_INTERVAL_S:
|
|
103
|
+
latest = fetch_latest()
|
|
104
|
+
# Stamp checked_at even on failure so an offline machine retries
|
|
105
|
+
# daily, not on every command.
|
|
106
|
+
state = {"checked_at": now, "latest": latest or state.get("latest")}
|
|
107
|
+
_write_state(state)
|
|
108
|
+
|
|
109
|
+
latest = state.get("latest")
|
|
110
|
+
if latest and is_newer(latest, __version__):
|
|
111
|
+
print(
|
|
112
|
+
f"\nA newer stml is available: {__version__} → {latest} — "
|
|
113
|
+
f"update with: pipx upgrade stml-cli",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stml-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: The stml partner CLI — pull, edit, push, and publish stml apps and libraries.
|
|
5
5
|
Author: stml
|
|
6
6
|
Project-URL: Homepage, https://stml.io
|
|
@@ -35,6 +35,12 @@ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-s
|
|
|
35
35
|
Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
|
|
36
36
|
platform-specific beyond the filesystem and HTTPS.
|
|
37
37
|
|
|
38
|
+
**Staying current**: after a command finishes, the CLI prints a one-line
|
|
39
|
+
notice when a newer release exists on PyPI (checked at most once a day,
|
|
40
|
+
cached, quiet when stderr isn't a terminal, never blocks or fails a command).
|
|
41
|
+
Update with `pipx upgrade stml-cli`. Set `STML4_NO_UPDATE_CHECK=1` to turn
|
|
42
|
+
the check off entirely.
|
|
43
|
+
|
|
38
44
|
## Quickstart
|
|
39
45
|
|
|
40
46
|
```sh
|
|
@@ -9,6 +9,7 @@ stml_cli/oauth.py
|
|
|
9
9
|
stml_cli/packaging.py
|
|
10
10
|
stml_cli/paths.py
|
|
11
11
|
stml_cli/refs.py
|
|
12
|
+
stml_cli/update_check.py
|
|
12
13
|
stml_cli.egg-info/PKG-INFO
|
|
13
14
|
stml_cli.egg-info/SOURCES.txt
|
|
14
15
|
stml_cli.egg-info/dependency_links.txt
|
|
@@ -19,4 +20,6 @@ tests/test_disambiguation.py
|
|
|
19
20
|
tests/test_init.py
|
|
20
21
|
tests/test_paths.py
|
|
21
22
|
tests/test_refs.py
|
|
23
|
+
tests/test_sessions.py
|
|
24
|
+
tests/test_update_check.py
|
|
22
25
|
tests/test_whoami_expiry.py
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Per-backend session store: file keying, isolation, legacy adoption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from stml_cli import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def cfg_dir(tmp_path, monkeypatch):
|
|
14
|
+
monkeypatch.setattr(config, "config_dir", lambda: tmp_path)
|
|
15
|
+
return tmp_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _session(backend: str, tag: str) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"backend": backend,
|
|
21
|
+
"access_token": f"at-{tag}",
|
|
22
|
+
"refresh_token": f"rt-{tag}",
|
|
23
|
+
"expires_at": 2_000_000_000,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_session_filename_per_backend():
|
|
28
|
+
assert config._session_filename("https://api.stml.io") == "session-api.stml.io.json"
|
|
29
|
+
assert config._session_filename("http://localhost:8010") == "session-localhost-8010.json"
|
|
30
|
+
# trailing slash and case don't change the key
|
|
31
|
+
assert config._session_filename("https://API.stml.io/") == "session-api.stml.io.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sessions_are_isolated_per_backend(cfg_dir):
|
|
35
|
+
config.write_session(_session("http://localhost:8010", "dev"))
|
|
36
|
+
config.write_session(_session("https://api.stml.io", "prod"))
|
|
37
|
+
|
|
38
|
+
assert config.read_session("http://localhost:8010")["access_token"] == "at-dev"
|
|
39
|
+
assert config.read_session("https://api.stml.io")["access_token"] == "at-prod"
|
|
40
|
+
# logging out of one backend leaves the other untouched
|
|
41
|
+
assert config.clear_session("http://localhost:8010") is True
|
|
42
|
+
assert config.read_session("http://localhost:8010") is None
|
|
43
|
+
assert config.read_session("https://api.stml.io")["access_token"] == "at-prod"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_write_session_requires_backend(cfg_dir):
|
|
47
|
+
with pytest.raises(ValueError):
|
|
48
|
+
config.write_session({"access_token": "x", "refresh_token": "y"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_legacy_session_adopted_for_matching_backend(cfg_dir):
|
|
52
|
+
legacy = cfg_dir / "session.json"
|
|
53
|
+
legacy.write_text(json.dumps(_session("http://localhost:8010", "old")))
|
|
54
|
+
|
|
55
|
+
sess = config.read_session("http://localhost:8010")
|
|
56
|
+
assert sess and sess["access_token"] == "at-old"
|
|
57
|
+
# migrated: per-backend file exists, legacy single file is gone
|
|
58
|
+
assert (cfg_dir / "session-localhost-8010.json").exists()
|
|
59
|
+
assert not legacy.exists()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_legacy_session_left_alone_for_other_backend(cfg_dir):
|
|
63
|
+
legacy = cfg_dir / "session.json"
|
|
64
|
+
legacy.write_text(json.dumps(_session("https://api.stml.io", "prod")))
|
|
65
|
+
|
|
66
|
+
assert config.read_session("http://localhost:8010") is None
|
|
67
|
+
assert legacy.exists() # still owned by prod; adopted when prod is targeted
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_clear_session_removes_matching_legacy_file(cfg_dir):
|
|
71
|
+
legacy = cfg_dir / "session.json"
|
|
72
|
+
legacy.write_text(json.dumps(_session("https://api.stml.io", "prod")))
|
|
73
|
+
|
|
74
|
+
assert config.clear_session("https://api.stml.io") is True
|
|
75
|
+
assert not legacy.exists()
|
|
76
|
+
assert config.clear_session("https://api.stml.io") is False
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Update notice: version comparison, daily cache, and notify gating."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from stml_cli import config, update_check
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def cfg_dir(tmp_path, monkeypatch):
|
|
14
|
+
monkeypatch.setattr(config, "config_dir", lambda: tmp_path)
|
|
15
|
+
return tmp_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def tty_stderr(monkeypatch):
|
|
20
|
+
"""maybe_notify is TTY-gated; pretend we're interactive. (Patching the
|
|
21
|
+
gate, not sys.stderr — capsys swaps the stream object mid-test.)"""
|
|
22
|
+
monkeypatch.setattr(update_check, "_interactive", lambda: True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_is_newer_plain_triples():
|
|
26
|
+
assert update_check.is_newer("0.2.0", "0.1.9")
|
|
27
|
+
assert update_check.is_newer("1.0.0", "0.9.9")
|
|
28
|
+
assert not update_check.is_newer("0.1.9", "0.1.9")
|
|
29
|
+
assert not update_check.is_newer("0.1.8", "0.1.9")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_is_newer_rejects_non_plain_versions():
|
|
33
|
+
# Pre-releases / odd tags never trigger a notice.
|
|
34
|
+
assert not update_check.is_newer("0.2.0rc1", "0.1.9")
|
|
35
|
+
assert not update_check.is_newer("banana", "0.1.9")
|
|
36
|
+
assert not update_check.is_newer("0.2", "0.1.9")
|
|
37
|
+
assert not update_check.is_newer("0.2.0", "0.1.9.dev0")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_notifies_from_fresh_cache_without_fetch(cfg_dir, tty_stderr, monkeypatch, capsys):
|
|
41
|
+
(cfg_dir / "update-check.json").write_text(
|
|
42
|
+
json.dumps({"checked_at": 1_000_000, "latest": "99.0.0"})
|
|
43
|
+
)
|
|
44
|
+
monkeypatch.setattr(
|
|
45
|
+
update_check, "fetch_latest", lambda: pytest.fail("fresh cache must not fetch")
|
|
46
|
+
)
|
|
47
|
+
update_check.maybe_notify(now=1_000_000 + 60)
|
|
48
|
+
assert "99.0.0" in capsys.readouterr().err
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_stale_cache_fetches_and_persists(cfg_dir, tty_stderr, monkeypatch, capsys):
|
|
52
|
+
monkeypatch.setattr(update_check, "fetch_latest", lambda: "99.0.0")
|
|
53
|
+
update_check.maybe_notify(now=5_000_000)
|
|
54
|
+
assert "99.0.0" in capsys.readouterr().err
|
|
55
|
+
state = json.loads((cfg_dir / "update-check.json").read_text())
|
|
56
|
+
assert state == {"checked_at": 5_000_000, "latest": "99.0.0"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_failed_fetch_stamps_checked_at_and_keeps_old_latest(
|
|
60
|
+
cfg_dir, tty_stderr, monkeypatch, capsys
|
|
61
|
+
):
|
|
62
|
+
(cfg_dir / "update-check.json").write_text(
|
|
63
|
+
json.dumps({"checked_at": 0, "latest": "99.0.0"})
|
|
64
|
+
)
|
|
65
|
+
monkeypatch.setattr(update_check, "fetch_latest", lambda: None)
|
|
66
|
+
update_check.maybe_notify(now=5_000_000)
|
|
67
|
+
assert "99.0.0" in capsys.readouterr().err # old answer still notifies
|
|
68
|
+
state = json.loads((cfg_dir / "update-check.json").read_text())
|
|
69
|
+
assert state["checked_at"] == 5_000_000 # offline machines retry daily, not per-command
|
|
70
|
+
assert state["latest"] == "99.0.0"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_no_notice_when_up_to_date(cfg_dir, tty_stderr, monkeypatch, capsys):
|
|
74
|
+
monkeypatch.setattr(update_check, "fetch_latest", lambda: update_check.__version__)
|
|
75
|
+
update_check.maybe_notify(now=5_000_000)
|
|
76
|
+
assert capsys.readouterr().err == ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_silent_when_disabled_by_env(cfg_dir, tty_stderr, monkeypatch, capsys):
|
|
80
|
+
monkeypatch.setenv("STML4_NO_UPDATE_CHECK", "1")
|
|
81
|
+
monkeypatch.setattr(
|
|
82
|
+
update_check, "fetch_latest", lambda: pytest.fail("disabled must not fetch")
|
|
83
|
+
)
|
|
84
|
+
update_check.maybe_notify(now=5_000_000)
|
|
85
|
+
assert capsys.readouterr().err == ""
|
|
86
|
+
assert not (cfg_dir / "update-check.json").exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_silent_when_stderr_is_not_a_tty(cfg_dir, monkeypatch, capsys):
|
|
90
|
+
monkeypatch.setattr(update_check, "_interactive", lambda: False)
|
|
91
|
+
monkeypatch.setattr(
|
|
92
|
+
update_check, "fetch_latest", lambda: pytest.fail("non-tty must not fetch")
|
|
93
|
+
)
|
|
94
|
+
update_check.maybe_notify(now=5_000_000)
|
|
95
|
+
assert capsys.readouterr().err == ""
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Per-OS config dir + the cached login session (024 §OS & runtime support §4).
|
|
2
|
-
|
|
3
|
-
The refresh token lives here between commands; access tokens are minted per
|
|
4
|
-
call and never persisted. The file is written user-only where the OS supports
|
|
5
|
-
it (``chmod`` is a no-op on Windows, which relies on the per-user profile dir).
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
import sys
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
DEFAULT_BACKEND = os.environ.get("STML4_BACKEND", "https://api.stml.io")
|
|
16
|
-
|
|
17
|
-
_SESSION_FILE = "session.json"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def config_dir() -> Path:
|
|
21
|
-
"""OS-appropriate config directory for stml (created on demand)."""
|
|
22
|
-
if sys.platform == "darwin":
|
|
23
|
-
base = Path.home() / "Library" / "Application Support"
|
|
24
|
-
elif os.name == "nt":
|
|
25
|
-
base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
|
|
26
|
-
else: # Linux / other POSIX
|
|
27
|
-
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
28
|
-
return base / "stml"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _session_path() -> Path:
|
|
32
|
-
return config_dir() / _SESSION_FILE
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def read_session() -> dict | None:
|
|
36
|
-
"""Return the cached session dict, or None if not logged in."""
|
|
37
|
-
p = _session_path()
|
|
38
|
-
if not p.exists():
|
|
39
|
-
return None
|
|
40
|
-
try:
|
|
41
|
-
return json.loads(p.read_text(encoding="utf-8"))
|
|
42
|
-
except (json.JSONDecodeError, OSError):
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def write_session(session: dict) -> None:
|
|
47
|
-
"""Persist the session dict with user-only perms where supported."""
|
|
48
|
-
d = config_dir()
|
|
49
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
p = _session_path()
|
|
51
|
-
p.write_text(json.dumps(session, indent=2), encoding="utf-8")
|
|
52
|
-
try: # best-effort; POSIX only
|
|
53
|
-
os.chmod(p, 0o600)
|
|
54
|
-
except OSError: # pragma: no cover
|
|
55
|
-
pass
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def clear_session() -> bool:
|
|
59
|
-
"""Delete the cached session. Returns True if one was present."""
|
|
60
|
-
p = _session_path()
|
|
61
|
-
if p.exists():
|
|
62
|
-
p.unlink()
|
|
63
|
-
return True
|
|
64
|
-
return False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|