Skip to content

Commit ab56b84

Browse files
nicolasguelficlaude
andcommitted
feat(cli): stx sync + deterministic stx update --locked (v0.7.15)
- New top-level `stx sync` command: project-level uv sync --locked (idempotent), walks up to find pyproject.toml. --upgrade-deps to refresh lock from pyproject. - `stx update` now uses `uv sync --locked` by default; new `--upgrade-deps` flag opts back into the legacy behaviour (uv lock --upgrade-package streamtex + plain uv sync) — required when bumping streamtex after a PyPI release. - Eliminates silent uv.lock flip-flop between editable and PyPI modes during routine updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d87cc1 commit ab56b84

8 files changed

Lines changed: 378 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.15] — 2026-05-25 — `stx sync` + deterministic `stx update --locked`
9+
10+
### Added
11+
12+
- **New top-level command `stx sync`** — project-level dependency sync.
13+
Wraps `uv sync --locked` (deterministic, idempotent) for any directory
14+
containing a `pyproject.toml`. Walks up from the cwd to find the
15+
project root; `stx sync <path>` targets a specific directory.
16+
- **`stx sync --upgrade-deps`** removes `--locked` so `uv sync` may
17+
refresh `uv.lock` from `pyproject.toml`. Use when pyproject changes
18+
intentionally.
19+
- **`stx update --upgrade-deps`** flag: opt back into the legacy
20+
behaviour (`uv lock --upgrade-package streamtex` + plain `uv sync`).
21+
Required when bumping streamtex after a new PyPI release.
22+
23+
### Changed
24+
25+
- **`stx update` now uses `uv sync --locked` by default.** Routine
26+
updates no longer rewrite `uv.lock` — they fail loudly when the lock
27+
diverges from `pyproject.toml`, with a hint pointing to
28+
`--upgrade-deps`. Eliminates silent lock flip-flop between editable
29+
and PyPI modes.
30+
- The `--no-sources` fallback (used when a local editable source is
31+
missing) still rewrites the lock; `_restore_uv_lock_if_only_dirty`
32+
brings it back to the committed state after sync.
33+
834
## [0.7.14] — 2026-05-24 — `stx claude update` redesign
935

1036
### Changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "streamtex"
7-
version = "0.7.14"
7+
version = "0.7.15"
88
description = "AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required."
99
readme = "README.md"
1010
requires-python = ">=3.11"

streamtex/cli/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from .screenshot_cmd import screenshot as stx_screenshot
5555
from .shortcuts import run_lint, run_test
5656
from .status_cmd import status as workspace_status
57+
from .sync_cmd import sync as stx_sync
5758
from .upgrade_cmd import upgrade as project_upgrade
5859
from .validate_cmd import validate as stx_validate
5960
from .workspace_cmd import update as workspace_update
@@ -70,6 +71,7 @@ def cli():
7071
cli.add_command(stx_install)
7172
cli.add_command(stx_run)
7273
cli.add_command(stx_screenshot)
74+
cli.add_command(stx_sync, name="sync")
7375
cli.add_command(workspace_update, name="update")
7476
cli.add_command(workspace_status, name="status")
7577

