stml-cli 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- stml_cli/__main__.py +153 -0
- stml_cli/commands.py +322 -0
- stml_cli/config.py +64 -0
- stml_cli/http.py +164 -0
- stml_cli/oauth.py +90 -0
- stml_cli/packaging.py +42 -0
- stml_cli/paths.py +70 -0
- stml_cli/refs.py +47 -0
- stml_cli-0.1.0.dist-info/METADATA +86 -0
- stml_cli-0.1.0.dist-info/RECORD +14 -0
- stml_cli-0.1.0.dist-info/WHEEL +5 -0
- stml_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stml_cli-0.1.0.dist-info/top_level.txt +1 -0
stml_cli/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""stml — the partner CLI (024 §Distributing the partner CLI).
|
|
2
|
+
|
|
3
|
+
Pure-Python, stdlib only. ``certifi`` is the sole packaging dependency, used to
|
|
4
|
+
point OpenSSL at a CA bundle so HTTPS to a real backend verifies on framework
|
|
5
|
+
Python builds that ship no CA store (same fix as the monorepo publish scripts).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
# TLS trust: framework/python.org builds have no OpenSSL CA path, so a real
|
|
15
|
+
# https backend fails with "unable to get local issuer certificate". certifi's
|
|
16
|
+
# bundle fixes it. setdefault honours an operator override; no-op for http.
|
|
17
|
+
try: # pragma: no cover - environment dependent
|
|
18
|
+
import certifi
|
|
19
|
+
|
|
20
|
+
os.environ.setdefault("SSL_CERT_FILE", certifi.where())
|
|
21
|
+
except ImportError: # pragma: no cover
|
|
22
|
+
pass
|
stml_cli/__main__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""`stml` CLI entry point — argparse dispatch (024 §CLI surface).
|
|
2
|
+
|
|
3
|
+
stml login | logout | whoami
|
|
4
|
+
stml list [--workspace <ws>]
|
|
5
|
+
stml pull <app-url> [<dir>]
|
|
6
|
+
stml push [<app-url>] <dir>
|
|
7
|
+
stml detach <app-url> (fork: own the full source)
|
|
8
|
+
stml revert <app-url> [--yes] (reset: back to the clean library)
|
|
9
|
+
stml status <app-url>
|
|
10
|
+
stml publish <dir> [--org <slug>]
|
|
11
|
+
stml init <dir> [--name <name>]
|
|
12
|
+
|
|
13
|
+
Auth: STML4_TOKEN overrides everything; otherwise the cached `stml login`
|
|
14
|
+
session is used (and silently refreshed). Backend: --backend or $STML4_BACKEND.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from . import __version__, commands, http
|
|
23
|
+
from .config import DEFAULT_BACKEND
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
p = argparse.ArgumentParser(prog="stml", description="The stml partner CLI.")
|
|
28
|
+
p.add_argument("--version", action="version", version=f"stml {__version__}")
|
|
29
|
+
p.add_argument("--backend", default=DEFAULT_BACKEND, help=f"Backend URL (default: {DEFAULT_BACKEND})")
|
|
30
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
31
|
+
|
|
32
|
+
sub.add_parser("login", help="Authenticate in the browser and cache a session").set_defaults(func=commands.cmd_login)
|
|
33
|
+
sub.add_parser("logout", help="Delete the cached session").set_defaults(func=commands.cmd_logout)
|
|
34
|
+
sub.add_parser("whoami", help="Show the resolved user + backend").set_defaults(func=commands.cmd_whoami)
|
|
35
|
+
|
|
36
|
+
ls = sub.add_parser(
|
|
37
|
+
"list",
|
|
38
|
+
help="List apps you can access (name + pull handle)",
|
|
39
|
+
description="List apps you can access, with the exact `stml pull` handle for each.",
|
|
40
|
+
)
|
|
41
|
+
ls.add_argument("--workspace", metavar="<workspace>", help="Filter to one workspace (slug or name)")
|
|
42
|
+
ls.set_defaults(func=commands.cmd_list)
|
|
43
|
+
|
|
44
|
+
# pull / push (an app's source)
|
|
45
|
+
pull = sub.add_parser(
|
|
46
|
+
"pull",
|
|
47
|
+
help="Pull an app's source to a local folder",
|
|
48
|
+
description="Pull an app's source to a local folder.",
|
|
49
|
+
epilog=(
|
|
50
|
+
"Identify the app by its URL — copy it from `stml list` or straight from\n"
|
|
51
|
+
"the browser. A trailing /flows/…, /sessions/… or /settings is ignored, so\n"
|
|
52
|
+
"the URL of a flow or a run resolves to its app just fine.\n\n"
|
|
53
|
+
"examples:\n"
|
|
54
|
+
" stml pull https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2\n"
|
|
55
|
+
" stml pull https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2/flows/x ./work\n"
|
|
56
|
+
" stml pull invoice-sync-a1b2 # a bare slug also works"
|
|
57
|
+
),
|
|
58
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
59
|
+
)
|
|
60
|
+
pull.add_argument("app", metavar="<app-url>", help="App URL (from `stml list` or the browser); a bare slug also works")
|
|
61
|
+
pull.add_argument("dir", nargs="?", metavar="<dir>", help="Target folder (default: ./<app-slug>)")
|
|
62
|
+
pull.set_defaults(func=commands.cmd_pull)
|
|
63
|
+
|
|
64
|
+
push = sub.add_parser(
|
|
65
|
+
"push",
|
|
66
|
+
help="Push a local folder into the app overlay and deploy",
|
|
67
|
+
description="Push a local folder into the app's overlay and deploy it.",
|
|
68
|
+
epilog=(
|
|
69
|
+
"The <app-url> is optional when pushing a folder created by `stml pull`\n"
|
|
70
|
+
"(it remembers the app in .stml/app.json).\n\n"
|
|
71
|
+
"examples:\n"
|
|
72
|
+
" stml push ./invoice-sync # remembered from pull\n"
|
|
73
|
+
" stml push https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2 ./invoice-sync\n"
|
|
74
|
+
" stml push ./invoice-sync --no-deploy"
|
|
75
|
+
),
|
|
76
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
77
|
+
)
|
|
78
|
+
push.add_argument("app", nargs="?", metavar="<app-url>", help="App URL to push to (optional for a pulled folder)")
|
|
79
|
+
push.add_argument("dir", metavar="<dir>", help="Source folder to push")
|
|
80
|
+
push.add_argument("--no-deploy", action="store_true", help="Write the overlay without deploying")
|
|
81
|
+
push.add_argument("--prune", action="store_true", help="Delete overlay paths absent from the folder")
|
|
82
|
+
push.set_defaults(func=commands.cmd_push)
|
|
83
|
+
|
|
84
|
+
# detach / revert / status (story 035 — the app's attachment lifecycle)
|
|
85
|
+
detach = sub.add_parser(
|
|
86
|
+
"detach",
|
|
87
|
+
help="Fork an installed app: full source into the overlay, library pin cleared",
|
|
88
|
+
description=(
|
|
89
|
+
"Fork an installed app into a self-contained one. The full effective source "
|
|
90
|
+
"(base library + your overlay) is materialised into the app's overlay and the "
|
|
91
|
+
"library pin is cleared — after this, `stml pull` returns the whole tree and "
|
|
92
|
+
"`stml publish` can ship it as your own standalone library. The app stops "
|
|
93
|
+
"receiving library updates. Non-destructive; `stml revert` undoes it."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
detach.add_argument("app", metavar="<app-url>", help="App URL (from `stml list`); a bare slug also works")
|
|
97
|
+
detach.set_defaults(func=commands.cmd_detach)
|
|
98
|
+
|
|
99
|
+
revert = sub.add_parser(
|
|
100
|
+
"revert",
|
|
101
|
+
help="DESTRUCTIVE: discard all customisations, restore the clean library version",
|
|
102
|
+
description=(
|
|
103
|
+
"Permanently delete every overlay customisation (code AND page edits) and restore "
|
|
104
|
+
"the app to its clean library version. A detached app is re-attached to the exact "
|
|
105
|
+
"version it was forked from. Connections, configurations, triggers and run history "
|
|
106
|
+
"are preserved. THERE IS NO UNDO."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
revert.add_argument("app", metavar="<app-url>", help="App URL (from `stml list`); a bare slug also works")
|
|
110
|
+
revert.add_argument("--yes", action="store_true", help="Skip the interactive confirmation")
|
|
111
|
+
revert.set_defaults(func=commands.cmd_revert)
|
|
112
|
+
|
|
113
|
+
status = sub.add_parser(
|
|
114
|
+
"status",
|
|
115
|
+
help="Show an app's mode (attached / detached / scratch) and provenance",
|
|
116
|
+
)
|
|
117
|
+
status.add_argument("app", metavar="<app-url>", help="App URL (from `stml list`); a bare slug also works")
|
|
118
|
+
status.set_defaults(func=commands.cmd_status)
|
|
119
|
+
|
|
120
|
+
# publish / init (library)
|
|
121
|
+
publish = sub.add_parser("publish", help="Publish a library version from a local folder")
|
|
122
|
+
publish.add_argument("dir")
|
|
123
|
+
publish.add_argument("--org", help="Issuer org slug (overrides [tool.stml].issuer-org)")
|
|
124
|
+
publish.set_defaults(func=commands.cmd_publish)
|
|
125
|
+
|
|
126
|
+
init = sub.add_parser("init", help="Scaffold a new library directory")
|
|
127
|
+
init.add_argument("dir")
|
|
128
|
+
init.add_argument("--name")
|
|
129
|
+
init.set_defaults(func=commands.cmd_init)
|
|
130
|
+
|
|
131
|
+
return p
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: list[str] | None = None) -> int:
|
|
135
|
+
args = build_parser().parse_args(argv)
|
|
136
|
+
try:
|
|
137
|
+
args.func(args)
|
|
138
|
+
except http.HttpError as e:
|
|
139
|
+
# An unhandled server/transport error (handled ones — e.g. a 409 the
|
|
140
|
+
# pull picker catches — never reach here). Print the clean detail.
|
|
141
|
+
print(f"✗ {e.detail}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
except SystemExit as e:
|
|
144
|
+
# SystemExit carries our user-facing error strings; print and exit non-zero.
|
|
145
|
+
if isinstance(e.code, str):
|
|
146
|
+
print(e.code, file=sys.stderr)
|
|
147
|
+
return 1
|
|
148
|
+
return e.code or 0
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
raise SystemExit(main())
|
stml_cli/commands.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Subcommand handlers for the `stml` CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import tomllib
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import config, http, oauth, paths
|
|
13
|
+
from .packaging import build_tarball
|
|
14
|
+
from .refs import parse_app_ref
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _log(msg: str) -> None:
|
|
18
|
+
print(msg, file=sys.stderr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _humanize_delta(seconds: float) -> str:
|
|
22
|
+
"""A compact largest-unit relative span, e.g. '42m', '3h', '2d', '15s'."""
|
|
23
|
+
seconds = int(abs(seconds))
|
|
24
|
+
for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
|
|
25
|
+
if seconds >= size:
|
|
26
|
+
return f"{seconds // size}{unit}"
|
|
27
|
+
return f"{seconds}s"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_expiry(exp: int, now: float | None = None) -> str:
|
|
31
|
+
"""Render a JWT ``exp`` (epoch seconds) as local time + relative span."""
|
|
32
|
+
now = time.time() if now is None else now
|
|
33
|
+
local = datetime.fromtimestamp(exp).strftime("%Y-%m-%d %H:%M:%S")
|
|
34
|
+
delta = exp - now
|
|
35
|
+
if delta >= 0:
|
|
36
|
+
return f"{local} (expires in {_humanize_delta(delta)})"
|
|
37
|
+
return f"{local} (EXPIRED {_humanize_delta(delta)} ago)"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── auth ────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def cmd_login(args) -> None:
|
|
43
|
+
sess = oauth.login(args.backend)
|
|
44
|
+
claims = http.token_claims(sess["access_token"])
|
|
45
|
+
who = claims.get("email") or claims.get("sub") or "?"
|
|
46
|
+
_log(f"✓ Logged in to {args.backend} as {who}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_logout(args) -> None:
|
|
50
|
+
_log("✓ Logged out" if config.clear_session() else "Not logged in.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_whoami(args) -> None:
|
|
54
|
+
token = http.resolve_token(args.backend)
|
|
55
|
+
claims = http.token_claims(token)
|
|
56
|
+
_log(f"backend: {args.backend}")
|
|
57
|
+
_log(f"user: {claims.get('email') or claims.get('sub') or '?'}")
|
|
58
|
+
if claims.get("exp"):
|
|
59
|
+
_log(f"token: {_format_expiry(int(claims['exp']))}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── discovery ───────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def cmd_list(args) -> None:
|
|
65
|
+
token = http.resolve_token(args.backend)
|
|
66
|
+
url = f"{args.backend}/api/apps"
|
|
67
|
+
if args.workspace:
|
|
68
|
+
url += "?" + urllib.parse.urlencode({"workspace": args.workspace})
|
|
69
|
+
apps = http.get_json(url, token).get("apps") or []
|
|
70
|
+
if not apps:
|
|
71
|
+
_log("No apps found.")
|
|
72
|
+
return
|
|
73
|
+
# Display name + mode (035: attached/detached/scratch) + the app URL — the
|
|
74
|
+
# exact handle `stml pull`/`push` accept (the URL's app slug is unique, so
|
|
75
|
+
# it never collides even when a library is installed twice). Rows to stdout
|
|
76
|
+
# so `stml list | grep …` works; header to stderr.
|
|
77
|
+
name_w = max(len(a.get("name") or "") for a in apps)
|
|
78
|
+
mode_w = max((len(a.get("mode") or "") for a in apps), default=4)
|
|
79
|
+
_log(f"{'APP'.ljust(name_w)} {'MODE'.ljust(mode_w)} URL")
|
|
80
|
+
for a in apps:
|
|
81
|
+
print(
|
|
82
|
+
f"{(a.get('name') or '').ljust(name_w)} "
|
|
83
|
+
f"{(a.get('mode') or '').ljust(mode_w)} {a.get('url') or ''}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── app source: pull / push ─────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def _pick_by_slug(e: http.HttpError, workspace: str | None, app: str) -> None:
|
|
90
|
+
"""Turn a 409 'matches multiple apps' into a slug picker; re-raise else."""
|
|
91
|
+
if e.code != 409:
|
|
92
|
+
raise SystemExit(e.detail)
|
|
93
|
+
marker = "exact slug:"
|
|
94
|
+
slugs = (
|
|
95
|
+
[s.strip() for s in e.detail.split(marker, 1)[1].split(",") if s.strip()]
|
|
96
|
+
if marker in e.detail else []
|
|
97
|
+
)
|
|
98
|
+
prefix = f"{workspace}/" if workspace else ""
|
|
99
|
+
lines = [f"'{app}' matches multiple apps — pick one by slug:"]
|
|
100
|
+
lines += [f" stml pull {prefix}{s}" for s in slugs]
|
|
101
|
+
if not slugs:
|
|
102
|
+
lines.append(" run `stml list` to see the slugs")
|
|
103
|
+
raise SystemExit("\n".join(lines))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_pull(args) -> None:
|
|
107
|
+
workspace, app = parse_app_ref(args.app)
|
|
108
|
+
token = http.resolve_token(args.backend)
|
|
109
|
+
|
|
110
|
+
query = {"app": app}
|
|
111
|
+
if workspace:
|
|
112
|
+
query["workspace"] = workspace
|
|
113
|
+
url = f"{args.backend}/api/app-source?" + urllib.parse.urlencode(query)
|
|
114
|
+
try:
|
|
115
|
+
result = http.get_json(url, token)
|
|
116
|
+
except http.HttpError as e:
|
|
117
|
+
_pick_by_slug(e, workspace, app)
|
|
118
|
+
files = result.get("files") or []
|
|
119
|
+
|
|
120
|
+
dest = Path(args.dir).resolve() if args.dir else Path.cwd() / app
|
|
121
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
written = paths.write_tree(dest, files)
|
|
123
|
+
|
|
124
|
+
# Record the source coordinate so a later push can be run from the folder.
|
|
125
|
+
_write_link(dest, args.backend, workspace, app)
|
|
126
|
+
_log(f"✓ Pulled {len(written)} file(s) from {result.get('app', app)} → {dest}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_push(args) -> None:
|
|
130
|
+
src = Path(args.dir).resolve()
|
|
131
|
+
if not src.is_dir():
|
|
132
|
+
raise SystemExit(f"{src} is not a directory")
|
|
133
|
+
|
|
134
|
+
link = _read_link(src)
|
|
135
|
+
if args.app:
|
|
136
|
+
workspace, app = parse_app_ref(args.app)
|
|
137
|
+
elif link:
|
|
138
|
+
workspace, app = link.get("workspace"), link["app"]
|
|
139
|
+
else:
|
|
140
|
+
raise SystemExit("Pass an app URL (or push a folder created by `stml pull`).")
|
|
141
|
+
|
|
142
|
+
files = paths.read_tree(src)
|
|
143
|
+
if not files:
|
|
144
|
+
raise SystemExit(f"No source files found under {src}")
|
|
145
|
+
|
|
146
|
+
token = http.resolve_token(args.backend)
|
|
147
|
+
body = {
|
|
148
|
+
"app": app,
|
|
149
|
+
"workspace": workspace,
|
|
150
|
+
"files": files,
|
|
151
|
+
"deploy": not args.no_deploy,
|
|
152
|
+
"prune": args.prune,
|
|
153
|
+
}
|
|
154
|
+
try:
|
|
155
|
+
result = http.post_json(f"{args.backend}/api/app-source", body, token)
|
|
156
|
+
except http.HttpError as e:
|
|
157
|
+
_pick_by_slug(e, workspace, app)
|
|
158
|
+
verb = "pushed" if args.no_deploy else "pushed + deployed"
|
|
159
|
+
_log(f"✓ {verb} {len(files)} file(s) to {app} (written={result.get('written', '?')})")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── detach / revert (story 035) ─────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def _detach_revert_url(backend: str, ref: str, action: str) -> str:
|
|
165
|
+
"""The action endpoint for an app reference. The backend accepts a slug or
|
|
166
|
+
a display name in the path; URL-style refs are reduced to their slug via
|
|
167
|
+
the same parser pull/push use."""
|
|
168
|
+
_workspace, app = parse_app_ref(ref)
|
|
169
|
+
return f"{backend}/api/apps/{urllib.parse.quote(app, safe='')}/{action}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_detach(args) -> None:
|
|
173
|
+
token = http.resolve_token(args.backend)
|
|
174
|
+
result = http.post_json(_detach_revert_url(args.backend, args.app, "detach"), {}, token)
|
|
175
|
+
if result.get("status") == "noop":
|
|
176
|
+
_log(result.get("message") or "Nothing to do.")
|
|
177
|
+
return
|
|
178
|
+
_log(f"✓ Detached from {result.get('detached_from', '?')} — "
|
|
179
|
+
f"{result.get('files_materialized', '?')} file(s) materialised; the app is self-contained.")
|
|
180
|
+
for path in result.get("skipped_binary") or []:
|
|
181
|
+
_log(f" ⚠ skipped binary file: {path}")
|
|
182
|
+
if result.get("deploy_error"):
|
|
183
|
+
_log(f" ⚠ deploy failed (detach itself succeeded): {result['deploy_error']}")
|
|
184
|
+
_log(" `stml pull` now returns the full tree; `stml revert` undoes this.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_revert(args) -> None:
|
|
188
|
+
token = http.resolve_token(args.backend)
|
|
189
|
+
if not args.yes:
|
|
190
|
+
_log("This PERMANENTLY deletes every customisation of this app (code and page "
|
|
191
|
+
"edits) and restores the clean library version. There is no undo.")
|
|
192
|
+
_log("Connections, configurations, triggers and run history are preserved.")
|
|
193
|
+
answer = input("Type the word revert to continue: ").strip().lower()
|
|
194
|
+
if answer != "revert":
|
|
195
|
+
raise SystemExit("Aborted — nothing changed.")
|
|
196
|
+
result = http.post_json(_detach_revert_url(args.backend, args.app, "revert"), {}, token)
|
|
197
|
+
if result.get("status") == "noop":
|
|
198
|
+
_log(result.get("message") or "Nothing to do.")
|
|
199
|
+
return
|
|
200
|
+
_log(f"✓ Reverted to {result.get('restored_version', '?')} — "
|
|
201
|
+
f"{result.get('overlay_files_removed', '?')} overlay file(s) removed.")
|
|
202
|
+
for f in result.get("removed_flows") or []:
|
|
203
|
+
_log(f" ⚠ removed flow whose source left with the overlay: {f.get('name')}")
|
|
204
|
+
if result.get("version_yanked"):
|
|
205
|
+
_log(" ⚠ the restored version is yanked — a newer version exists (App Store → Update).")
|
|
206
|
+
if result.get("deploy_error"):
|
|
207
|
+
_log(f" ⚠ deploy failed (revert itself succeeded): {result['deploy_error']}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_status(args) -> None:
|
|
211
|
+
"""Mode + provenance for one app, resolved via `stml list`'s endpoint."""
|
|
212
|
+
token = http.resolve_token(args.backend)
|
|
213
|
+
_workspace, app = parse_app_ref(args.app)
|
|
214
|
+
apps = http.get_json(f"{args.backend}/api/apps", token).get("apps") or []
|
|
215
|
+
row = next((a for a in apps if a.get("slug") == app or a.get("name") == app), None)
|
|
216
|
+
if row is None:
|
|
217
|
+
raise SystemExit(f"App {app!r} not found (see `stml list`).")
|
|
218
|
+
_log(f"app: {row.get('name')} ({row.get('slug')})")
|
|
219
|
+
_log(f"mode: {row.get('mode')}")
|
|
220
|
+
_log(f"url: {row.get('url')}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ── publish / init (library, ported from stml_lib.py) ───────────────────────
|
|
224
|
+
|
|
225
|
+
def cmd_publish(args) -> None:
|
|
226
|
+
lib_dir = Path(args.dir).resolve()
|
|
227
|
+
manifest_path = lib_dir / "pyproject.toml"
|
|
228
|
+
if not manifest_path.exists():
|
|
229
|
+
raise SystemExit(f"{manifest_path} not found")
|
|
230
|
+
manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
|
231
|
+
project = manifest.get("project") or {}
|
|
232
|
+
name, version = project.get("name"), project.get("version")
|
|
233
|
+
if not name or not version:
|
|
234
|
+
raise SystemExit("pyproject.toml missing project.name or project.version")
|
|
235
|
+
|
|
236
|
+
stml_cfg = (manifest.get("tool") or {}).get("stml") or {}
|
|
237
|
+
org_slug = args.org or stml_cfg.get("issuer-org")
|
|
238
|
+
if not org_slug:
|
|
239
|
+
raise SystemExit("Pass --org or set [tool.stml].issuer-org in pyproject.toml")
|
|
240
|
+
|
|
241
|
+
token = http.resolve_token(args.backend)
|
|
242
|
+
|
|
243
|
+
# Ensure the library exists (409 = already there, which is fine).
|
|
244
|
+
try:
|
|
245
|
+
http.post_json(f"{args.backend}/api/libraries", {"org_slug": org_slug, "name": name}, token)
|
|
246
|
+
_log(f"Created library {org_slug}/{name}")
|
|
247
|
+
except http.HttpError as e:
|
|
248
|
+
if e.code == 409 or "already exists" in (e.detail or ""):
|
|
249
|
+
_log(f"Library {org_slug}/{name} exists — uploading new version")
|
|
250
|
+
else:
|
|
251
|
+
raise SystemExit(e.detail)
|
|
252
|
+
|
|
253
|
+
tarball = build_tarball(lib_dir)
|
|
254
|
+
_log(f"Built tarball: {len(tarball)} bytes")
|
|
255
|
+
result = http.post_multipart(
|
|
256
|
+
f"{args.backend}/api/libraries/{org_slug}/{name}/versions",
|
|
257
|
+
fields={"expected_version": version},
|
|
258
|
+
file_field="tarball",
|
|
259
|
+
filename=f"{name}-{version}.tar.gz",
|
|
260
|
+
file_bytes=tarball,
|
|
261
|
+
token=token,
|
|
262
|
+
)
|
|
263
|
+
sha = (result.get("tarball_sha256") or "")[:12]
|
|
264
|
+
size_kb = (result.get("size_bytes") or 0) / 1024
|
|
265
|
+
_log(f"✓ Published {org_slug}/{name}@{version} ({size_kb:.1f} KB, sha={sha}…)")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cmd_init(args) -> None:
|
|
269
|
+
target = Path(args.dir).resolve()
|
|
270
|
+
if target.exists() and any(target.iterdir()):
|
|
271
|
+
raise SystemExit(f"{target} exists and is not empty")
|
|
272
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
name = args.name or target.name
|
|
274
|
+
(target / "pyproject.toml").write_text(
|
|
275
|
+
f'''[project]
|
|
276
|
+
name = "{name}"
|
|
277
|
+
version = "0.1.0"
|
|
278
|
+
description = "A reusable stml library."
|
|
279
|
+
requires-python = ">=3.11"
|
|
280
|
+
dependencies = []
|
|
281
|
+
|
|
282
|
+
[tool.stml]
|
|
283
|
+
requires-runtime = ">=0.1"
|
|
284
|
+
issuer-org = "" # set to your org slug
|
|
285
|
+
exports-flows = []
|
|
286
|
+
depends-on = []
|
|
287
|
+
''',
|
|
288
|
+
encoding="utf-8",
|
|
289
|
+
)
|
|
290
|
+
pkg = target / name.replace("-", "_")
|
|
291
|
+
pkg.mkdir()
|
|
292
|
+
(pkg / "__init__.py").write_text('"""Library entry point."""\n', encoding="utf-8")
|
|
293
|
+
(target / "README.md").write_text(f"# {name}\n", encoding="utf-8")
|
|
294
|
+
_log(f"✓ Scaffolded {target}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── pull/push folder link (.stml/app.json) ──────────────────────────────────
|
|
298
|
+
|
|
299
|
+
_LINK = Path(".stml") / "app.json"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _write_link(dest: Path, backend: str, workspace: str | None, app: str) -> None:
|
|
303
|
+
import json
|
|
304
|
+
|
|
305
|
+
link_path = dest / _LINK
|
|
306
|
+
link_path.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
link_path.write_text(
|
|
308
|
+
json.dumps({"backend": backend, "workspace": workspace, "app": app}, indent=2),
|
|
309
|
+
encoding="utf-8",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _read_link(src: Path) -> dict | None:
|
|
314
|
+
import json
|
|
315
|
+
|
|
316
|
+
p = src / _LINK
|
|
317
|
+
if not p.exists():
|
|
318
|
+
return None
|
|
319
|
+
try:
|
|
320
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
321
|
+
except (json.JSONDecodeError, OSError):
|
|
322
|
+
return None
|
stml_cli/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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", "http://localhost:8010")
|
|
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
|
stml_cli/http.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tiny stdlib HTTP client + token resolution.
|
|
2
|
+
|
|
3
|
+
Token precedence (024 §The partner CLI login flow):
|
|
4
|
+
1. ``STML4_TOKEN`` env — used verbatim (monorepo `npm run token`, CI).
|
|
5
|
+
2. the cached login session — its access token, silently refreshed when stale.
|
|
6
|
+
3. otherwise: a clear "run stml login" error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import config
|
|
21
|
+
|
|
22
|
+
CLIENT_ID = "stml4-cli"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HttpError(Exception):
|
|
26
|
+
"""A non-2xx (or transport) failure, carrying the status code + a clean
|
|
27
|
+
detail string. Callers can branch on ``.code`` (e.g. 409 → disambiguate);
|
|
28
|
+
the top-level handler prints ``.detail`` for everything else.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, code: int, detail: str):
|
|
32
|
+
super().__init__(detail)
|
|
33
|
+
self.code = code
|
|
34
|
+
self.detail = detail
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── low-level requests ──────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def _open(req: urllib.request.Request) -> dict:
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req) as resp:
|
|
42
|
+
body = resp.read().decode("utf-8")
|
|
43
|
+
return json.loads(body) if body else {}
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
raw = e.read().decode("utf-8", "replace")
|
|
46
|
+
detail = raw
|
|
47
|
+
try: # FastAPI errors are {"detail": "..."} — surface the message, not JSON
|
|
48
|
+
parsed = json.loads(raw)
|
|
49
|
+
if isinstance(parsed, dict) and "detail" in parsed:
|
|
50
|
+
detail = str(parsed["detail"])
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
pass
|
|
53
|
+
raise HttpError(e.code, detail)
|
|
54
|
+
except urllib.error.URLError as e: # network / DNS / TLS
|
|
55
|
+
raise HttpError(0, f"cannot reach {req.full_url}: {e.reason}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_json(url: str, token: str) -> dict:
|
|
59
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET")
|
|
60
|
+
return _open(req)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def post_json(url: str, body: dict, token: str | None = None) -> dict:
|
|
64
|
+
headers = {"Content-Type": "application/json"}
|
|
65
|
+
if token:
|
|
66
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST"
|
|
69
|
+
)
|
|
70
|
+
return _open(req)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def post_form(url: str, fields: dict[str, str]) -> dict:
|
|
74
|
+
"""application/x-www-form-urlencoded POST (the OAuth token endpoint)."""
|
|
75
|
+
data = urllib.parse.urlencode(fields).encode("utf-8")
|
|
76
|
+
req = urllib.request.Request(
|
|
77
|
+
url, data=data,
|
|
78
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST",
|
|
79
|
+
)
|
|
80
|
+
return _open(req)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def post_multipart(
|
|
84
|
+
url: str, fields: dict[str, str], file_field: str, filename: str,
|
|
85
|
+
file_bytes: bytes, token: str,
|
|
86
|
+
) -> dict:
|
|
87
|
+
boundary = f"----STML4-{uuid.uuid4().hex}"
|
|
88
|
+
body = bytearray()
|
|
89
|
+
for name, value in fields.items():
|
|
90
|
+
body += f"--{boundary}\r\n".encode()
|
|
91
|
+
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
|
92
|
+
body += value.encode("utf-8") + b"\r\n"
|
|
93
|
+
body += f"--{boundary}\r\n".encode()
|
|
94
|
+
body += (
|
|
95
|
+
f'Content-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\n'
|
|
96
|
+
f"Content-Type: application/gzip\r\n\r\n"
|
|
97
|
+
).encode()
|
|
98
|
+
body += file_bytes + b"\r\n"
|
|
99
|
+
body += f"--{boundary}--\r\n".encode()
|
|
100
|
+
|
|
101
|
+
req = urllib.request.Request(
|
|
102
|
+
url, data=bytes(body),
|
|
103
|
+
headers={
|
|
104
|
+
"Authorization": f"Bearer {token}",
|
|
105
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
106
|
+
},
|
|
107
|
+
method="POST",
|
|
108
|
+
)
|
|
109
|
+
return _open(req)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── token resolution ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def resolve_token(backend: str) -> str:
|
|
115
|
+
"""Return a usable access token, refreshing the cached session if stale."""
|
|
116
|
+
import os
|
|
117
|
+
|
|
118
|
+
override = os.environ.get("STML4_TOKEN")
|
|
119
|
+
if override:
|
|
120
|
+
return override.strip()
|
|
121
|
+
|
|
122
|
+
sess = config.read_session()
|
|
123
|
+
if not sess or not sess.get("refresh_token"):
|
|
124
|
+
raise SystemExit("Not logged in. Run: stml login (or set STML4_TOKEN)")
|
|
125
|
+
|
|
126
|
+
now = int(time.time())
|
|
127
|
+
if not sess.get("expires_at") or int(sess["expires_at"]) - now < 60:
|
|
128
|
+
sess = refresh_session(backend, sess)
|
|
129
|
+
return sess["access_token"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def refresh_session(backend: str, sess: dict) -> dict:
|
|
133
|
+
"""Exchange the refresh token for a fresh access token; re-cache."""
|
|
134
|
+
try:
|
|
135
|
+
tok = post_form(
|
|
136
|
+
f"{backend}/mcp/oauth/token",
|
|
137
|
+
{"grant_type": "refresh_token", "refresh_token": sess["refresh_token"], "client_id": CLIENT_ID},
|
|
138
|
+
)
|
|
139
|
+
except HttpError:
|
|
140
|
+
raise SystemExit("Session expired. Run: stml login")
|
|
141
|
+
return _store_tokens(backend, tok)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _store_tokens(backend: str, tok: dict) -> dict:
|
|
145
|
+
now = int(time.time())
|
|
146
|
+
expires_at = tok.get("expires_at") or (now + int(tok.get("expires_in") or 3600))
|
|
147
|
+
sess = {
|
|
148
|
+
"backend": backend,
|
|
149
|
+
"access_token": tok["access_token"],
|
|
150
|
+
"refresh_token": tok["refresh_token"],
|
|
151
|
+
"expires_at": int(expires_at),
|
|
152
|
+
}
|
|
153
|
+
config.write_session(sess)
|
|
154
|
+
return sess
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def token_claims(token: str) -> dict[str, Any]:
|
|
158
|
+
"""Best-effort decode of a JWT payload (NOT verified — display only)."""
|
|
159
|
+
try:
|
|
160
|
+
payload = token.split(".")[1]
|
|
161
|
+
payload += "=" * (-len(payload) % 4)
|
|
162
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
163
|
+
except Exception:
|
|
164
|
+
return {}
|
stml_cli/oauth.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`stml login` — the browser OAuth loopback flow (024 §The partner CLI login).
|
|
2
|
+
|
|
3
|
+
Standard PKCE authorization-code flow against the platform's existing
|
|
4
|
+
``/mcp/oauth/{authorize,token}`` endpoints (the same ones `npm run libs:login`
|
|
5
|
+
drives), with a one-shot loopback server catching the redirect. No password is
|
|
6
|
+
ever handled by the CLI; the refresh token is cached, the access token is not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import secrets
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import webbrowser
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
18
|
+
|
|
19
|
+
from . import http
|
|
20
|
+
from .http import CLIENT_ID
|
|
21
|
+
|
|
22
|
+
_DONE_PAGE = (
|
|
23
|
+
b"<!doctype html><html><body style='font-family:sans-serif;padding:3rem'>"
|
|
24
|
+
b"<h2>stml login complete</h2><p>You can close this tab and return to the terminal.</p>"
|
|
25
|
+
b"</body></html>"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
30
|
+
verifier = secrets.token_urlsafe(64)
|
|
31
|
+
challenge = base64.urlsafe_b64encode(
|
|
32
|
+
hashlib.sha256(verifier.encode("ascii")).digest()
|
|
33
|
+
).rstrip(b"=").decode("ascii")
|
|
34
|
+
return verifier, challenge
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def login(backend: str) -> dict:
|
|
38
|
+
"""Run the browser flow and cache the resulting session. Returns it."""
|
|
39
|
+
verifier, challenge = _pkce_pair()
|
|
40
|
+
state = secrets.token_urlsafe(16)
|
|
41
|
+
captured: dict[str, str | None] = {}
|
|
42
|
+
|
|
43
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
44
|
+
def do_GET(self): # noqa: N802 (stdlib signature)
|
|
45
|
+
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
46
|
+
captured["code"] = (params.get("code") or [None])[0]
|
|
47
|
+
captured["state"] = (params.get("state") or [None])[0]
|
|
48
|
+
self.send_response(200)
|
|
49
|
+
self.send_header("Content-Type", "text/html")
|
|
50
|
+
self.end_headers()
|
|
51
|
+
self.wfile.write(_DONE_PAGE)
|
|
52
|
+
|
|
53
|
+
def log_message(self, *_args): # silence the default stderr logging
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
57
|
+
redirect_uri = f"http://127.0.0.1:{server.server_address[1]}/callback"
|
|
58
|
+
authorize_url = f"{backend}/mcp/oauth/authorize?" + urllib.parse.urlencode({
|
|
59
|
+
"response_type": "code",
|
|
60
|
+
"client_id": CLIENT_ID,
|
|
61
|
+
"redirect_uri": redirect_uri,
|
|
62
|
+
"state": state,
|
|
63
|
+
"code_challenge": challenge,
|
|
64
|
+
"code_challenge_method": "S256",
|
|
65
|
+
"scope": "mcp",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
print("Opening your browser to sign in…", file=sys.stderr)
|
|
69
|
+
print(f"If it doesn't open, visit:\n {authorize_url}\n", file=sys.stderr)
|
|
70
|
+
try:
|
|
71
|
+
webbrowser.open(authorize_url)
|
|
72
|
+
except Exception: # headless / no browser — the printed URL is the fallback
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
server.handle_request() # blocks until the single callback arrives
|
|
76
|
+
server.server_close()
|
|
77
|
+
|
|
78
|
+
if not captured.get("code"):
|
|
79
|
+
raise SystemExit("Login failed: no authorization code was returned.")
|
|
80
|
+
if captured.get("state") != state:
|
|
81
|
+
raise SystemExit("Login failed: state mismatch (possible CSRF); aborted.")
|
|
82
|
+
|
|
83
|
+
tok = http.post_form(f"{backend}/mcp/oauth/token", {
|
|
84
|
+
"grant_type": "authorization_code",
|
|
85
|
+
"code": captured["code"],
|
|
86
|
+
"code_verifier": verifier,
|
|
87
|
+
"redirect_uri": redirect_uri,
|
|
88
|
+
"client_id": CLIENT_ID,
|
|
89
|
+
})
|
|
90
|
+
return http._store_tokens(backend, tok)
|
stml_cli/packaging.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Publish-layout tarball builder (ported from scripts/stml_lib.py).
|
|
2
|
+
|
|
3
|
+
One directory → a ``<dir-name>/…`` gzip tarball, excluding local dev junk. The
|
|
4
|
+
platform publish endpoint reads the manifest and explodes ``src/``; this only
|
|
5
|
+
packages the bytes. Kept byte-compatible with the monorepo builder so a lib
|
|
6
|
+
published by either path lands identically.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import tarfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
EXCLUDE_DIRS = {
|
|
16
|
+
".git", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache",
|
|
17
|
+
"dist", "build", ".mypy_cache", ".ruff_cache",
|
|
18
|
+
# Unit tests live in a top-level tests/ per lib (pytest testpaths); test-
|
|
19
|
+
# named files inside packages are intentional runnable flows, so dir-only.
|
|
20
|
+
"tests",
|
|
21
|
+
}
|
|
22
|
+
EXCLUDE_DIR_SUFFIXES = (".egg-info",)
|
|
23
|
+
EXCLUDE_FILES = {"uv.lock", "poetry.lock", "Pipfile.lock", ".DS_Store"}
|
|
24
|
+
EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_tarball(lib_dir: Path) -> bytes:
|
|
28
|
+
buf = io.BytesIO()
|
|
29
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
30
|
+
for path in sorted(lib_dir.rglob("*")):
|
|
31
|
+
parts = path.relative_to(lib_dir).parts
|
|
32
|
+
if any(part in EXCLUDE_DIRS for part in parts):
|
|
33
|
+
continue
|
|
34
|
+
if any(part.endswith(EXCLUDE_DIR_SUFFIXES) for part in parts):
|
|
35
|
+
continue
|
|
36
|
+
if path.name in EXCLUDE_FILES or path.suffix in EXCLUDE_SUFFIXES:
|
|
37
|
+
continue
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
continue
|
|
40
|
+
arcname = f"{lib_dir.name}/{path.relative_to(lib_dir).as_posix()}"
|
|
41
|
+
tar.add(path, arcname=arcname)
|
|
42
|
+
return buf.getvalue()
|
stml_cli/paths.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Cross-platform overlay ↔ local-folder mapping (024 §OS & runtime support).
|
|
2
|
+
|
|
3
|
+
The overlay keys the platform stores are always POSIX (``flows/x.py``). "Runs on
|
|
4
|
+
Windows" is only real if the CLI:
|
|
5
|
+
1. maps a POSIX key to a NATIVE path when writing to disk, and back to a POSIX
|
|
6
|
+
key (never backslashes) when reading — via ``PurePosixPath`` on the wire,
|
|
7
|
+
``Path`` on disk;
|
|
8
|
+
2. reads/writes UTF-8 and preserves ``\n`` exactly, so a pulled file survives
|
|
9
|
+
an untouched push byte-for-byte (no CRLF injection on Windows).
|
|
10
|
+
|
|
11
|
+
These two functions are the whole contract; they are unit-tested in isolation so
|
|
12
|
+
the Windows guarantee doesn't rest on a live round-trip.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
|
|
19
|
+
# Local-only artifacts that must never be pushed into the overlay.
|
|
20
|
+
_EXCLUDE_DIRS = {
|
|
21
|
+
".git", "__pycache__", ".venv", "venv", ".stml",
|
|
22
|
+
"node_modules", ".mypy_cache", ".ruff_cache", ".pytest_cache",
|
|
23
|
+
}
|
|
24
|
+
_EXCLUDE_NAMES = {".DS_Store"}
|
|
25
|
+
_EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_posix_key(rel: Path) -> str:
|
|
29
|
+
"""A native relative path → the POSIX overlay key (forward slashes)."""
|
|
30
|
+
return PurePosixPath(*rel.parts).as_posix()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def key_to_native(key: str) -> Path:
|
|
34
|
+
"""A POSIX overlay key → a native relative Path for this OS."""
|
|
35
|
+
return Path(*PurePosixPath(key).parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_tree(root: Path) -> list[dict]:
|
|
39
|
+
"""Read a local folder into ``[{path, content}]`` with POSIX keys.
|
|
40
|
+
|
|
41
|
+
``read_text`` (universal newlines) collapses any CRLF to ``\n``, so the
|
|
42
|
+
content pushed is always LF-normalised regardless of the author's OS.
|
|
43
|
+
"""
|
|
44
|
+
out: list[dict] = []
|
|
45
|
+
for p in sorted(root.rglob("*")):
|
|
46
|
+
rel = p.relative_to(root)
|
|
47
|
+
if any(part in _EXCLUDE_DIRS for part in rel.parts):
|
|
48
|
+
continue
|
|
49
|
+
if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
|
|
50
|
+
continue
|
|
51
|
+
if not p.is_file():
|
|
52
|
+
continue
|
|
53
|
+
out.append({"path": to_posix_key(rel), "content": p.read_text(encoding="utf-8")})
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_tree(root: Path, files: list[dict]) -> list[str]:
|
|
58
|
+
"""Write ``[{path, content}]`` (POSIX keys) into a local folder.
|
|
59
|
+
|
|
60
|
+
``newline=""`` disables newline translation so ``\n`` is written verbatim —
|
|
61
|
+
a pulled file is not silently rewritten to CRLF on Windows.
|
|
62
|
+
"""
|
|
63
|
+
written: list[str] = []
|
|
64
|
+
for f in files:
|
|
65
|
+
key = f["path"]
|
|
66
|
+
dest = root / key_to_native(key)
|
|
67
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
dest.write_text(f.get("content") or "", encoding="utf-8", newline="")
|
|
69
|
+
written.append(key)
|
|
70
|
+
return written
|
stml_cli/refs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Resolve an app reference to (workspace, app).
|
|
2
|
+
|
|
3
|
+
The canonical handle is an **app URL** — exactly what `stml list` prints and what
|
|
4
|
+
the browser shows. Parsing is deliberately forgiving (most-specific first):
|
|
5
|
+
|
|
6
|
+
- a full app URL with ANY trailing path — /flows/…, /sessions/…, /settings are
|
|
7
|
+
ignored, so pasting the URL of a flow or a run still resolves to its app:
|
|
8
|
+
https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2/flows/x/5
|
|
9
|
+
- a bare path: /orgs/acme/ws/main/apps/invoice-sync-a1b2
|
|
10
|
+
- <workspace>/<app> (legacy, still accepted)
|
|
11
|
+
- <app> (a bare slug or display name)
|
|
12
|
+
|
|
13
|
+
For a URL/path we return the app **slug** (unique) plus the workspace slug for
|
|
14
|
+
context; for the bare forms we return whatever was given (slug or name), and the
|
|
15
|
+
backend resolves + disambiguates it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_app_ref(ref: str) -> tuple[str | None, str]:
|
|
24
|
+
ref = (ref or "").strip()
|
|
25
|
+
if not ref:
|
|
26
|
+
raise SystemExit("No app given. Pass an app URL (see `stml list`).")
|
|
27
|
+
|
|
28
|
+
# A URL or a path carrying the app route → pull the slug out, drop the rest.
|
|
29
|
+
path = urlparse(ref).path if "://" in ref else ref
|
|
30
|
+
segs = [s for s in path.split("/") if s]
|
|
31
|
+
if "apps" in segs:
|
|
32
|
+
i = segs.index("apps")
|
|
33
|
+
if i + 1 >= len(segs):
|
|
34
|
+
raise SystemExit(f"URL has no app slug after /apps/: {ref}")
|
|
35
|
+
app = segs[i + 1] # trailing /flows/…, /sessions/…, /settings ignored
|
|
36
|
+
workspace = None
|
|
37
|
+
if "ws" in segs:
|
|
38
|
+
j = segs.index("ws")
|
|
39
|
+
if j + 1 < len(segs):
|
|
40
|
+
workspace = segs[j + 1]
|
|
41
|
+
return workspace, app
|
|
42
|
+
|
|
43
|
+
# Not a URL: <workspace>/<app>, or a bare slug/name.
|
|
44
|
+
if "/" in ref:
|
|
45
|
+
workspace, app = ref.split("/", 1)
|
|
46
|
+
return (workspace or None), app
|
|
47
|
+
return None, ref
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stml-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The stml partner CLI — pull, edit, push, and publish stml apps and libraries.
|
|
5
|
+
Author: stml
|
|
6
|
+
Project-URL: Homepage, https://stml.io
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: certifi
|
|
10
|
+
|
|
11
|
+
# stml — the partner CLI
|
|
12
|
+
|
|
13
|
+
Pull an app you built on the platform down to a local folder, edit it with your
|
|
14
|
+
own editor and git, push it back to test, and publish a finished version to the
|
|
15
|
+
App Store — without cloning the stml4 monorepo.
|
|
16
|
+
|
|
17
|
+
Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-stories/feature/024-app-authoring-platform.md).
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- **Python 3.11 or newer** on your PATH. That's the only hard requirement.
|
|
22
|
+
- macOS: `brew install python@3.12` (or python.org). **Not** the system
|
|
23
|
+
`/usr/bin/python3` — it's often 3.9, which is too old.
|
|
24
|
+
- Linux: your distro's Python 3.11+, or `pipx`/`pyenv`/`uv`.
|
|
25
|
+
- Windows 10/11: the python.org installer (gives you the `py` launcher).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
| Method | Command |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **pipx** (recommended) | `pipx install stml-cli` |
|
|
32
|
+
| pip (in a venv) | `pip install stml-cli` |
|
|
33
|
+
| single file (no installer) | `curl -fsSL <backend>/cli/stml -o stml && chmod +x stml` |
|
|
34
|
+
|
|
35
|
+
Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
|
|
36
|
+
platform-specific beyond the filesystem and HTTPS.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
# Point at your backend (default: http://localhost:8010)
|
|
42
|
+
export STML4_BACKEND=https://api.stml.io
|
|
43
|
+
|
|
44
|
+
# 1. Sign in — opens your browser; approve, and a session is cached locally.
|
|
45
|
+
stml login
|
|
46
|
+
stml whoami
|
|
47
|
+
|
|
48
|
+
# 2. See your apps and their URLs (the handle you pull/push by).
|
|
49
|
+
stml list
|
|
50
|
+
|
|
51
|
+
# 3. Pull an app's source into a folder — identify it by its URL (copy from
|
|
52
|
+
# `stml list` or the browser; a trailing /flows/… or /sessions/… is fine).
|
|
53
|
+
stml pull https://app.stml.io/orgs/acme/ws/main/apps/my-app-a1b2 ./my-app
|
|
54
|
+
|
|
55
|
+
# 3. Edit with your own tools, commit to git, whatever.
|
|
56
|
+
|
|
57
|
+
# 4. Push it back — checks, writes the overlay, and deploys in one step.
|
|
58
|
+
stml push ./my-app # app remembered from pull
|
|
59
|
+
# or: stml push https://app.stml.io/orgs/acme/ws/main/apps/my-app-a1b2 ./my-app
|
|
60
|
+
|
|
61
|
+
# 5. When ready, publish a version to the App Store.
|
|
62
|
+
# (bump project.version in pyproject.toml first; set [tool.stml].issuer-org)
|
|
63
|
+
stml publish ./my-app
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Authentication
|
|
67
|
+
|
|
68
|
+
`stml login` runs the platform's browser OAuth flow (the same one the monorepo's
|
|
69
|
+
`npm run libs:login` uses): you authenticate in the browser, approve, and a
|
|
70
|
+
**refresh token** is cached under your OS config dir (`~/Library/Application
|
|
71
|
+
Support/stml`, `$XDG_CONFIG_HOME/stml`, or `%APPDATA%\stml`). Access tokens are
|
|
72
|
+
minted per-command and never written to disk.
|
|
73
|
+
|
|
74
|
+
`STML4_TOKEN`, if set, overrides the cache verbatim — how CI and the monorepo
|
|
75
|
+
(`STML4_TOKEN=$(npm run --silent token)`) authenticate.
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
| Command | What |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `stml login` / `logout` / `whoami` | session auth |
|
|
82
|
+
| `stml list [--workspace <ws>]` | list your apps with their app URLs |
|
|
83
|
+
| `stml pull <app-url> [<dir>]` | pull overlay source to a folder (bare slug also works) |
|
|
84
|
+
| `stml push [<app-url>] <dir>` | push a folder to the overlay **and deploy** (`--no-deploy`, `--prune`) |
|
|
85
|
+
| `stml publish <dir> [--org <slug>]` | publish a library version from a folder |
|
|
86
|
+
| `stml init <dir> [--name <name>]` | scaffold a new library directory |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
stml_cli/__init__.py,sha256=24NhWGqLjLcIepCQTVzJ4XpcXH4m2PKI97iwaZcqsk8,789
|
|
2
|
+
stml_cli/__main__.py,sha256=6XpV-fU9IF_mcWaEw-8_FCNV1RlAVnQNFW87vSx8CUE,7229
|
|
3
|
+
stml_cli/commands.py,sha256=oNZg1RjQYYRg3ntOypzz9ZiKDwhBWEeW6f5LaPulD0A,12657
|
|
4
|
+
stml_cli/config.py,sha256=va3K5zTLylAj53Pg7fuzA_obXUeXkER6WmxtCJRY-vM,1934
|
|
5
|
+
stml_cli/http.py,sha256=WMb9QsJNNLoHfa0RCqMoK1lUh8cB8yl8WrHT1IeWdKA,5676
|
|
6
|
+
stml_cli/oauth.py,sha256=3nSc6B4uFibfNMBlAVPKNCy2H_f5VDc5yV584VluL-E,3258
|
|
7
|
+
stml_cli/packaging.py,sha256=ggOjdsEM0kQu0f5IEHuTaofr6clupqXK8GyonwJbfEI,1626
|
|
8
|
+
stml_cli/paths.py,sha256=C6OKhiTI_H6AU9g4tQnR3nL0WPncsau_JCaQXs3xRtI,2619
|
|
9
|
+
stml_cli/refs.py,sha256=Bgp305FqtqpUeDORth-lKH4KoSGBosyZDstEXoCiBXA,1862
|
|
10
|
+
stml_cli-0.1.0.dist-info/METADATA,sha256=GqdY2kIEwfnW5xDUTHB8-OLDnSYmiA5R1Ik6Y6xupA8,3379
|
|
11
|
+
stml_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
stml_cli-0.1.0.dist-info/entry_points.txt,sha256=i3Zxa4MVRZREo5hm7lF82PQpHctVP9gowtssxW1WKN4,48
|
|
13
|
+
stml_cli-0.1.0.dist-info/top_level.txt,sha256=cuShv3nGzgfIZZAT7Xq_tOLE3dVUft3KW-LKT-b4hEc,9
|
|
14
|
+
stml_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stml_cli
|