stml-cli 0.1.6__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.
Files changed (28) hide show
  1. {stml_cli-0.1.6 → stml_cli-0.1.9}/PKG-INFO +7 -1
  2. {stml_cli-0.1.6 → stml_cli-0.1.9}/README.md +6 -0
  3. {stml_cli-0.1.6 → stml_cli-0.1.9}/pyproject.toml +5 -0
  4. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/__init__.py +1 -1
  5. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/__main__.py +10 -2
  6. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/commands.py +89 -4
  7. stml_cli-0.1.9/stml_cli/config.py +110 -0
  8. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/http.py +4 -12
  9. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/packaging.py +9 -0
  10. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/paths.py +12 -2
  11. stml_cli-0.1.9/stml_cli/update_check.py +115 -0
  12. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/PKG-INFO +7 -1
  13. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/SOURCES.txt +4 -0
  14. stml_cli-0.1.9/tests/test_init.py +42 -0
  15. {stml_cli-0.1.6 → stml_cli-0.1.9}/tests/test_paths.py +12 -0
  16. stml_cli-0.1.9/tests/test_sessions.py +76 -0
  17. stml_cli-0.1.9/tests/test_update_check.py +95 -0
  18. stml_cli-0.1.6/stml_cli/config.py +0 -64
  19. {stml_cli-0.1.6 → stml_cli-0.1.9}/setup.cfg +0 -0
  20. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/oauth.py +0 -0
  21. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli/refs.py +0 -0
  22. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/dependency_links.txt +0 -0
  23. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/entry_points.txt +0 -0
  24. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/requires.txt +0 -0
  25. {stml_cli-0.1.6 → stml_cli-0.1.9}/stml_cli.egg-info/top_level.txt +0 -0
  26. {stml_cli-0.1.6 → stml_cli-0.1.9}/tests/test_disambiguation.py +0 -0
  27. {stml_cli-0.1.6 → stml_cli-0.1.9}/tests/test_refs.py +0 -0
  28. {stml_cli-0.1.6 → 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.6
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
@@ -25,3 +25,8 @@ version = { attr = "stml_cli.__version__" }
25
25
  [tool.setuptools.packages.find]
26
26
  where = ["."]
27
27
  include = ["stml_cli*"]
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8",
32
+ ]
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
 
12
- __version__ = "0.1.6"
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
- _log("✓ Logged out" if config.clear_session() else "Not logged in.")
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:
@@ -152,6 +162,16 @@ def cmd_pull(args) -> None:
152
162
  _write_link(dest, args.backend, workspace, app)
153
163
  tail = f" (+ {asset_count} asset(s))" if asset_count else ""
154
164
  _log(f"✓ Pulled {len(written)} file(s) from {result.get('app', app)} → {dest}{tail}")
165
+ if result.get("generated_manifest"):
166
+ # The app's overlay had no manifest — the backend reconstructed one from
167
+ # the platform's live state (flow rows, dependency pins, provenance).
168
+ _log(" ℹ pyproject.toml was generated from the app's platform state — "
169
+ "review it (name/version/issuer-org) before `stml publish`.")
170
+ if result.get("base_files"):
171
+ # Attached app: files the app never forked come from its base library
172
+ # version, so the folder is the complete effective tree.
173
+ _log(f" ℹ {result['base_files']} file(s) came from the app's base "
174
+ "library (never forked in the app).")
155
175
 
156
176
 
157
177
  def cmd_push(args) -> None:
@@ -305,6 +325,7 @@ def cmd_init(args) -> None:
305
325
  raise SystemExit(f"{target} exists and is not empty")
306
326
  target.mkdir(parents=True, exist_ok=True)
307
327
  name = args.name or target.name
328
+ pkg_name = name.replace("-", "_")
308
329
  (target / "pyproject.toml").write_text(
309
330
  f'''[project]
310
331
  name = "{name}"
@@ -316,15 +337,79 @@ dependencies = []
316
337
  [tool.stml]
317
338
  requires-runtime = ">=0.1"
318
339
  issuer-org = "" # set to your org slug
319
- exports-flows = []
340
+ exports-flows = [
341
+ {{ name = "hello", entrypoint = "{pkg_name}.hello:app" }},
342
+ ]
320
343
  depends-on = []
344
+
345
+ [dependency-groups]
346
+ dev = [
347
+ "pytest",
348
+ ]
349
+
350
+ [tool.pytest.ini_options]
351
+ pythonpath = ["src"]
321
352
  ''',
322
353
  encoding="utf-8",
323
354
  )