streamtex/cli/sync_cmd.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""stx sync — project-level deterministic dependency sync.
2+
3+
Wraps ``uv sync --locked`` for any directory containing a pyproject.toml.
4+
Use this when you are not at a workspace root (where ``stx update`` is
5+
the right command for the whole workspace), but inside a single project
6+
— e.g. a pack subdirectory, a standalone document folder, or any
7+
``pyproject.toml``-bearing dir outside the workspace orchestration.
8+
9+
Default uses ``--locked`` for idempotent, deterministic syncs. Pass
10+
``--upgrade-deps`` to allow ``uv`` to refresh the lock from pyproject.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import subprocess
16+
from pathlib import Path
17+
18+
import click
19+
20+
from .console import get_console
21+
from .workspace_cmd import _find_uv, _has_missing_local_sources, _restore_uv_lock_if_only_dirty
22+
23+
24+
def _find_pyproject_dir(start: Path) -> Path | None:
25+
"""Walk up from `start` until a directory containing pyproject.toml is found."""
26+
current = start.resolve()
27+
while True:
28+
if (current / "pyproject.toml").is_file():
29+
return current
30+
if current.parent == current:
31+
return None
32+
current = current.parent
33+
34+
35+
@click.command("sync")
36+
@click.option(
37+
"--upgrade-deps",
38+
is_flag=True,
39+
help="Allow uv to refresh uv.lock from pyproject.toml. Default is "
40+
"--locked (deterministic sync to the committed lock state).",
41+
)
42+
@click.argument("path", required=False, type=click.Path(exists=True, file_okay=False))
43+
def sync(upgrade_deps: bool, path: str | None) -> None:
44+
"""Sync the current project's venv from uv.lock (deterministic by default).
45+
46+
With no arguments, syncs the project containing the current directory.
47+
Pass PATH to sync a specific project directory.
48+
"""
49+
console = get_console()
50+
start = Path(path) if path else Path.cwd()
51+
project_dir = _find_pyproject_dir(start)
52+
if project_dir is None:
53+
raise click.ClickException(
54+
f"No pyproject.toml found in {start} or any parent directory."
55+
)
56+
57+
uv = _find_uv()
58+
no_sources = _has_missing_local_sources(str(project_dir))
59+
60+
cmd = [uv, "sync"]
61+
if no_sources:
62+
cmd.append("--no-sources")
63+
console.print(f"[cyan]{project_dir.name}[/cyan]: uv sync --no-sources (editable source not found) …")
64+
elif upgrade_deps:
65+
console.print(f"[cyan]{project_dir.name}[/cyan]: uv sync (upgrade-deps) …")
66+
else:
67+
cmd.append("--locked")
68+
console.print(f"[cyan]{project_dir.name}[/cyan]: uv sync --locked …")
69+
70+
result = subprocess.run(
71+
cmd,
72+
cwd=str(project_dir),
73+
capture_output=True,
74+
text=True,
75+
timeout=180,
76+
)
77+
if result.returncode == 0:
78+
console.print(f"[green]{project_dir.name}[/green]: ok")
79+
if no_sources:
80+
_restore_uv_lock_if_only_dirty(str(project_dir))
81+
return
82+
83+
stderr_text = (result.stderr or "").strip()
84+
if "--locked" in cmd and ("lock" in stderr_text.lower() or "out of date" in stderr_text.lower()):
85+
console.print(f"[yellow]{project_dir.name}[/yellow]: lock out of date")
86+
console.print(
87+
"[dim]Run `stx sync --upgrade-deps` to refresh the lock from pyproject.toml, "
88+
"or fix the divergence manually.[/dim]"
89+
)
90+
if stderr_text:
91+
console.print(f"[dim]{stderr_text}[/dim]")
92+
raise click.ClickException("uv sync --locked failed")
93+
94+
console.print(f"[red]{project_dir.name}[/red]: uv sync failed")
95+
if stderr_text:
96+
console.print(f" {stderr_text}")
97+
raise click.ClickException("uv sync failed")

streamtex/cli/workspace_cmd.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ def _run_uv_sync(
355355
repos: dict,
356356
ws_root: str,
357357
type_filter: set[str] | None = None,
358+
*,
359+
upgrade_deps: bool = False,
358360
) -> None:
359361
"""Run ``uv sync`` in selected repos and projects.
360362
@@ -366,11 +368,18 @@ def _run_uv_sync(
366368
Absolute path to the workspace root.
367369
type_filter:
368370
If provided, only sync repos whose *type* is in this set.
371+
upgrade_deps:
372+
When True, actively upgrade ``streamtex`` in the lock (legacy
373+
behaviour). When False (default), pass ``--locked`` to ``uv sync``
374+
so the lock is never modified — deterministic sync to the committed
375+
state. The ``--no-sources`` fallback always drops ``--locked``
376+
because it intentionally rewrites the lock to PyPI mode.
369377
"""
370378
uv = _find_uv()
371379
console = get_console()
372380
synced = 0
373381
skipped = 0
382+
needs_upgrade_hint = False
374383

375384
targets = _collect_sync_targets(repos, ws_root, type_filter)
376385

