mkctl 0.4.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.
mkctl/oauth.py ADDED
@@ -0,0 +1,398 @@
1
+ """The two flows a native application is allowed to use, and nothing else.
2
+
3
+ **Authorization code with PKCE over a loopback redirect** (RFC 8252 §7.3, RFC 7636)
4
+ is the default. It is what the OAuth native-app BCP asks for when the machine has a
5
+ browser: the client holds no secret, and the `code_verifier` binds the code to the
6
+ process that asked for it, so a code intercepted on its way back is useless.
7
+
8
+ **The device authorization grant** (RFC 8628) is the fallback, for the case the
9
+ loopback flow cannot serve: a terminal over SSH, a container, a machine with no
10
+ browser. The human authorizes on a device that does have one.
11
+
12
+ PKCE applies to the first and not to the second, and that is not an omission. PKCE
13
+ protects a redirect from interception; the device grant has no redirect and no code
14
+ travelling back through the user agent — the `device_code` goes straight to the
15
+ polling client over TLS. There is no `code_challenge` parameter in a device
16
+ authorization request. See ADR 0004 Amendment 2.
17
+
18
+ Neither flow uses a client secret. A secret shipped inside a CLI is a public value
19
+ with a misleading name (RFC 8252 §8.5).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import http.server
27
+ import secrets
28
+ import socketserver
29
+ import threading
30
+ import time
31
+ import urllib.parse
32
+ import webbrowser
33
+ from collections.abc import Callable
34
+ from dataclasses import dataclass
35
+ from typing import Any
36
+
37
+ import httpx
38
+
39
+ DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
40
+
41
+ # RFC 8628 §3.5. `slow_down` means add at least five seconds to the interval.
42
+ SLOW_DOWN_INCREMENT = 5
43
+ DEFAULT_INTERVAL = 5
44
+
45
+
46
+ class AuthorizationError(Exception):
47
+ """The human did not authorize, or the provider refused."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Tokens:
52
+ access_token: str
53
+ refresh_token: str | None
54
+ expires_at: float
55
+ scope: str | None = None
56
+
57
+ @property
58
+ def expired(self) -> bool:
59
+ # A minute of slack: a token that expires mid-request is a failure the user
60
+ # sees, and refreshing slightly early costs nothing.
61
+ return time.time() >= self.expires_at - 60
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Endpoints:
66
+ """Read from the provider's discovery document rather than configured.
67
+
68
+ Hard-coding paths means an IdP change is a code change, which is the coupling
69
+ ADR 0004 spent an adapter to avoid.
70
+ """
71
+
72
+ issuer: str
73
+ authorization: str
74
+ token: str
75
+ device_authorization: str | None
76
+ revocation: str | None
77
+
78
+ @classmethod
79
+ def discover(cls, issuer: str, *, client: httpx.Client | None = None) -> Endpoints:
80
+ http_client = client or httpx.Client(timeout=10)
81
+ document = (
82
+ http_client.get(f"{issuer.rstrip('/')}/.well-known/openid-configuration")
83
+ .raise_for_status()
84
+ .json()
85
+ )
86
+ return cls(
87
+ issuer=document["issuer"],
88
+ authorization=document["authorization_endpoint"],
89
+ token=document["token_endpoint"],
90
+ device_authorization=document.get("device_authorization_endpoint"),
91
+ revocation=document.get("revocation_endpoint"),
92
+ )
93
+
94
+
95
+ def _pkce() -> tuple[str, str]:
96
+ """A verifier and its S256 challenge (RFC 7636 sections 4.1 and 4.2)."""
97
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
98
+ challenge = (
99
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
100
+ )
101
+ return verifier, challenge
102
+
103
+
104
+ def _tokens_from(payload: dict[str, Any]) -> Tokens:
105
+ return Tokens(
106
+ access_token=payload["access_token"],
107
+ refresh_token=payload.get("refresh_token"),
108
+ expires_at=time.time() + int(payload.get("expires_in", 300)),
109
+ scope=payload.get("scope"),
110
+ )
111
+
112
+
113
+ class _AuthorizationCallbackServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
114
+ """Single-use loopback endpoint for the OAuth authorization response (RFC 8252 §7.3).
115
+
116
+ Exactly one path is registered as `redirect_uri` (`/callback`). The authorization
117
+ response is the query on that path (`code` + `state`, or `error`). The server records
118
+ the first valid response and signals completion; the listen socket is closed by the
119
+ waiting client after that. Non-callback requests are not an authorization response
120
+ and must not be treated as one (or as a reason to discard one already received).
121
+ """
122
+
123
+ daemon_threads = True
124
+ allow_reuse_address = True
125
+
126
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
127
+ super().__init__(*args, **kwargs)
128
+ self._lock = threading.Lock()
129
+ self.authorization_response: dict[str, str] | None = None
130
+ self.completed = threading.Event()
131
+
132
+ def accept_authorization_response(self, params: dict[str, str]) -> bool:
133
+ """Store the first authorization response. Returns whether this call stored it."""
134
+ with self._lock:
135
+ if self.authorization_response is not None:
136
+ return False
137
+ self.authorization_response = params
138
+ self.completed.set()
139
+ return True
140
+
141
+
142
+ class _AuthorizationCallbackHandler(http.server.BaseHTTPRequestHandler):
143
+ """HTTP handler for `redirect_uri` only."""
144
+
145
+ server: _AuthorizationCallbackServer
146
+
147
+ def do_GET(self) -> None: # http.server's interface, not our naming
148
+ parsed = urllib.parse.urlparse(self.path)
149
+ if parsed.path != "/callback":
150
+ self.send_error(404, "Not Found")
151
+ return
152
+
153
+ params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
154
+ if "code" not in params and "error" not in params:
155
+ self.send_error(400, "Expected an OAuth authorization response")
156
+ return
157
+
158
+ self.server.accept_authorization_response(params)
159
+ body = b"<html><body><p>Signed in. You can close this tab.</p></body></html>"
160
+ self.send_response(200)
161
+ self.send_header("content-type", "text/html; charset=utf-8")
162
+ self.send_header("content-length", str(len(body)))
163
+ self.end_headers()
164
+ self.wfile.write(body)
165
+
166
+ def log_message(self, *args: object) -> None:
167
+ return
168
+
169
+
170
+ def login_loopback(
171
+ endpoints: Endpoints,
172
+ client_id: str,
173
+ *,
174
+ scope: str = "openid profile email offline_access",
175
+ open_browser: bool = True,
176
+ timeout: float = 300,
177
+ client: httpx.Client | None = None,
178
+ ) -> Tokens:
179
+ """Authorization code with PKCE, redirected to a loopback address.
180
+
181
+ The listener binds to 127.0.0.1 on a port the OS picks (RFC 8252 §7.3 requires
182
+ the loopback interface and requires the provider to accept any port). `state` is
183
+ checked because a redirect is a request anything on this machine could make.
184
+ """
185
+ verifier, challenge = _pkce()
186
+ state = secrets.token_urlsafe(16)
187
+
188
+ server = _AuthorizationCallbackServer(("127.0.0.1", 0), _AuthorizationCallbackHandler)
189
+ redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
190
+
191
+ query = urllib.parse.urlencode(
192
+ {
193
+ "client_id": client_id,
194
+ "response_type": "code",
195
+ "redirect_uri": redirect_uri,
196
+ "scope": scope,
197
+ "state": state,
198
+ "code_challenge": challenge,
199
+ "code_challenge_method": "S256",
200
+ }
201
+ )
202
+ url = f"{endpoints.authorization}?{query}"
203
+
204
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
205
+ thread.start()
206
+ try:
207
+ if open_browser:
208
+ webbrowser.open(url)
209
+ print(f"Opening {url}")
210
+ print(f"Waiting for authorization redirect to {redirect_uri}")
211
+ if not server.completed.wait(timeout=timeout):
212
+ raise AuthorizationError("timed out waiting for the browser to come back")
213
+ finally:
214
+ # Always free the port — including when the human hits Ctrl+C mid-wait.
215
+ server.shutdown()
216
+ server.server_close()
217
+
218
+ result = server.authorization_response
219
+ if not result:
220
+ raise AuthorizationError("timed out waiting for the browser to come back")
221
+ if "error" in result:
222
+ raise AuthorizationError(result.get("error_description") or result["error"])
223
+ if result.get("state") != state:
224
+ # Not a formality: without it any page on this machine could hand us a code.
225
+ raise AuthorizationError("state did not match the request")
226
+ if not result.get("code"):
227
+ raise AuthorizationError("authorization response carried no code")
228
+
229
+ print("Exchanging authorization code for tokens")
230
+ http_client = client or httpx.Client(timeout=30)
231
+ response = http_client.post(
232
+ endpoints.token,
233
+ data={
234
+ "grant_type": "authorization_code",
235
+ "code": result["code"],
236
+ "redirect_uri": redirect_uri,
237
+ "client_id": client_id,
238
+ "code_verifier": verifier,
239
+ },
240
+ )
241
+ if response.status_code != 200:
242
+ raise AuthorizationError(_describe(response))
243
+ return _tokens_from(response.json())
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class DeviceChallenge:
248
+ """What the human has to be shown, and how long they have."""
249
+
250
+ user_code: str
251
+ verification_uri: str
252
+ verification_uri_complete: str | None
253
+ expires_in: int
254
+
255
+
256
+ def start_device(
257
+ endpoints: Endpoints,
258
+ client_id: str,
259
+ *,
260
+ scope: str = "openid profile email offline_access",
261
+ client: httpx.Client | None = None,
262
+ ) -> tuple[str, DeviceChallenge, int]:
263
+ if not endpoints.device_authorization:
264
+ raise AuthorizationError(
265
+ "this provider publishes no device_authorization_endpoint; use the default browser flow"
266
+ )
267
+ http_client = client or httpx.Client(timeout=30)
268
+ response = http_client.post(
269
+ endpoints.device_authorization, data={"client_id": client_id, "scope": scope}
270
+ )
271
+ if response.status_code != 200:
272
+ raise AuthorizationError(_describe(response))
273
+ payload = response.json()
274
+ challenge = DeviceChallenge(
275
+ user_code=payload["user_code"],
276
+ verification_uri=payload["verification_uri"],
277
+ verification_uri_complete=payload.get("verification_uri_complete"),
278
+ expires_in=int(payload.get("expires_in", 600)),
279
+ )
280
+ return payload["device_code"], challenge, int(payload.get("interval", DEFAULT_INTERVAL))
281
+
282
+
283
+ def poll_device(
284
+ endpoints: Endpoints,
285
+ client_id: str,
286
+ device_code: str,
287
+ *,
288
+ interval: int = DEFAULT_INTERVAL,
289
+ expires_in: int = 600,
290
+ client: httpx.Client | None = None,
291
+ sleep: Callable[[float], None] = time.sleep,
292
+ ) -> Tokens:
293
+ """Poll until the human decides, honouring what the provider asks for.
294
+
295
+ `slow_down` adds to the interval and is not a retry-immediately (RFC 8628 §3.5);
296
+ ignoring it is how a client gets rate-limited into failing a flow that would
297
+ otherwise have worked.
298
+ """
299
+ http_client = client or httpx.Client(timeout=30)
300
+ deadline = time.time() + expires_in
301
+
302
+ while True:
303
+ if time.time() >= deadline:
304
+ raise AuthorizationError("the device code expired before it was approved")
305
+ sleep(interval)
306
+ response = http_client.post(
307
+ endpoints.token,
308
+ data={"grant_type": DEVICE_GRANT, "device_code": device_code, "client_id": client_id},
309
+ )
310
+ if response.status_code == 200:
311
+ return _tokens_from(response.json())
312
+
313
+ error = _error_of(response)
314
+ if error == "authorization_pending":
315
+ continue
316
+ if error == "slow_down":
317
+ interval += SLOW_DOWN_INCREMENT
318
+ continue
319
+ if error == "access_denied":
320
+ raise AuthorizationError("the request was denied")
321
+ if error == "expired_token":
322
+ raise AuthorizationError("the device code expired before it was approved")
323
+ raise AuthorizationError(_describe(response))
324
+
325
+
326
+ def refresh(
327
+ endpoints: Endpoints,
328
+ client_id: str,
329
+ refresh_token: str,
330
+ *,
331
+ client: httpx.Client | None = None,
332
+ ) -> Tokens:
333
+ """Exchange a refresh token, and expect a new one back.
334
+
335
+ Rotation is the provider's job; ours is to store what comes back rather than
336
+ keeping the token we sent. A client that keeps the old one turns the provider's
337
+ single-use enforcement into a login loop the day it is switched on.
338
+ """
339
+ http_client = client or httpx.Client(timeout=30)
340
+ response = http_client.post(
341
+ endpoints.token,
342
+ data={
343
+ "grant_type": "refresh_token",
344
+ "refresh_token": refresh_token,
345
+ "client_id": client_id,
346
+ },
347
+ )
348
+ if response.status_code != 200:
349
+ raise AuthorizationError(_describe(response))
350
+ payload = response.json()
351
+ tokens = _tokens_from(payload)
352
+ if tokens.refresh_token is None:
353
+ # The provider rotated nothing; keep what still works rather than losing it.
354
+ tokens = Tokens(
355
+ access_token=tokens.access_token,
356
+ refresh_token=refresh_token,
357
+ expires_at=tokens.expires_at,
358
+ scope=tokens.scope,
359
+ )
360
+ return tokens
361
+
362
+
363
+ def revoke(
364
+ endpoints: Endpoints,
365
+ client_id: str,
366
+ token: str,
367
+ *,
368
+ client: httpx.Client | None = None,
369
+ ) -> bool:
370
+ """Tell the provider to forget this device's token.
371
+
372
+ "Revocable per device" (ADR 0004) is only true if logging out actually revokes.
373
+ Deleting the local copy would leave a live refresh token behind on a laptop
374
+ somebody just handed back.
375
+ """
376
+ if not endpoints.revocation:
377
+ return False
378
+ http_client = client or httpx.Client(timeout=30)
379
+ response = http_client.post(
380
+ endpoints.revocation,
381
+ data={"token": token, "token_type_hint": "refresh_token", "client_id": client_id},
382
+ )
383
+ return response.status_code in (200, 204)
384
+
385
+
386
+ def _error_of(response: httpx.Response) -> str | None:
387
+ try:
388
+ return response.json().get("error")
389
+ except ValueError:
390
+ return None
391
+
392
+
393
+ def _describe(response: httpx.Response) -> str:
394
+ try:
395
+ payload = response.json()
396
+ return payload.get("error_description") or payload.get("error") or response.text
397
+ except ValueError:
398
+ return f"HTTP {response.status_code}"
mkctl/py.typed ADDED
File without changes
mkctl/session.py ADDED
@@ -0,0 +1,104 @@
1
+ """Holding a login: acquiring, refreshing, and giving it up.
2
+
3
+ The one rule worth stating: whatever comes back from a refresh is what gets stored.
4
+ Providers rotate refresh tokens and enforce single use, so a client that keeps the
5
+ token it just sent turns that enforcement into a login loop on the day it is switched
6
+ on — and the failure looks like the provider misbehaving.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import httpx
14
+
15
+ from . import oauth
16
+ from .oauth import AuthorizationError, Endpoints, Tokens
17
+ from .store import TokenStore
18
+
19
+
20
+ @dataclass
21
+ class Session:
22
+ control_plane: str
23
+ issuer: str
24
+ client_id: str
25
+ store: TokenStore
26
+ client: httpx.Client | None = None
27
+
28
+ def endpoints(self) -> Endpoints:
29
+ return Endpoints.discover(self.issuer, client=self.client)
30
+
31
+ def login(self, *, device: bool = False, open_browser: bool = True) -> Tokens:
32
+ """Acquire a login and store it.
33
+
34
+ The browser flow is the default because it is what the native-app BCP asks
35
+ for when a browser is present. `device=True` is for when it is not, which is
36
+ every terminal reached over SSH.
37
+ """
38
+ endpoints = self.endpoints()
39
+ if device:
40
+ tokens = self._device_login(endpoints)
41
+ else:
42
+ tokens = oauth.login_loopback(
43
+ endpoints,
44
+ self.client_id,
45
+ open_browser=open_browser,
46
+ client=self.client,
47
+ )
48
+ self.store.save(tokens)
49
+ return tokens
50
+
51
+ def _device_login(self, endpoints: Endpoints) -> Tokens:
52
+ device_code, challenge, interval = oauth.start_device(
53
+ endpoints, self.client_id, client=self.client
54
+ )
55
+ target = challenge.verification_uri_complete or challenge.verification_uri
56
+ print(f"Open {target}")
57
+ if not challenge.verification_uri_complete:
58
+ print(f"and enter the code: {challenge.user_code}")
59
+ return oauth.poll_device(
60
+ endpoints,
61
+ self.client_id,
62
+ device_code,
63
+ interval=interval,
64
+ expires_in=challenge.expires_in,
65
+ client=self.client,
66
+ )
67
+
68
+ def access_token(self) -> str:
69
+ """A usable access token, refreshed if it is due.
70
+
71
+ Raises rather than silently returning nothing: a caller that gets `None` here
72
+ writes `Authorization: Bearer None`, and the server's answer to that is the
73
+ same 401 as a real credential failure — a confusing way to learn you are
74
+ logged out.
75
+ """
76
+ tokens = self.store.load()
77
+ if tokens is None:
78
+ raise AuthorizationError("not logged in — run `mkctl login`")
79
+ if not tokens.expired:
80
+ return tokens.access_token
81
+ if not tokens.refresh_token:
82
+ raise AuthorizationError("the session expired and cannot be refreshed — log in again")
83
+
84
+ refreshed = oauth.refresh(
85
+ self.endpoints(), self.client_id, tokens.refresh_token, client=self.client
86
+ )
87
+ self.store.save(refreshed)
88
+ return refreshed.access_token
89
+
90
+ def logout(self) -> bool:
91
+ """Revoke at the provider, then forget locally.
92
+
93
+ In that order, and both. Deleting only the local copy leaves a live refresh
94
+ token on a machine somebody may have just handed back, which is the opposite
95
+ of "revocable per device".
96
+ """
97
+ tokens = self.store.load()
98
+ revoked = False
99
+ if tokens and tokens.refresh_token:
100
+ revoked = oauth.revoke(
101
+ self.endpoints(), self.client_id, tokens.refresh_token, client=self.client
102
+ )
103
+ self.store.clear()
104
+ return revoked
mkctl/store.py ADDED
@@ -0,0 +1,90 @@
1
+ """Where the tokens live.
2
+
3
+ > Tokens in the OS keyring — ADR 0004
4
+
5
+ So: the OS keyring, and no second place. If there is no secure backend, this raises
6
+ and says what to do about it rather than quietly writing a file. A CLI that degrades
7
+ to plaintext when the keyring is missing is `nate`'s `TOKEN_ENCRYPTION_KEY` again —
8
+ the safe path must not be the one that happens to be configured.
9
+
10
+ One entry per control plane, keyed by its URL, so a laptop that talks to production
11
+ and to a local stack does not have one token overwrite the other.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass
18
+ from typing import Protocol
19
+
20
+ from .oauth import Tokens
21
+
22
+ SERVICE = "mkctl"
23
+
24
+
25
+ class KeyringBackend(Protocol):
26
+ # Positional-only, because the real keyring backend decorates `set_password`
27
+ # into a wrapper whose parameter names are not the interface's.
28
+ def get_password(self, service: str, username: str, /) -> str | None: ...
29
+ def set_password(self, service: str, username: str, password: str, /) -> None: ...
30
+ def delete_password(self, service: str, username: str, /) -> None: ...
31
+
32
+
33
+ class StoreUnavailable(Exception):
34
+ """There is no secure place to put a token on this machine."""
35
+
36
+
37
+ @dataclass
38
+ class TokenStore:
39
+ control_plane: str
40
+ backend: KeyringBackend | None = None
41
+
42
+ def _keyring(self) -> KeyringBackend:
43
+ if self.backend is not None:
44
+ return self.backend
45
+ import keyring
46
+ from keyring.backends.fail import Keyring as FailKeyring
47
+
48
+ backend = keyring.get_keyring()
49
+ if isinstance(backend, FailKeyring):
50
+ raise StoreUnavailable(
51
+ "no OS keyring is available on this machine, and mkctl does not write "
52
+ "tokens to disk (ADR 0004). Install and start a Secret Service provider "
53
+ "— gnome-keyring or kwallet on Linux — and try again"
54
+ )
55
+ return backend
56
+
57
+ def load(self) -> Tokens | None:
58
+ raw = self._keyring().get_password(SERVICE, self.control_plane)
59
+ if not raw:
60
+ return None
61
+ payload = json.loads(raw)
62
+ return Tokens(
63
+ access_token=payload["access_token"],
64
+ refresh_token=payload.get("refresh_token"),
65
+ expires_at=payload["expires_at"],
66
+ scope=payload.get("scope"),
67
+ )
68
+
69
+ def save(self, tokens: Tokens) -> None:
70
+ self._keyring().set_password(
71
+ SERVICE,
72
+ self.control_plane,
73
+ json.dumps(
74
+ {
75
+ "access_token": tokens.access_token,
76
+ "refresh_token": tokens.refresh_token,
77
+ "expires_at": tokens.expires_at,
78
+ "scope": tokens.scope,
79
+ }
80
+ ),
81
+ )
82
+
83
+ def clear(self) -> None:
84
+ # Broad on purpose: backends raise different things for "no such entry", and
85
+ # for a logout that is success. The only outcome that matters is that nothing
86
+ # is left behind.
87
+ try:
88
+ self._keyring().delete_password(SERVICE, self.control_plane)
89
+ except Exception:
90
+ return
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkctl
3
+ Version: 0.4.0
4
+ Summary: The command line for the control plane (ADR 0004)
5
+ License-Expression: Apache-2.0
6
+ License-File: LICENSE
7
+ Requires-Python: <3.14,>=3.11
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: keyring>=25.0
10
+ Requires-Dist: mkp-contracts>=0.2.0
@@ -0,0 +1,14 @@
1
+ mkctl/__init__.py,sha256=NJPyKC8AaLjOktbJ-9kQgXY8u8xKve4fUscqEyHVONo,305
2
+ mkctl/cli.py,sha256=5rjpDuSP1KAwYIt35nf2ZXKIXJWa4ITRAu0DVID3Zts,20212
3
+ mkctl/client.py,sha256=uCsfjRK9p3tbtoSmvowC7iIlSEuj0NYpxshUqZ1nErI,5219
4
+ mkctl/commands.py,sha256=RjFcBootuUzL4iNvJ2_f7pZGs5pGZMpmMlfg7m44CY4,21040
5
+ mkctl/config.py,sha256=84wZRwn9w2In_zFtPytyPy3p_mDUYjyxo4qgjRsiQRQ,2251
6
+ mkctl/oauth.py,sha256=CTnsaTp0HOrLZ-kiomzJqcMZDlNK4st_On1K-7bj9bY,14078
7
+ mkctl/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ mkctl/session.py,sha256=J4sj2T1nNw_0DDK5HMuo8-KUYb5YZpxz5oArIJsFnTk,3724
9
+ mkctl/store.py,sha256=Gsrw4nrI3cCKpv3ylluY-YyQUMIN6bPnOr-i5Yc67nY,3138
10
+ mkctl-0.4.0.dist-info/METADATA,sha256=qUB3k2iRzHGilcGyOGeo2AsEPQcGJyTV733pyMZ3K-8,283
11
+ mkctl-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ mkctl-0.4.0.dist-info/entry_points.txt,sha256=6eZOnaohy6orNkfGb_1ZjQyrOkZjJ4Y-pfYkX3yNjv4,41
13
+ mkctl-0.4.0.dist-info/licenses/LICENSE,sha256=OomESGL67ZXotKqNbTv84ga2xAp3zho0QQH_kjVDTxQ,11338
14
+ mkctl-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mkctl = mkctl.cli:main