324
- pkg = target / name.replace("-", "_")
325
- pkg.mkdir()
355
+ # src layout — the shape `stml pull` writes and deployment-version 2
356
+ # expects (flows ship under src/); keeps init/pull/push round-trips stable.
357
+ pkg = target / "src" / pkg_name
358
+ pkg.mkdir(parents=True)
326
359
  (pkg / "__init__.py").write_text('"""Library entry point."""\n', encoding="utf-8")
360
+ (pkg / "hello.py").write_text(
361
+ f'''"""A minimal runnable flow — replace its steps with your own and keep
362
+ `exports-flows` in pyproject.toml pointing at each flow's `module:app`."""
363
+
364
+ from stml.runtime import App
365
+
366
+ app = App("hello")
367
+
368
+
369
+ @app.step(name="Greet", description="Says hello.")
370
+ def greet() -> dict:
371
+ return {{"message": "Hello from {name}!"}}
372
+ ''',
373
+ encoding="utf-8",
374
+ )
327
375
  (target / "README.md").write_text(f"# {name}\n", encoding="utf-8")
376
+ (target / "AGENTS.md").write_text(
377
+ f'''# `{name}` — usage for the assistant
378
+
379
+ <What this library does, who installs it, and how its flows are meant to be
380
+ run — write it for the AI assistant working on or with this library.>
381
+
382
+ ## Layout
383
+
384
+ - `src/{pkg_name}/` — the package. Flows live here and are listed in
385
+ `pyproject.toml` under `[tool.stml] exports-flows` as `module:app`.
386
+ - `tests/` — local-only: `stml push` and `stml publish` both skip it.
387
+ Run with `uv run pytest`.
388
+ ''',
389
+ encoding="utf-8",
390
+ )
391
+ (target / ".gitignore").write_text(
392
+ ".venv/\n__pycache__/\n*.pyc\n.stml/\n.pytest_cache/\n.ruff_cache/\n",
393
+ encoding="utf-8",
394
+ )
395
+ # tests/ is LOCAL-ONLY by convention: push and publish both skip it
396
+ # (paths._EXCLUDE_DIRS / packaging.EXCLUDE_DIRS), so scaffold it with a
397
+ # stub that says so — runnable code belongs in the package, never here.
398
+ tests_dir = target / "tests"
399
+ tests_dir.mkdir()
400
+ (tests_dir / "__init__.py").write_text("", encoding="utf-8")
401
+ (tests_dir / "test_smoke.py").write_text(
402
+ f'''"""tests/ is local-only: `stml push` and `stml publish` both skip this
403
+ folder — put runnable code in the package, never in here."""
404
+
405
+ import {pkg_name}
406
+
407
+
408
+ def test_import():
409
+ assert {pkg_name}.__doc__
410
+ ''',
411
+ encoding="utf-8",
412
+ )
328
413
  _log(f"✓ Scaffolded {target}")
329
414
 
330
415
 
@@ -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
- sess = config.read_session()
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()}"
@@ -47,15 +47,23 @@ def _is_packaging_path(rel: str) -> bool:
47
47
  return True
48
48
  return False
49
49
 
50
- # Local-only artifacts that must never be pushed into the overlay.
50
+ # Local-only artifacts that must never be pushed into the overlay. `tests`
51
+ # matches packaging.EXCLUDE_DIRS: unit tests are test-only, never shipped.
51
52
  _EXCLUDE_DIRS = {
52
- ".git", "__pycache__", ".venv", "venv", ".stml",
53
+ ".git", "__pycache__", ".venv", "venv", ".stml", "tests",
53
54
  "node_modules", ".mypy_cache", ".ruff_cache", ".pytest_cache",
54
55
  }
55
56
  _EXCLUDE_NAMES = {".DS_Store"}
56
57
  _EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
57
58
 
58
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
+
59
67
  def to_posix_key(rel: Path) -> str:
60
68
  """A native relative path → the POSIX overlay key (forward slashes)."""
61
69
  return PurePosixPath(*rel.parts).as_posix()
@@ -86,6 +94,8 @@ def read_tree(root: Path) -> list[dict]:
86
94
  continue
