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.
@@ -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()