stacktrace-cli 0.12.0__py3-none-any.whl → 0.14.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.
- stacktrace/__init__.py +1 -1
- stacktrace/cli.py +14 -8
- stacktrace/cloud/__init__.py +12 -35
- stacktrace/cloud/boot.py +186 -0
- stacktrace/cloud/profile.py +71 -0
- stacktrace/cloud/setup.sh +27 -311
- stacktrace/correlate/composition.py +22 -1
- stacktrace/cost/measures.py +2 -2
- stacktrace/cost/models.py +12 -0
- stacktrace/cost/rules.py +3 -193
- stacktrace/cost/usage.py +55 -0
- stacktrace/cost/weights.py +3 -8
- stacktrace/daemon/client.py +10 -4
- stacktrace/daemon/config.py +0 -42
- stacktrace/daemon/costs.py +5 -13
- stacktrace/daemon/db.py +45 -0
- stacktrace/daemon/fleet.py +34 -9
- stacktrace/daemon/policy_collect.py +346 -0
- stacktrace/daemon/policy_inputs.py +278 -0
- stacktrace/daemon/policy_runtime.py +597 -0
- stacktrace/daemon/presentation.py +22 -7
- stacktrace/daemon/protocol.py +46 -2
- stacktrace/daemon/runtime.py +30 -12
- stacktrace/daemon/server.py +76 -2
- stacktrace/daemon/store.py +61 -5
- stacktrace/daemon/usage.py +13 -17
- stacktrace/daemon/webhook.py +251 -23
- stacktrace/detector/cache.py +2 -8
- stacktrace/detector/deterministic.py +813 -568
- stacktrace/detector/finding.py +35 -6
- stacktrace/detector/history.py +4 -13
- stacktrace/detector/jev.py +1 -14
- stacktrace/detector/personal_data.py +328 -0
- stacktrace/detector/reasoning.py +1 -9
- stacktrace/detector/render.py +10 -0
- stacktrace/detector/rules.py +94 -25
- stacktrace/detector/run.py +3 -0
- stacktrace/detector/secrets.py +88 -3
- stacktrace/finding_contract.py +553 -0
- stacktrace/finding_titles.py +46 -0
- stacktrace/hooks/__init__.py +1 -0
- stacktrace/hooks/claude.py +242 -0
- stacktrace/hooks/codex.py +84 -0
- stacktrace/hooks/session.py +76 -0
- stacktrace/monitor/costs.py +4 -4
- stacktrace/monitor/render.py +11 -5
- stacktrace/monitor/site/app.js +57 -1
- stacktrace/monitor/site/cost.js +16 -9
- stacktrace/monitor/site/styles.css +2 -0
- stacktrace/monitor/verdicts.py +26 -9
- stacktrace/policy_display.py +129 -0
- stacktrace/remote/cli.py +8 -44
- stacktrace/remote/client.py +159 -15
- stacktrace/remote/config.py +61 -2
- stacktrace/remote/detect_payload.py +55 -4
- stacktrace/remote/hook_policy.py +193 -0
- stacktrace/remote/policy_service.py +0 -21
- stacktrace/remote/policy_show.py +119 -0
- stacktrace/remote/sync.py +14 -2
- stacktrace/remote/upload_contract.py +40 -0
- stacktrace/telemetry/events.py +1 -0
- {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/METADATA +12 -7
- {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/RECORD +65 -50
- {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.12.0.dist-info → stacktrace_cli-0.14.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
stacktrace/cli.py
CHANGED
|
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import re
|
|
19
19
|
from datetime import UTC, datetime, timedelta
|
|
20
|
-
from importlib.metadata import version
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
from typing import Final
|
|
23
22
|
|
|
@@ -120,7 +119,7 @@ def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
120
119
|
return
|
|
121
120
|
# A build from a branch or PR reports its commit as a PEP 440 local label,
|
|
122
121
|
# `0.4.0+86eef86`; a release install prints the bare version.
|
|
123
|
-
click.echo(f"stacktrace {version_label()}
|
|
122
|
+
click.echo(f"stacktrace {version_label()}")
|
|
124
123
|
ctx.exit()
|
|
125
124
|
|
|
126
125
|
|
|
@@ -195,11 +194,11 @@ class SectionedGroup(click.Group):
|
|
|
195
194
|
raise
|
|
196
195
|
except (click.exceptions.Exit, click.ClickException) as error:
|
|
197
196
|
cause = error.__cause__
|
|
198
|
-
if cause is not None and
|
|
197
|
+
if cause is not None and not _uncounted(ctx):
|
|
199
198
|
emit_error(cause, "cli")
|
|
200
199
|
raise
|
|
201
200
|
except Exception as error:
|
|
202
|
-
if
|
|
201
|
+
if not _uncounted(ctx):
|
|
203
202
|
emit_error(error, "cli")
|
|
204
203
|
raise
|
|
205
204
|
_count_run(ctx)
|
|
@@ -232,9 +231,15 @@ class SectionedGroup(click.Group):
|
|
|
232
231
|
formatter.write_dl(rows)
|
|
233
232
|
|
|
234
233
|
|
|
234
|
+
def _uncounted(ctx: click.Context) -> bool:
|
|
235
|
+
# An internal synchronous native hook is not a developer command and must
|
|
236
|
+
# never initialize an analytics client on the action path (ADR-0131).
|
|
237
|
+
return ctx.invoked_subcommand in UNCOUNTED or ctx.meta.get("internal_admission") is True
|
|
238
|
+
|
|
239
|
+
|
|
235
240
|
def _count_run(ctx: click.Context) -> None:
|
|
236
241
|
name = ctx.invoked_subcommand
|
|
237
|
-
if
|
|
242
|
+
if not _uncounted(ctx):
|
|
238
243
|
emit("command_run", command=command_of(name))
|
|
239
244
|
|
|
240
245
|
|
|
@@ -245,7 +250,7 @@ def _count_run(ctx: click.Context) -> None:
|
|
|
245
250
|
expose_value=False,
|
|
246
251
|
is_eager=True,
|
|
247
252
|
callback=_show_version,
|
|
248
|
-
help="Show the stacktrace
|
|
253
|
+
help="Show the stacktrace version (and build commit) and exit.",
|
|
249
254
|
)
|
|
250
255
|
def main() -> None:
|
|
251
256
|
"""Stacktrace: agent composition analysis, detection and upload."""
|
|
@@ -431,8 +436,9 @@ def detect(
|
|
|
431
436
|
One network call is made on every run, and it is not the detector's:
|
|
432
437
|
correlation matches advisories by sending the package coordinates of the
|
|
433
438
|
components a session invoked to osv.dev. Coordinates only, so this is not
|
|
434
|
-
an offline run, and without stage three it is the only thing
|
|
435
|
-
|
|
439
|
+
an offline run, and without stage three it is the only thing detection
|
|
440
|
+
sends. Usage telemetry, on by default, is separate and carries no session
|
|
441
|
+
content (ADR-0039); `stacktrace telemetry show` prints all of it.
|
|
436
442
|
"""
|
|
437
443
|
analyzer = resolve_analyzer(analyzer, reasoning)
|
|
438
444
|
try:
|
stacktrace/cloud/__init__.py
CHANGED
|
@@ -1,38 +1,10 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Rather than paste that into the setup-script box, a setup script installs the
|
|
10
|
-
CLI and runs this:
|
|
11
|
-
|
|
12
|
-
UV_TOOL_DIR=/usr/local/share/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin \
|
|
13
|
-
uv tool install stacktrace-cli
|
|
14
|
-
stacktrace-cloud-setup
|
|
15
|
-
|
|
16
|
-
Or, equivalently, one hosted line that wraps exactly those two steps (see
|
|
17
|
-
`stacktrace-site`'s `public/remote-install.sh`). Download before running rather
|
|
18
|
-
than piping directly into `sh`: without `pipefail` (the default in the `sh`
|
|
19
|
-
that a setup-script box runs), a failed `curl` still lets `sh` read an empty
|
|
20
|
-
script and exit 0, so a broken download would otherwise snapshot an
|
|
21
|
-
environment with neither the CLI nor the boot hook and report success:
|
|
22
|
-
|
|
23
|
-
curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
|
|
24
|
-
&& sh /tmp/remote-install.sh
|
|
25
|
-
|
|
26
|
-
Both variables matter: setup runs as root, and `uv tool install`'s root
|
|
27
|
-
defaults put the tool's environment under `/root/.local/share/uv/tools` and the
|
|
28
|
-
linked executable under `/root/.local/bin` — both inside `/root`, which a later
|
|
29
|
-
non-root session user cannot traverse. `UV_TOOL_BIN_DIR` alone moves the
|
|
30
|
-
symlink but not the environment it points into, so `command -v stacktrace` would
|
|
31
|
-
find the binary while running it still fails; both must move.
|
|
32
|
-
|
|
33
|
-
The work itself is `setup.sh`, shipped beside this module so it travels in the
|
|
34
|
-
wheel. This shim only locates it and hands off, so the logic stays one auditable
|
|
35
|
-
bash file rather than being reimplemented in Python.
|
|
1
|
+
"""Prepare plugin-owned hooks and asset identity from a remote startup script.
|
|
2
|
+
|
|
3
|
+
The shared installer calls `stacktrace-cloud-setup --user-profile` as the
|
|
4
|
+
selected user. This prepares identity and update metadata without system
|
|
5
|
+
writes. Legacy provisioning retains asset and update records for existing
|
|
6
|
+
recipes. Agent installation belongs to the shared installer.
|
|
7
|
+
SessionStart restores the daemon after process loss. See ADR-0143.
|
|
36
8
|
"""
|
|
37
9
|
|
|
38
10
|
from __future__ import annotations
|
|
@@ -53,6 +25,11 @@ from pathlib import Path
|
|
|
53
25
|
|
|
54
26
|
|
|
55
27
|
def main() -> None:
|
|
28
|
+
if "--user-profile" in sys.argv[1:]:
|
|
29
|
+
from stacktrace.cloud.profile import main as prepare_profile
|
|
30
|
+
|
|
31
|
+
prepare_profile(sys.argv[1:])
|
|
32
|
+
return
|
|
56
33
|
script = Path(__file__).with_name("setup.sh")
|
|
57
34
|
# execvp so the process *becomes* bash: its exit status is the setup
|
|
58
35
|
# script's, which is what the environment reads to decide the session may
|
stacktrace/cloud/boot.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Recover the shared daemon on every host (ADR-0143). Remote recovery can
|
|
2
|
+
update the client before startup (ADR-0123). The SessionStart adapter runs
|
|
3
|
+
this worker detached with the state directory and installer update record.
|
|
4
|
+
|
|
5
|
+
One worker at a time holds `boot.lock`, from its own `daemon status` check
|
|
6
|
+
until the daemon it starts answers. So a worker never replaces the client's
|
|
7
|
+
files while a daemon runs or is starting: a second hook's worker waits, checks
|
|
8
|
+
again, and finds the daemon up.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import os
|
|
13
|
+
import pwd
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
STATE, UPDATE_ENV = "", ""
|
|
21
|
+
#: How long an update may take before the installed client runs instead.
|
|
22
|
+
UPDATE_SECONDS = 120
|
|
23
|
+
#: How long the lock waits for the daemon this worker started to answer.
|
|
24
|
+
START_SECONDS = 30
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def note(text):
|
|
28
|
+
with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
|
|
29
|
+
log.write(f"{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} {text}\n") # noqa: UP017 - legacy wrapper uses system Python, including 3.10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def running():
|
|
33
|
+
"""What `daemon status` says: yes, no, unresponsive, or "" for no answer."""
|
|
34
|
+
try:
|
|
35
|
+
status = subprocess.run(
|
|
36
|
+
["stacktrace", "daemon", "status"],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=30,
|
|
40
|
+
check=False,
|
|
41
|
+
).stdout
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return ""
|
|
44
|
+
for line in status.splitlines():
|
|
45
|
+
if line.startswith("running: "):
|
|
46
|
+
return line[len("running: ") :].strip()
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def recorded():
|
|
51
|
+
"""The uv and the UV_* directories setup wrote, as `KEY=value` lines."""
|
|
52
|
+
try:
|
|
53
|
+
with open(UPDATE_ENV, encoding="utf-8") as stream:
|
|
54
|
+
lines = stream.read().splitlines()
|
|
55
|
+
except OSError:
|
|
56
|
+
return {}
|
|
57
|
+
values = {}
|
|
58
|
+
for line in lines:
|
|
59
|
+
words = shlex.split(line)
|
|
60
|
+
if words[:1] == ["export"]:
|
|
61
|
+
words = words[1:]
|
|
62
|
+
if len(words) == 1 and "=" in words[0]:
|
|
63
|
+
key, value = words[0].split("=", 1)
|
|
64
|
+
values[key] = value
|
|
65
|
+
return values
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def update():
|
|
69
|
+
"""Upgrade the tool install setup recorded, as a user who can write it;
|
|
70
|
+
else say why not. No file: the client is pinned, or setup found no uv."""
|
|
71
|
+
values = recorded()
|
|
72
|
+
if not values:
|
|
73
|
+
return
|
|
74
|
+
uv = values.get("STACKTRACE_UV", "")
|
|
75
|
+
directories = [values[key] for key in ("UV_TOOL_DIR", "UV_TOOL_BIN_DIR") if values.get(key)]
|
|
76
|
+
if not (uv and os.access(uv, os.X_OK)) or not all(os.access(d, os.W_OK) for d in directories):
|
|
77
|
+
user = pwd.getpwuid(os.getuid()).pw_name
|
|
78
|
+
note(
|
|
79
|
+
f"not updating: {user} cannot run uv or write the install; "
|
|
80
|
+
"rebuild the environment to update the client"
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
environment = {**os.environ, **{k: v for k, v in values.items() if k.startswith("UV_")}}
|
|
84
|
+
with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
|
|
85
|
+
try:
|
|
86
|
+
subprocess.run(
|
|
87
|
+
[uv, "tool", "upgrade", "stacktrace-cli"],
|
|
88
|
+
env=environment,
|
|
89
|
+
stdin=subprocess.DEVNULL,
|
|
90
|
+
stdout=log,
|
|
91
|
+
stderr=subprocess.STDOUT,
|
|
92
|
+
timeout=UPDATE_SECONDS,
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
96
|
+
note(f"update stopped: {type(error).__name__}; the installed client runs")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def configure():
|
|
100
|
+
"""Session credentials belong to this user; subscribers fail independently."""
|
|
101
|
+
commands = []
|
|
102
|
+
if os.environ.get("STACKTRACE_REMOTE_TOKEN"):
|
|
103
|
+
# Click reads credentials from the environment, not the process list.
|
|
104
|
+
commands.append(["stacktrace", "remote", "configure"])
|
|
105
|
+
webhook = os.environ.get("STACKTRACE_SLACK_WEBHOOK_URL")
|
|
106
|
+
if webhook:
|
|
107
|
+
commands.append(
|
|
108
|
+
[
|
|
109
|
+
"stacktrace",
|
|
110
|
+
"webhook",
|
|
111
|
+
"configure",
|
|
112
|
+
"--url",
|
|
113
|
+
webhook,
|
|
114
|
+
"--secret",
|
|
115
|
+
os.environ.get("STACKTRACE_WEBHOOK_SECRET", ""),
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
for arguments in commands:
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
arguments,
|
|
122
|
+
stdin=subprocess.DEVNULL,
|
|
123
|
+
stdout=subprocess.DEVNULL,
|
|
124
|
+
stderr=subprocess.DEVNULL,
|
|
125
|
+
timeout=30,
|
|
126
|
+
check=False,
|
|
127
|
+
)
|
|
128
|
+
if result.returncode:
|
|
129
|
+
note("session configuration failed; using saved configuration")
|
|
130
|
+
except (OSError, subprocess.SubprocessError):
|
|
131
|
+
note("session configuration unavailable; using saved configuration")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ensure_daemon():
|
|
135
|
+
lock = os.open(os.path.join(STATE, "boot.lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
136
|
+
try:
|
|
137
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
138
|
+
configure()
|
|
139
|
+
_ensure_daemon()
|
|
140
|
+
finally:
|
|
141
|
+
os.close(lock)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ensure_daemon():
|
|
145
|
+
answer = running()
|
|
146
|
+
if answer == "yes":
|
|
147
|
+
return
|
|
148
|
+
# Only a status of no daemon lets the update run: `unresponsive` is a
|
|
149
|
+
# daemon holding its lock that has not answered yet, and an error or no
|
|
150
|
+
# answer says nothing. Either may be a live client.
|
|
151
|
+
if answer == "no":
|
|
152
|
+
if os.environ.get("CLAUDE_CODE_REMOTE") == "true":
|
|
153
|
+
update()
|
|
154
|
+
else:
|
|
155
|
+
note(f"not updating: daemon status said {answer or 'nothing'}, not that no daemon runs")
|
|
156
|
+
with open(os.path.join(STATE, "daemon.log"), "a", encoding="utf-8") as out:
|
|
157
|
+
subprocess.Popen(
|
|
158
|
+
["stacktrace", "daemon", "start", "--no-reasoning"],
|
|
159
|
+
stdin=subprocess.DEVNULL,
|
|
160
|
+
stdout=out,
|
|
161
|
+
stderr=subprocess.STDOUT,
|
|
162
|
+
start_new_session=True,
|
|
163
|
+
)
|
|
164
|
+
deadline = time.monotonic() + START_SECONDS
|
|
165
|
+
while time.monotonic() < deadline and running() != "yes":
|
|
166
|
+
time.sleep(0.5)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
os.makedirs(STATE, mode=0o700, exist_ok=True)
|
|
171
|
+
os.chmod(STATE, 0o700)
|
|
172
|
+
ensure_daemon()
|
|
173
|
+
if len(sys.argv) > 3:
|
|
174
|
+
import json
|
|
175
|
+
|
|
176
|
+
from stacktrace.hooks.session import register
|
|
177
|
+
|
|
178
|
+
register(json.loads(sys.argv[3]))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
STATE, UPDATE_ENV = sys.argv[1], sys.argv[2]
|
|
183
|
+
try:
|
|
184
|
+
main()
|
|
185
|
+
except Exception: # noqa: BLE001 - never expose native payloads or credentials
|
|
186
|
+
note("session bootstrap failed; inspect daemon status and rerun setup")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Prepare remote state as the selected user, without privileged system files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from stacktrace.daemon.paths import RuntimePaths
|
|
15
|
+
from stacktrace.private_state import ensure_private_directory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write(path: Path, value: str) -> None:
|
|
19
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent)
|
|
20
|
+
try:
|
|
21
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
22
|
+
stream.write(value)
|
|
23
|
+
os.replace(name, path)
|
|
24
|
+
finally:
|
|
25
|
+
Path(name).unlink(missing_ok=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prepare(environment: Mapping[str, str]) -> None:
|
|
29
|
+
config = Path.home() / ".config" / "stacktrace"
|
|
30
|
+
ensure_private_directory(config)
|
|
31
|
+
identity = config / "asset-id"
|
|
32
|
+
imposed = environment.get("STACKTRACE_ASSET_EXTERNAL_ID", "")
|
|
33
|
+
if imposed and (
|
|
34
|
+
len(imposed) > 255 or not imposed.isprintable() or any(c.isspace() for c in imposed)
|
|
35
|
+
):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"asset id must contain at most 255 printable characters without whitespace"
|
|
38
|
+
)
|
|
39
|
+
if imposed or not identity.exists():
|
|
40
|
+
_write(identity, (imposed or str(uuid.uuid4())) + "\n")
|
|
41
|
+
_write(config / "asset-display-prefix", "cloud\n")
|
|
42
|
+
|
|
43
|
+
state = RuntimePaths.from_environment().state.parent
|
|
44
|
+
ensure_private_directory(state)
|
|
45
|
+
uv = shutil.which("uv")
|
|
46
|
+
values = {}
|
|
47
|
+
if uv and environment.get("STACKTRACE_VERSION", "latest") in ("", "latest"):
|
|
48
|
+
values["STACKTRACE_UV"] = uv
|
|
49
|
+
for key in (
|
|
50
|
+
"UV_TOOL_DIR",
|
|
51
|
+
"UV_TOOL_BIN_DIR",
|
|
52
|
+
"UV_PYTHON_INSTALL_DIR",
|
|
53
|
+
"UV_PYTHON_PREFERENCE",
|
|
54
|
+
):
|
|
55
|
+
if environment.get(key):
|
|
56
|
+
values[key] = environment[key]
|
|
57
|
+
# Keep an empty record for a pin. Removing it would enable the legacy fallback.
|
|
58
|
+
_write(
|
|
59
|
+
state / "install-update.env", "".join(f"{k}={shlex.quote(v)}\n" for k, v in values.items())
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(arguments: list[str]) -> None:
|
|
64
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
65
|
+
parser.add_argument("--user-profile", action="store_true", required=True)
|
|
66
|
+
parser.add_argument("--asset-id")
|
|
67
|
+
options = parser.parse_args(arguments)
|
|
68
|
+
environment = dict(os.environ)
|
|
69
|
+
if options.asset_id is not None:
|
|
70
|
+
environment["STACKTRACE_ASSET_EXTERNAL_ID"] = options.asset_id
|
|
71
|
+
prepare(environment)
|