87
95
  if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
88
96
  continue
97
+ if _is_env_file(p.name):
98
+ continue
89
99
  if not p.is_file():
90
100
  continue
91
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.6
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
@@ -16,6 +17,9 @@ stml_cli.egg-info/entry_points.txt
16
17
  stml_cli.egg-info/requires.txt
17
18
  stml_cli.egg-info/top_level.txt
18
19
  tests/test_disambiguation.py
20
+ tests/test_init.py
19
21
  tests/test_paths.py
20
22
  tests/test_refs.py
23
+ tests/test_sessions.py
24
+ tests/test_update_check.py
21
25
  tests/test_whoami_expiry.py
@@ -0,0 +1,42 @@
1
+ from pathlib import Path
2
+ from types import SimpleNamespace
3
+
4
+ from stml_cli import commands, paths
5
+
6
+
7
+ def test_init_scaffolds_src_layout_lib(tmp_path: Path):
8
+ target = tmp_path / "my-lib"
9
+ commands.cmd_init(SimpleNamespace(dir=str(target), name=None))
10
+
11
+ # src layout with a runnable example flow, exported in pyproject.
12
+ assert (target / "src" / "my_lib" / "__init__.py").is_file()
13
+ assert (target / "src" / "my_lib" / "hello.py").is_file()
14
+ pyproject = (target / "pyproject.toml").read_text()
15
+ assert 'entrypoint = "my_lib.hello:app"' in pyproject
16
+ # pytest works out of the box: dev dep + src on the test pythonpath.
17
+ assert "pytest" in pyproject and 'pythonpath = ["src"]' in pyproject
18
+ assert (target / "AGENTS.md").is_file()
19
+ assert (target / ".gitignore").is_file()
20
+
21
+ # tests/ is scaffolded but local-only: the stub imports the package name
22
+ # and push (read_tree) must not pick the folder up.
23
+ stub = (target / "tests" / "test_smoke.py").read_text()
24
+ assert "import my_lib" in stub
25
+ keys = {f["path"] for f in paths.read_tree(target)}
26
+ assert "my_lib/hello.py" in keys # src/ stripped to flat overlay keys
27
+ assert not any(k.startswith("tests/") for k in keys)
28
+
29
+
30
+ def test_init_scaffolded_smoke_test_passes(tmp_path: Path):
31
+ """The generated tests/test_smoke.py must actually pass in the scaffold."""
32
+ import subprocess
33
+ import sys
34
+
35
+ target = tmp_path / "demo-lib"
36
+ commands.cmd_init(SimpleNamespace(dir=str(target), name=None))
37
+ res = subprocess.run(
38
+ [sys.executable, "-m", "pytest", "tests/", "-q", "-p", "no:cacheprovider",
39
+ "--rootdir", str(target), "-o", 'pythonpath=src'],
40
+ cwd=target, capture_output=True, text=True,
41
+ )
42
+ assert res.returncode == 0, res.stdout + res.stderr
@@ -114,6 +114,18 @@ def test_local_junk_is_excluded(tmp_path: Path):
114
114
  assert keys == {"flows/x.py"} # .git, __pycache__, .stml, *.pyc all dropped
115
115
 
116
116
 
117
+ def test_tests_dir_is_never_pushed(tmp_path: Path):
118
+ """tests/ is test-only and never shipped — push skips it like publish does
119
+ (packaging.EXCLUDE_DIRS), both top-level and under src/."""
120
+ paths.write_tree(tmp_path, [{"path": "flows/x.py", "content": "x=1\n"}])
121
+ (tmp_path / "tests").mkdir()
122
+ (tmp_path / "tests" / "test_x.py").write_text("def test_x(): pass\n")
123
+ (tmp_path / "src" / "tests").mkdir()
124
+ (tmp_path / "src" / "tests" / "test_y.py").write_text("def test_y(): pass\n")
125
+ keys = {f["path"] for f in paths.read_tree(tmp_path)}
126
+ assert keys == {"flows/x.py"}
127
+
128
+
117
129
  def test_assets_dir_is_never_pushed(tmp_path: Path):
118
130
  """Top-level assets/ is bucket-backed binary media (written by pull) — push
119
131
  must skip it (a .xlsx would crash read_text) but publish ships it from disk."""
@@ -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