scriptit-cli 0.2.2__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.
- scriptit_cli/__init__.py +17 -0
- scriptit_cli/analytics.py +244 -0
- scriptit_cli/auth.py +553 -0
- scriptit_cli/client.py +42 -0
- scriptit_cli/commands.py +302 -0
- scriptit_cli/config.py +19 -0
- scriptit_cli/errors.py +21 -0
- scriptit_cli/main.py +184 -0
- scriptit_cli/output.py +79 -0
- scriptit_cli/remote.py +1306 -0
- scriptit_cli/remote_auth.py +464 -0
- scriptit_cli/update_check.py +104 -0
- scriptit_cli/util.py +84 -0
- scriptit_cli-0.2.2.dist-info/METADATA +599 -0
- scriptit_cli-0.2.2.dist-info/RECORD +18 -0
- scriptit_cli-0.2.2.dist-info/WHEEL +4 -0
- scriptit_cli-0.2.2.dist-info/entry_points.txt +2 -0
- scriptit_cli-0.2.2.dist-info/licenses/LICENSE +202 -0
scriptit_cli/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The Script.it remote CLI.
|
|
2
|
+
|
|
3
|
+
A thin client for driving a Script.it sandbox from a workstation: it forwards
|
|
4
|
+
verbs into the sandbox and streams back output, so the sandbox's own CLI stays
|
|
5
|
+
the single behavior surface and no automation ever executes locally.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.2"
|
|
9
|
+
|
|
10
|
+
# Oldest sandbox CLI that answers everything this client forwards or parses:
|
|
11
|
+
# `scriptit skills list|show`, `scriptit describe --concepts`, and bare-name
|
|
12
|
+
# `scriptit start <name>` resolution. The sandbox's CLI ships inside the
|
|
13
|
+
# sandbox image on the platform's release schedule, independently of this
|
|
14
|
+
# package, so `session new` checks it and says so once when it is older.
|
|
15
|
+
MIN_SANDBOX_CLI_VERSION = "0.2.1"
|
|
16
|
+
|
|
17
|
+
__all__ = ["MIN_SANDBOX_CLI_VERSION", "__version__"]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Anonymous usage analytics.
|
|
2
|
+
|
|
3
|
+
Sends: the command's name (``session new``, ``start``, ``trigger list``), how
|
|
4
|
+
long it took, whether it succeeded, the client/Python/OS versions, and which
|
|
5
|
+
agent harness is driving when one identifies itself.
|
|
6
|
+
|
|
7
|
+
Never sends: your command line, arguments, script names, file paths, file
|
|
8
|
+
contents, command output, environment variables, tokens, or your email.
|
|
9
|
+
Commands are reduced to a name drawn from a fixed allowlist before anything
|
|
10
|
+
leaves the process (:func:`safe_command`), so a script name, a path or a prompt
|
|
11
|
+
cannot ride along even by accident.
|
|
12
|
+
|
|
13
|
+
Set ``DO_NOT_TRACK=1`` to turn it off.
|
|
14
|
+
|
|
15
|
+
Events go to your own Script.it deployment, over the connection this client is
|
|
16
|
+
already authenticated on. No third party, no collector to configure, and no key
|
|
17
|
+
in this package. Nothing is sent before you log in.
|
|
18
|
+
|
|
19
|
+
Events are buffered in memory and flushed once at exit, on a daemon thread with
|
|
20
|
+
a hard time budget — a slow or unreachable network drops them rather than
|
|
21
|
+
delaying or failing a command.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import atexit
|
|
27
|
+
import contextlib
|
|
28
|
+
import os
|
|
29
|
+
import platform
|
|
30
|
+
import threading
|
|
31
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
32
|
+
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
from scriptit_cli import __version__
|
|
36
|
+
from scriptit_cli.client import detect_client
|
|
37
|
+
|
|
38
|
+
ENDPOINT = "/api/v1/telemetry"
|
|
39
|
+
|
|
40
|
+
# The cross-tool convention, so one setting covers every CLI on the machine
|
|
41
|
+
# instead of one per vendor.
|
|
42
|
+
ENV_DO_NOT_TRACK = "DO_NOT_TRACK"
|
|
43
|
+
|
|
44
|
+
# Budget for the flush. A CLI that pauses to phone home is a CLI people rip
|
|
45
|
+
# out, so this is deliberately shorter than any sane request.
|
|
46
|
+
#
|
|
47
|
+
# Connect and read are split because they fail differently: an unreachable
|
|
48
|
+
# deployment is the common outage and shows up as a connect that never
|
|
49
|
+
# completes, so that half is short. One that answered slowly is already working
|
|
50
|
+
# and gets the longer half. The join is the real ceiling — when it expires the
|
|
51
|
+
# interpreter exits and the daemon thread dies with it.
|
|
52
|
+
_POST_TIMEOUT_SECONDS = (1.0, 2.0)
|
|
53
|
+
_FLUSH_BUDGET_SECONDS = 2.5
|
|
54
|
+
|
|
55
|
+
# The only words that may leave this process from a command line. An
|
|
56
|
+
# allowlist, not a pattern: a script name, a workspace path and a bearer token
|
|
57
|
+
# are all shaped exactly like a verb, so nothing but "is this one of ours?"
|
|
58
|
+
# can tell them apart. `start quarterly-revenue` must report as `start`.
|
|
59
|
+
#
|
|
60
|
+
# The cost is that a verb the platform adds later reports as `other` until this
|
|
61
|
+
# set catches up — the right direction to fail, and the `other` count is the
|
|
62
|
+
# signal that it needs updating.
|
|
63
|
+
_KNOWN_TOKENS = frozenset(
|
|
64
|
+
{
|
|
65
|
+
# this client's own commands and their subcommands
|
|
66
|
+
"auth",
|
|
67
|
+
"context",
|
|
68
|
+
"current",
|
|
69
|
+
"exec",
|
|
70
|
+
"fs",
|
|
71
|
+
"help",
|
|
72
|
+
"login",
|
|
73
|
+
"logout",
|
|
74
|
+
"ls",
|
|
75
|
+
"pull",
|
|
76
|
+
"push",
|
|
77
|
+
"read",
|
|
78
|
+
"sandbox",
|
|
79
|
+
"session",
|
|
80
|
+
"use",
|
|
81
|
+
"version",
|
|
82
|
+
"wake",
|
|
83
|
+
"write",
|
|
84
|
+
# the platform CLI's verbs and subcommands, as of this release
|
|
85
|
+
"cancel",
|
|
86
|
+
"catalog",
|
|
87
|
+
"clean",
|
|
88
|
+
"compile",
|
|
89
|
+
"config",
|
|
90
|
+
"connect",
|
|
91
|
+
"create",
|
|
92
|
+
"delete",
|
|
93
|
+
"describe",
|
|
94
|
+
"disable",
|
|
95
|
+
"edit",
|
|
96
|
+
"enable",
|
|
97
|
+
"eval",
|
|
98
|
+
"events",
|
|
99
|
+
"get",
|
|
100
|
+
"info",
|
|
101
|
+
"init",
|
|
102
|
+
"inspect",
|
|
103
|
+
"integrations",
|
|
104
|
+
"list",
|
|
105
|
+
"logs",
|
|
106
|
+
"memory",
|
|
107
|
+
"migrate",
|
|
108
|
+
"migrate_workspace",
|
|
109
|
+
"new",
|
|
110
|
+
"new_block",
|
|
111
|
+
"onboarding",
|
|
112
|
+
"path",
|
|
113
|
+
"reconcile",
|
|
114
|
+
"result",
|
|
115
|
+
"revoke",
|
|
116
|
+
"rotate",
|
|
117
|
+
"run",
|
|
118
|
+
"runs",
|
|
119
|
+
"script",
|
|
120
|
+
"seed",
|
|
121
|
+
"sequence",
|
|
122
|
+
"set",
|
|
123
|
+
"share",
|
|
124
|
+
"show",
|
|
125
|
+
"skills",
|
|
126
|
+
"source",
|
|
127
|
+
"start",
|
|
128
|
+
"status",
|
|
129
|
+
"sync",
|
|
130
|
+
"tail",
|
|
131
|
+
"test",
|
|
132
|
+
"trigger",
|
|
133
|
+
"unshare",
|
|
134
|
+
"update",
|
|
135
|
+
"update_block",
|
|
136
|
+
"update_script",
|
|
137
|
+
"validate",
|
|
138
|
+
"wait",
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def safe_command(argv: List[str]) -> str:
|
|
144
|
+
"""The command's *name*, with everything user-supplied removed.
|
|
145
|
+
|
|
146
|
+
At most the first two tokens survive, and only if they are in
|
|
147
|
+
:data:`_KNOWN_TOKENS`. ``["start", "daily-report"]`` becomes ``"start"``;
|
|
148
|
+
``["exec", "--", "cat", "secrets.env"]`` becomes ``"exec"``. Anything
|
|
149
|
+
unrecognized reports as ``"other"``, so an unknown command contributes a
|
|
150
|
+
count and nothing else.
|
|
151
|
+
"""
|
|
152
|
+
tokens: List[str] = []
|
|
153
|
+
for token in argv[:2]:
|
|
154
|
+
lowered = token.lower()
|
|
155
|
+
if lowered not in _KNOWN_TOKENS:
|
|
156
|
+
break
|
|
157
|
+
tokens.append(lowered)
|
|
158
|
+
if not tokens:
|
|
159
|
+
return "other"
|
|
160
|
+
return " ".join(tokens)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Analytics:
|
|
164
|
+
"""Buffers events and posts them once, at exit.
|
|
165
|
+
|
|
166
|
+
Inert until :meth:`bind` supplies somewhere to send them, which is what
|
|
167
|
+
makes "not logged in" and "never reached a deployment" collect nothing
|
|
168
|
+
without a separate check: the credential the events ride on is the same one
|
|
169
|
+
the command needed.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self) -> None:
|
|
173
|
+
self._api_url: Optional[str] = None
|
|
174
|
+
self._token: Optional[Callable[[], str]] = None
|
|
175
|
+
self._events: List[Dict[str, Any]] = []
|
|
176
|
+
self._registered = False
|
|
177
|
+
|
|
178
|
+
def bind(self, api_url: str, token: Callable[[], str]) -> None:
|
|
179
|
+
"""Point at the deployment this invocation authenticated against.
|
|
180
|
+
|
|
181
|
+
``token`` is called at flush time, on the daemon thread, so a command
|
|
182
|
+
that never needed a token does not mint one just to report itself.
|
|
183
|
+
"""
|
|
184
|
+
self._api_url = api_url.rstrip("/")
|
|
185
|
+
self._token = token
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def enabled(self) -> bool:
|
|
189
|
+
if os.environ.get(ENV_DO_NOT_TRACK, "").strip() not in ("", "0"):
|
|
190
|
+
return False
|
|
191
|
+
return bool(self._api_url and self._token)
|
|
192
|
+
|
|
193
|
+
def track(self, event: str, **properties: Any) -> None:
|
|
194
|
+
if not self.enabled:
|
|
195
|
+
return
|
|
196
|
+
self._events.append(
|
|
197
|
+
{
|
|
198
|
+
"event_name": event,
|
|
199
|
+
"properties": {
|
|
200
|
+
"cli_version": __version__,
|
|
201
|
+
"python_version": platform.python_version(),
|
|
202
|
+
"os": platform.system().lower(),
|
|
203
|
+
"arch": platform.machine().lower(),
|
|
204
|
+
"client": detect_client(),
|
|
205
|
+
**{k: v for k, v in properties.items() if v is not None},
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
if not self._registered:
|
|
210
|
+
atexit.register(self.flush)
|
|
211
|
+
self._registered = True
|
|
212
|
+
|
|
213
|
+
def _post(self) -> None:
|
|
214
|
+
"""Ship the buffer. One request per event — the sink takes one at a
|
|
215
|
+
time, and a run emits one or two."""
|
|
216
|
+
token = self._token
|
|
217
|
+
if token is None: # unreachable: `enabled` gates every caller
|
|
218
|
+
return
|
|
219
|
+
headers = {"Authorization": f"Bearer {token()}"}
|
|
220
|
+
url = f"{self._api_url}{ENDPOINT}"
|
|
221
|
+
for event in self._events:
|
|
222
|
+
requests.post(url, json=event, headers=headers, timeout=_POST_TIMEOUT_SECONDS)
|
|
223
|
+
|
|
224
|
+
def flush(self) -> None:
|
|
225
|
+
"""Send what is buffered, or give up — whichever comes first.
|
|
226
|
+
|
|
227
|
+
The daemon thread is what makes the budget a real ceiling: when the
|
|
228
|
+
join times out the interpreter exits and the thread dies with it, so an
|
|
229
|
+
unresponsive deployment costs the time budget and nothing more.
|
|
230
|
+
"""
|
|
231
|
+
if not self._events or not self.enabled:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
def _run() -> None:
|
|
235
|
+
with contextlib.suppress(Exception):
|
|
236
|
+
self._post()
|
|
237
|
+
|
|
238
|
+
worker = threading.Thread(target=_run, daemon=True)
|
|
239
|
+
worker.start()
|
|
240
|
+
worker.join(_FLUSH_BUDGET_SECONDS)
|
|
241
|
+
self._events = []
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
analytics = Analytics()
|