credux 0.2.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.
- credux/__init__.py +1 -0
- credux/app.py +55 -0
- credux/awsfiles.py +276 -0
- credux/browser.py +117 -0
- credux/cli.py +1081 -0
- credux/completion.py +136 -0
- credux/config.py +159 -0
- credux/doctor.py +286 -0
- credux/errors.py +51 -0
- credux/fsutil.py +64 -0
- credux/integrations.py +194 -0
- credux/model.py +137 -0
- credux/naming.py +145 -0
- credux/providers/__init__.py +1 -0
- credux/providers/aws/__init__.py +65 -0
- credux/providers/aws/federation.py +140 -0
- credux/providers/aws/iam_role.py +54 -0
- credux/providers/aws/iam_user.py +76 -0
- credux/providers/aws/org.py +81 -0
- credux/providers/aws/sso.py +174 -0
- credux/providers/aws/sso_login.py +161 -0
- credux/providers/base.py +145 -0
- credux/sessions.py +279 -0
- credux/shell/credux.fish +22 -0
- credux/shell/credux.ps1 +17 -0
- credux/shell/credux.sh +14 -0
- credux/shellenv.py +82 -0
- credux/state.py +125 -0
- credux/store.py +74 -0
- credux/sync.py +262 -0
- credux/templates/aliases.sh.j2 +4 -0
- credux/upgrade.py +222 -0
- credux-0.2.0.dist-info/METADATA +217 -0
- credux-0.2.0.dist-info/RECORD +37 -0
- credux-0.2.0.dist-info/WHEEL +4 -0
- credux-0.2.0.dist-info/entry_points.txt +2 -0
- credux-0.2.0.dist-info/licenses/LICENSE +21 -0
credux/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Credux: manage cloud credentials and the profiles that use them."""
|
credux/app.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Wiring: one object that owns config, registry, state, and resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .model import Registry
|
|
11
|
+
from .providers import aws as aws_provider # noqa: F401 registers the aws provider
|
|
12
|
+
from .providers import base as provider_base
|
|
13
|
+
from .providers.base import ResolutionContext
|
|
14
|
+
from .sessions import SessionManager
|
|
15
|
+
from .state import State
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prompt_mfa(serial: str) -> str:
|
|
19
|
+
return click.prompt(f"MFA code for {serial}", hide_input=False, type=str)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class App:
|
|
23
|
+
"""Built once per CLI invocation and handed to every command."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
client_factory: Callable[..., Any] | None = None,
|
|
28
|
+
mfa_code: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.config = Config.load()
|
|
31
|
+
self.registry = Registry(self.config)
|
|
32
|
+
self.state = State.load()
|
|
33
|
+
if client_factory is None:
|
|
34
|
+
client_factory = provider_base.default_client_factory
|
|
35
|
+
self.client_factory = client_factory
|
|
36
|
+
self._mfa_code = mfa_code
|
|
37
|
+
|
|
38
|
+
def _prompt_mfa(self, serial: str) -> str:
|
|
39
|
+
if self._mfa_code:
|
|
40
|
+
return self._mfa_code
|
|
41
|
+
return prompt_mfa(serial)
|
|
42
|
+
|
|
43
|
+
def context(self) -> ResolutionContext:
|
|
44
|
+
return ResolutionContext(
|
|
45
|
+
self.config,
|
|
46
|
+
self.registry,
|
|
47
|
+
client_factory=self.client_factory,
|
|
48
|
+
prompt_mfa=self._prompt_mfa,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def sessions(self, ctx: ResolutionContext | None = None) -> SessionManager:
|
|
52
|
+
return SessionManager(self.config, self.registry, self.state, ctx or self.context())
|
|
53
|
+
|
|
54
|
+
def save(self) -> None:
|
|
55
|
+
self.config.save()
|
credux/awsfiles.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Writing the AWS shared credentials and config files.
|
|
2
|
+
|
|
3
|
+
Credux owns a marker-delimited region. Anything a human wrote outside it is
|
|
4
|
+
copied through untouched.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import configparser
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
from .errors import ConfigError
|
|
15
|
+
from .fsutil import atomic_write_text, backup_once
|
|
16
|
+
|
|
17
|
+
BEGIN_MARKER = "# >>> credux managed block >>> do not edit inside"
|
|
18
|
+
END_MARKER = "# <<< credux managed block <<<"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class WriteReport:
|
|
23
|
+
"""What the caller should tell the user about a write it just made.
|
|
24
|
+
|
|
25
|
+
Both facts are about the file as it was *before* the write, and neither
|
|
26
|
+
can be recovered afterwards: the backup is taken once and never again,
|
|
27
|
+
and credux's own `[default]` section lands inside the managed region on
|
|
28
|
+
every write, so afterwards the file always contains one.
|
|
29
|
+
|
|
30
|
+
Carries no file content, only a path and a flag, because everything this
|
|
31
|
+
module writes is credential material.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
backup: Path | None = None
|
|
35
|
+
shadowed_default: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProfileEntry:
|
|
40
|
+
profile: str
|
|
41
|
+
access_key_id: str
|
|
42
|
+
secret_access_key: str
|
|
43
|
+
session_token: str | None = None
|
|
44
|
+
region: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _default_entry(default_profile: dict | None) -> ProfileEntry | None:
|
|
48
|
+
if not default_profile or not default_profile.get("enabled", True):
|
|
49
|
+
return None
|
|
50
|
+
return ProfileEntry(
|
|
51
|
+
profile="default",
|
|
52
|
+
access_key_id=default_profile.get("access_key_id", "test"),
|
|
53
|
+
secret_access_key=default_profile.get("secret_access_key", "test"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_credentials_block(entries: Iterable[ProfileEntry]) -> str:
|
|
58
|
+
"""The managed region of the credentials file, markers included."""
|
|
59
|
+
lines = [BEGIN_MARKER]
|
|
60
|
+
for entry in entries:
|
|
61
|
+
lines.append("")
|
|
62
|
+
lines.append(f"[{entry.profile}]")
|
|
63
|
+
lines.append(f"aws_access_key_id = {entry.access_key_id}")
|
|
64
|
+
lines.append(f"aws_secret_access_key = {entry.secret_access_key}")
|
|
65
|
+
if entry.session_token:
|
|
66
|
+
lines.append(f"aws_session_token = {entry.session_token}")
|
|
67
|
+
lines.append("")
|
|
68
|
+
lines.append(END_MARKER)
|
|
69
|
+
return "\n".join(lines) + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_config_block(entries: Iterable[ProfileEntry]) -> str:
|
|
73
|
+
"""The managed region of the AWS config file, markers included."""
|
|
74
|
+
lines = [BEGIN_MARKER]
|
|
75
|
+
for entry in entries:
|
|
76
|
+
if not entry.region:
|
|
77
|
+
continue
|
|
78
|
+
lines.append("")
|
|
79
|
+
lines.append(f"[profile {entry.profile}]")
|
|
80
|
+
lines.append(f"region = {entry.region}")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append(END_MARKER)
|
|
83
|
+
return "\n".join(lines) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _locate_managed_region(text: str) -> tuple[int, int] | None:
|
|
87
|
+
"""Find the (start, end) string indices of BEGIN_MARKER and END_MARKER.
|
|
88
|
+
|
|
89
|
+
The end marker is searched for starting at the begin marker's index,
|
|
90
|
+
never from the start of the string. Otherwise human-written text that
|
|
91
|
+
happens to contain an END_MARKER-like line before the real BEGIN_MARKER
|
|
92
|
+
would be mistaken for the close of the region: `end` would land before
|
|
93
|
+
`start`, and every caller built on this - replacing the region on write,
|
|
94
|
+
or reading it back - would either treat the region as absent or slice
|
|
95
|
+
out the wrong text as the managed body.
|
|
96
|
+
|
|
97
|
+
This is the single source of truth for "where is the managed region";
|
|
98
|
+
`replace_managed_region` and `read_managed_entries` both call it so the
|
|
99
|
+
two can never drift apart on how they find it.
|
|
100
|
+
|
|
101
|
+
Returns None if no complete BEGIN_MARKER...END_MARKER pair is found.
|
|
102
|
+
"""
|
|
103
|
+
start = text.find(BEGIN_MARKER)
|
|
104
|
+
if start == -1:
|
|
105
|
+
return None
|
|
106
|
+
end = text.find(END_MARKER, start)
|
|
107
|
+
if end == -1:
|
|
108
|
+
return None
|
|
109
|
+
return start, end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def replace_managed_region(existing: str, block: str) -> str:
|
|
113
|
+
"""Swap the managed region inside existing text, or append it.
|
|
114
|
+
|
|
115
|
+
One asymmetry is intentional rather than a bug: a BEGIN_MARKER left
|
|
116
|
+
dangling without its matching END_MARKER (a human deleted only the
|
|
117
|
+
closing line) is not repaired on this call - the orphaned marker is
|
|
118
|
+
kept as part of the preserved prefix and a fresh block is appended
|
|
119
|
+
after it. On the *next* write, `_locate_managed_region` finds that
|
|
120
|
+
orphaned BEGIN_MARKER first, its search for an END_MARKER lands on the
|
|
121
|
+
newly appended block's closing line, and the two collapse into one
|
|
122
|
+
region. So a half-deleted marker pair self-heals after one further
|
|
123
|
+
write, while a stray END_MARKER-like string before any real
|
|
124
|
+
BEGIN_MARKER can no longer confuse region detection at all (see
|
|
125
|
+
`_locate_managed_region`).
|
|
126
|
+
"""
|
|
127
|
+
located = _locate_managed_region(existing)
|
|
128
|
+
if located is None:
|
|
129
|
+
prefix = existing
|
|
130
|
+
if prefix and not prefix.endswith("\n"):
|
|
131
|
+
prefix += "\n"
|
|
132
|
+
if prefix:
|
|
133
|
+
prefix += "\n"
|
|
134
|
+
return prefix + block
|
|
135
|
+
start, end = located
|
|
136
|
+
tail = existing[end + len(END_MARKER) :]
|
|
137
|
+
tail = tail.lstrip("\n")
|
|
138
|
+
if tail:
|
|
139
|
+
tail = "\n" + tail
|
|
140
|
+
return existing[:start] + block + tail
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def has_unmanaged_default(existing: str) -> bool:
|
|
144
|
+
"""Whether a `[default]` section sits outside the managed region.
|
|
145
|
+
|
|
146
|
+
Only the text a human owns is searched: the managed region is cut out
|
|
147
|
+
first, because credux writes its own `[default]` in there on every write
|
|
148
|
+
and finding that one would report a collision with itself.
|
|
149
|
+
|
|
150
|
+
A file holding two `[default]` sections is not an error credux can fix
|
|
151
|
+
for the user - which set of keys an AWS SDK ends up using is decided by
|
|
152
|
+
that SDK's parser, not by credux - so this only ever feeds a warning.
|
|
153
|
+
"""
|
|
154
|
+
located = _locate_managed_region(existing)
|
|
155
|
+
if located is None:
|
|
156
|
+
outside = existing
|
|
157
|
+
else:
|
|
158
|
+
start, end = located
|
|
159
|
+
outside = existing[:start] + existing[end + len(END_MARKER) :]
|
|
160
|
+
return any(line.strip() == "[default]" for line in outside.splitlines())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _write(path: Path, block: str) -> tuple[str, Path | None]:
|
|
164
|
+
"""Replace the managed region. Returns the prior text and any backup.
|
|
165
|
+
|
|
166
|
+
The backup captures the file as it was *before credux ever touched it*,
|
|
167
|
+
so it is taken only when the existing text carries no managed region.
|
|
168
|
+
Backing up a file that already has one would snapshot credux's own
|
|
169
|
+
output - live session tokens included - and, since `backup_once` skips
|
|
170
|
+
an existing backup, a user who deleted the real snapshot would silently
|
|
171
|
+
get a credux-written file in its place under the same name.
|
|
172
|
+
"""
|
|
173
|
+
path = Path(path)
|
|
174
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
175
|
+
backup = (
|
|
176
|
+
backup_once(path) if existing and _locate_managed_region(existing) is None else None
|
|
177
|
+
)
|
|
178
|
+
atomic_write_text(path, replace_managed_region(existing, block))
|
|
179
|
+
return existing, backup
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def write_credentials(
|
|
183
|
+
path: Path, entries: list[ProfileEntry], default_profile: dict | None
|
|
184
|
+
) -> WriteReport:
|
|
185
|
+
"""Rewrite the managed region with the given active sessions."""
|
|
186
|
+
ordered: list[ProfileEntry] = []
|
|
187
|
+
fake_default = _default_entry(default_profile)
|
|
188
|
+
if fake_default is not None:
|
|
189
|
+
ordered.append(fake_default)
|
|
190
|
+
ordered.extend(entry for entry in entries if entry.profile != "default")
|
|
191
|
+
existing, backup = _write(path, render_credentials_block(ordered))
|
|
192
|
+
# A handwritten `[default]` only collides with something if credux is
|
|
193
|
+
# actually writing a `default` of its own; with the fake default turned
|
|
194
|
+
# off, the user's is simply the only one in the file.
|
|
195
|
+
shadowed = fake_default is not None and has_unmanaged_default(existing)
|
|
196
|
+
return WriteReport(backup=backup, shadowed_default=shadowed)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_config(path: Path, entries: list[ProfileEntry]) -> WriteReport:
|
|
200
|
+
"""Rewrite the managed region of the AWS config file."""
|
|
201
|
+
filtered = [entry for entry in entries if entry.profile != "default"]
|
|
202
|
+
_, backup = _write(path, render_config_block(filtered))
|
|
203
|
+
return WriteReport(backup=backup)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _malformed_block_message(path: Path, exc: configparser.Error) -> str:
|
|
207
|
+
"""Describe a parse failure without quoting any of the file's content.
|
|
208
|
+
|
|
209
|
+
configparser's own message text is not safe to interpolate here: its
|
|
210
|
+
parse errors embed the offending source line verbatim, and in this file
|
|
211
|
+
every line is either a profile header or a key line carrying real
|
|
212
|
+
credential material, so `{exc}` reliably leaks a secret into an error
|
|
213
|
+
message that `CreduxGroup.invoke` echoes to stderr. Only the *line number*
|
|
214
|
+
is reported, which is enough to find the problem and carries no content.
|
|
215
|
+
"""
|
|
216
|
+
lineno = getattr(exc, "lineno", None)
|
|
217
|
+
where = f", line {lineno} of the block" if isinstance(lineno, int) else ""
|
|
218
|
+
return (
|
|
219
|
+
f"{path} has a malformed managed credentials block "
|
|
220
|
+
f"({type(exc).__name__}{where}). "
|
|
221
|
+
"Credux owns everything between the credux-managed markers in "
|
|
222
|
+
"this file; delete that block (the lines between "
|
|
223
|
+
f"{BEGIN_MARKER!r} and {END_MARKER!r}) and credux will "
|
|
224
|
+
"regenerate it on the next write."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_managed_entries(path: Path) -> list[ProfileEntry]:
|
|
229
|
+
"""Parse the managed region back into entries, skipping the fake default.
|
|
230
|
+
|
|
231
|
+
Reading what is already materialized means starting one profile does not
|
|
232
|
+
require re-resolving every other active profile.
|
|
233
|
+
|
|
234
|
+
Uses `_locate_managed_region`, the same offset-aware search
|
|
235
|
+
`replace_managed_region` uses, so a human-written line that happens to
|
|
236
|
+
look like END_MARKER before the real block cannot make this return an
|
|
237
|
+
empty list and cause a caller (like `SessionManager.start`) to rewrite
|
|
238
|
+
the file believing no profile is active yet.
|
|
239
|
+
"""
|
|
240
|
+
path = Path(path)
|
|
241
|
+
if not path.exists():
|
|
242
|
+
return []
|
|
243
|
+
text = path.read_text(encoding="utf-8")
|
|
244
|
+
located = _locate_managed_region(text)
|
|
245
|
+
if located is None:
|
|
246
|
+
return []
|
|
247
|
+
start, end = located
|
|
248
|
+
body = text[start + len(BEGIN_MARKER) : end]
|
|
249
|
+
# interpolation=None: configparser's default BasicInterpolation treats '%'
|
|
250
|
+
# as an escape, so a secret key containing one (AWS generates '+' and '/',
|
|
251
|
+
# but a user-supplied value is not constrained) raises
|
|
252
|
+
# InterpolationSyntaxError - and that exception's message quotes the
|
|
253
|
+
# offending fragment of the value. Credential material is opaque bytes to
|
|
254
|
+
# this parser and must never be interpolated.
|
|
255
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
256
|
+
entries: list[ProfileEntry] = []
|
|
257
|
+
# The section loop stays inside the try, not after it: one handler over the
|
|
258
|
+
# whole parse is what stops any configparser error from bypassing the
|
|
259
|
+
# content-free wrapper below and reaching the user with the value quoted.
|
|
260
|
+
try:
|
|
261
|
+
parser.read_string(body)
|
|
262
|
+
for name in parser.sections():
|
|
263
|
+
if name == "default":
|
|
264
|
+
continue
|
|
265
|
+
section = parser[name]
|
|
266
|
+
entries.append(
|
|
267
|
+
ProfileEntry(
|
|
268
|
+
profile=name,
|
|
269
|
+
access_key_id=section.get("aws_access_key_id", ""),
|
|
270
|
+
secret_access_key=section.get("aws_secret_access_key", ""),
|
|
271
|
+
session_token=section.get("aws_session_token") or None,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
except configparser.Error as exc:
|
|
275
|
+
raise ConfigError(_malformed_block_message(path, exc)) from exc
|
|
276
|
+
return entries
|
credux/browser.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Launching a browser session isolated per profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from .errors import UsageError
|
|
11
|
+
|
|
12
|
+
# The exact vocabularies Firefox's container API accepts. A value outside
|
|
13
|
+
# either list is rejected by the container extension, which refuses the whole
|
|
14
|
+
# link rather than falling back, so these are not suggestions.
|
|
15
|
+
CONTAINER_COLORS = (
|
|
16
|
+
"blue",
|
|
17
|
+
"turquoise",
|
|
18
|
+
"green",
|
|
19
|
+
"yellow",
|
|
20
|
+
"orange",
|
|
21
|
+
"red",
|
|
22
|
+
"pink",
|
|
23
|
+
"purple",
|
|
24
|
+
)
|
|
25
|
+
CONTAINER_ICONS = (
|
|
26
|
+
"fingerprint",
|
|
27
|
+
"briefcase",
|
|
28
|
+
"dollar",
|
|
29
|
+
"cart",
|
|
30
|
+
"circle",
|
|
31
|
+
"gift",
|
|
32
|
+
"vacation",
|
|
33
|
+
"food",
|
|
34
|
+
"fruit",
|
|
35
|
+
"pet",
|
|
36
|
+
"tree",
|
|
37
|
+
"chill",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _profile_digest(profile: str) -> bytes:
|
|
42
|
+
"""A stable digest of a profile name, for deriving its container styling.
|
|
43
|
+
|
|
44
|
+
blake2b, not the built-in hash(): hash() is salted per interpreter process
|
|
45
|
+
unless PYTHONHASHSEED is set, so it would repaint every container on every
|
|
46
|
+
run. The point of deriving these from the name at all is that an account
|
|
47
|
+
keeps the same color and icon forever, on every machine, with nothing
|
|
48
|
+
stored anywhere.
|
|
49
|
+
"""
|
|
50
|
+
return hashlib.blake2b(profile.encode("utf-8"), digest_size=8).digest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def container_color(profile: str) -> str:
|
|
54
|
+
"""The container color for a profile: derived, stable, never configured."""
|
|
55
|
+
return CONTAINER_COLORS[_profile_digest(profile)[0] % len(CONTAINER_COLORS)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def container_icon(profile: str) -> str:
|
|
59
|
+
"""The container icon for a profile. Independent of the color: a separate
|
|
60
|
+
digest byte, so two profiles sharing a color rarely also share an icon."""
|
|
61
|
+
return CONTAINER_ICONS[_profile_digest(profile)[1] % len(CONTAINER_ICONS)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def open_container(
|
|
65
|
+
profile: str,
|
|
66
|
+
url: str,
|
|
67
|
+
command: list[str],
|
|
68
|
+
runner: Callable[[list[str]], Any] | None = None,
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Render the configured browser command and run it. Returns the argv used.
|
|
71
|
+
|
|
72
|
+
Four placeholders are offered, in two pairs, because whether the value
|
|
73
|
+
needs percent-encoding depends entirely on where the command puts it:
|
|
74
|
+
|
|
75
|
+
- `{profile_encoded}` / `{url_encoded}` for a value embedded *inside
|
|
76
|
+
another URL*, which is what the default Firefox container command does
|
|
77
|
+
("ext+container:name=...&url=..."). Both values can legitimately
|
|
78
|
+
contain '+', '=' or '&', which would otherwise corrupt the container
|
|
79
|
+
URL's own query string.
|
|
80
|
+
- `{profile}` / `{url}` raw, for a value passed as a standalone argv
|
|
81
|
+
element, which is what every non-container browser command does
|
|
82
|
+
("google-chrome --profile-directory=... <url>"). Encoding there is
|
|
83
|
+
actively wrong: Chrome receives "https%3A%2F%2F..." and treats it as a
|
|
84
|
+
search string rather than a URL.
|
|
85
|
+
|
|
86
|
+
Two more placeholders, `{color}` and `{icon}`, carry the container styling
|
|
87
|
+
derived from the profile name (see `container_color`). They are single
|
|
88
|
+
lowercase words from a fixed vocabulary, so they need no encoded variants.
|
|
89
|
+
A command that omits them leaves the styling to the container extension,
|
|
90
|
+
which applies one fixed pair to everything it creates.
|
|
91
|
+
"""
|
|
92
|
+
if runner is None:
|
|
93
|
+
runner = subprocess.Popen
|
|
94
|
+
if not command:
|
|
95
|
+
raise UsageError(
|
|
96
|
+
"settings.browser_command is empty; set it with "
|
|
97
|
+
"'credux config set browser_command'"
|
|
98
|
+
)
|
|
99
|
+
argv = [
|
|
100
|
+
part.format(
|
|
101
|
+
profile=profile,
|
|
102
|
+
url=url,
|
|
103
|
+
profile_encoded=quote(profile, safe=""),
|
|
104
|
+
url_encoded=quote(url, safe=""),
|
|
105
|
+
color=container_color(profile),
|
|
106
|
+
icon=container_icon(profile),
|
|
107
|
+
)
|
|
108
|
+
for part in command
|
|
109
|
+
]
|
|
110
|
+
try:
|
|
111
|
+
runner(argv)
|
|
112
|
+
except FileNotFoundError as exc:
|
|
113
|
+
raise UsageError(
|
|
114
|
+
f"Could not launch {argv[0]!r}. Install it, or point "
|
|
115
|
+
"settings.browser_command at the browser you use."
|
|
116
|
+
) from exc
|
|
117
|
+
return argv
|