@@ -379,8 +388,7 @@ def _run_uv_sync(
379388
return
380389

381390
for target_name, target_path in targets:
382-
# Upgrade streamtex in the lock file first (skip for the library itself)
383-
if _depends_on_streamtex(target_path):
391+
if upgrade_deps and _depends_on_streamtex(target_path):
384392
no_src = _has_missing_local_sources(target_path)
385393
lock_cmd = [uv, "lock", "--upgrade-package", "streamtex"]
386394
if no_src:
@@ -399,13 +407,17 @@ def _run_uv_sync(
399407
skipped += 1
400408
continue
401409

402-
# If local editable sources are missing, fall back to PyPI
410+
no_sources = _has_missing_local_sources(target_path)
403411
cmd = [uv, "sync"]
404-
if _has_missing_local_sources(target_path):
412+
if no_sources:
405413
cmd.append("--no-sources")
406414
console.print(f" [cyan]{target_name}[/cyan]: running uv sync --no-sources (editable source not found) …")
415+
elif upgrade_deps:
416+
console.print(f" [cyan]{target_name}[/cyan]: running uv sync (upgrade-deps) …")
407417
else:
408-
console.print(f" [cyan]{target_name}[/cyan]: running uv sync …")
418+
cmd.append("--locked")
419+
console.print(f" [cyan]{target_name}[/cyan]: running uv sync --locked …")
420+
409421
result = subprocess.run(
410422
cmd,
411423
cwd=target_path,
@@ -416,17 +428,28 @@ def _run_uv_sync(
416428
if result.returncode == 0:
417429
console.print(f" [green]{target_name}[/green]: ok")
418430
synced += 1
419-
# --no-sources rewrites uv.lock (local paths → PyPI).
420-
# Restore the committed version so the repo stays clean.
421-
if "--no-sources" in cmd:
431+
if no_sources:
422432
_restore_uv_lock_if_only_dirty(target_path)
423433
else:
424-
console.print(f" [red]{target_name}[/red]: failed")
425-
if result.stderr:
426-
console.print(f" {result.stderr.strip()}")
434+
stderr_text = (result.stderr or "").strip()
435+
if "--locked" in cmd and ("lock" in stderr_text.lower() or "out of date" in stderr_text.lower()):
436+
console.print(
437+
f" [yellow]{target_name}[/yellow]: lock out of date "
438+
"— re-run with `stx update --upgrade-deps`"
439+
)
440+
needs_upgrade_hint = True
441+
else:
442+
console.print(f" [red]{target_name}[/red]: failed")
443+
if stderr_text:
444+
console.print(f" {stderr_text}")
427445
skipped += 1
428446

429447
console.print(f"\n[bold]Done:[/bold] {synced} synced, {skipped} skipped")
448+
if needs_upgrade_hint:
449+
console.print(
450+
"[dim]Hint: `stx update --upgrade-deps` refreshes the lock when "
451+
"pyproject.toml has new constraints or new streamtex versions exist.[/dim]"
452+
)
430453

431454

432455
# ---------------------------------------------------------------------------
@@ -838,7 +861,13 @@ def _upgrade_cli_tool(
838861
@click.option("--dry-run", is_flag=True, help="Show steps without executing.")
839862
@click.option("--repair", is_flag=True, help="Run repair checks (broken venv, missing __init__.py).")
840863
@click.option("--force", is_flag=True, help="Overwrite locally modified profile files (backup in .claude/.backup/).")
841-
def update(skip_sync, skip_profiles, dry_run, repair, force):
864+
@click.option(
865+
"--upgrade-deps",
866+
is_flag=True,
867+
help="Allow uv to refresh uv.lock (upgrade streamtex + apply pyproject changes). "
868+
"Default is --locked: deterministic sync to the committed lock state.",
869+
)
870+
def update(skip_sync, skip_profiles, dry_run, repair, force, upgrade_deps):
842871
"""Pull repos, clone missing, sync deps, install hooks, update profiles."""
843872
ws_root, config = _require_workspace()
844873
repos = config.get("repos", {})
@@ -995,10 +1024,13 @@ def _step(label: str) -> None:
9951024
_step("Syncing dependencies …")
9961025
if dry_run:
9971026
for target_name, target_path in _collect_sync_targets(repos, ws_root):
998-
upgrade = " (+ upgrade streamtex)" if _depends_on_streamtex(target_path) else ""
999-
console.print(f" [cyan]{target_name}[/cyan]: would uv sync{upgrade}")
1027+
if upgrade_deps:
1028+
upgrade = " (+ upgrade streamtex)" if _depends_on_streamtex(target_path) else ""
1029+
console.print(f" [cyan]{target_name}[/cyan]: would uv sync{upgrade}")
1030+
else:
1031+
console.print(f" [cyan]{target_name}[/cyan]: would uv sync --locked")
10001032
else:
1001-
_run_uv_sync(repos, ws_root)
1033+
_run_uv_sync(repos, ws_root, upgrade_deps=upgrade_deps)
10021034

10031035
# --- project migrations ---
10041036
if not skip_sync:

0 commit comments

Comments
 (0)