tripwire-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tripwire-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Command-line client for Tripwire canaries
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: click>=8.1
@@ -53,7 +53,11 @@ plain-text messages go to stderr, so stdout stays clean JSON. Run
53
53
  `tripwire --help` for the full reference.
54
54
 
55
55
  Supported create types are `dns_label`, `aws_access_key`, `anthropic_api_key`,
56
- and `github_pat`.
56
+ `github_pat`, `web_login_credential`, `browser_session_cookie`,
57
+ `postgres_login`, and `kubernetes_kubeconfig`. The request-path types
58
+ (`web_login_credential`, `browser_session_cookie`, `postgres_login`,
59
+ `kubernetes_kubeconfig`) inline their artifact fields directly in the create
60
+ response.
57
61
 
58
62
  `canaries create` accepts `--timeout <seconds>` (env `TRIPWIRE_CREATE_TIMEOUT`,
59
63
  default 240) for the per-request read timeout; it must stay above the server's
@@ -44,7 +44,11 @@ plain-text messages go to stderr, so stdout stays clean JSON. Run
44
44
  `tripwire --help` for the full reference.
45
45
 
46
46
  Supported create types are `dns_label`, `aws_access_key`, `anthropic_api_key`,
47
- and `github_pat`.
47
+ `github_pat`, `web_login_credential`, `browser_session_cookie`,
48
+ `postgres_login`, and `kubernetes_kubeconfig`. The request-path types
49
+ (`web_login_credential`, `browser_session_cookie`, `postgres_login`,
50
+ `kubernetes_kubeconfig`) inline their artifact fields directly in the create
51
+ response.
48
52
 
49
53
  `canaries create` accepts `--timeout <seconds>` (env `TRIPWIRE_CREATE_TIMEOUT`,
50
54
  default 240) for the per-request read timeout; it must stay above the server's
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tripwire-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Command-line client for Tripwire canaries"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -14,6 +14,7 @@ from tripwire_cli.cli import (
14
14
  build_create_payload,
15
15
  cli,
16
16
  resolve_login_server,
17
+ _unauthorized_message,
17
18
  )
18
19
  from tripwire_cli.client import ApiError
19
20
 
@@ -170,15 +171,18 @@ def test_build_create_payload_matches_api_contract():
170
171
  assert build_create_payload(canary_type="dns_label") == {"type": "dns_label"}
171
172
 
172
173
 
173
- def test_canary_types_is_the_four_public_types():
174
- # The four request-path types (web_login_credential, browser_session_cookie,
175
- # postgres_login, kubernetes_kubeconfig) are held private; the CLI must not
176
- # surface them.
174
+ def test_canary_types_are_the_eight_api_types():
175
+ # All eight types POST /canary accepts: the four provider/dns types plus the
176
+ # four request-path types whose artifact fields are inlined in the response.
177
177
  assert CANARY_TYPES == [
178
178
  "dns_label",
179
179
  "aws_access_key",
180
180
  "anthropic_api_key",
181
181
  "github_pat",
182
+ "web_login_credential",
183
+ "browser_session_cookie",
184
+ "postgres_login",
185
+ "kubernetes_kubeconfig",
182
186
  ]
183
187
 
184
188
 
@@ -201,10 +205,81 @@ def test_readme_lists_every_supported_create_type(path: Path):
201
205
  "kubernetes_kubeconfig",
202
206
  ],
203
207
  )
204
- def test_create_rejects_held_private_request_path_types(canary_type: str):
205
- # These are not part of the public --type surface yet.
206
- result = CliRunner().invoke(cli, ["canaries", "create", "--type", canary_type])
207
- assert result.exit_code != 0
208
+ def test_create_accepts_request_path_types(tmp_path, canary_type: str):
209
+ # The four request-path types are now part of the public --type surface and
210
+ # send the same {"type": ...} body as the provider types.
211
+ canary = {"id": "can_1", "type": canary_type, "status": "active"}
212
+ client = _FakeClient(result=canary)
213
+ result = CliRunner().invoke(
214
+ cli,
215
+ ["canaries", "create", "--type", canary_type],
216
+ obj=_logged_in_context(tmp_path, client),
217
+ )
218
+ assert result.exit_code == 0, result.output
219
+ assert client.created_payloads == [{"type": canary_type}]
220
+
221
+
222
+ @pytest.mark.parametrize(
223
+ "canary_type,inlined",
224
+ [
225
+ (
226
+ "web_login_credential",
227
+ {
228
+ "url": "https://app.example/login",
229
+ "username": "svc-backups",
230
+ "password": "s3kret",
231
+ },
232
+ ),
233
+ (
234
+ "browser_session_cookie",
235
+ {
236
+ "url": "https://app.example",
237
+ "cookie_name": "session",
238
+ "cookie_value": "abc.def",
239
+ "cookie_domain": "app.example",
240
+ "cookie_path": "/",
241
+ },
242
+ ),
243
+ (
244
+ "postgres_login",
245
+ {
246
+ "database_url": "postgres://u:p@h:5432/db?sslmode=require",
247
+ "host": "h",
248
+ "port": 5432,
249
+ "database": "db",
250
+ "username": "u",
251
+ "password": "p",
252
+ "sslmode": "require",
253
+ "url": "postgres://h:5432",
254
+ },
255
+ ),
256
+ (
257
+ "kubernetes_kubeconfig",
258
+ {
259
+ "kubeconfig": "apiVersion: v1\nkind: Config\n",
260
+ "server": "https://k8s.example:6443",
261
+ "cluster_name": "prod",
262
+ "user_name": "deployer",
263
+ "bearer_token": "eyJ.tok",
264
+ "token": "eyJ.tok",
265
+ },
266
+ ),
267
+ ],
268
+ )
269
+ def test_create_prints_request_path_inlined_fields_verbatim(
270
+ tmp_path, canary_type: str, inlined: dict
271
+ ):
272
+ # The create response inlines each type's artifact fields; the CLI prints
273
+ # the server JSON unchanged and must not filter any field out.
274
+ canary = {"id": "can_1", "type": canary_type, "status": "active", **inlined}
275
+ client = _FakeClient(result=canary)
276
+ result = CliRunner().invoke(
277
+ cli,
278
+ ["canaries", "create", "--type", canary_type],
279
+ obj=_logged_in_context(tmp_path, client),
280
+ )
281
+ assert result.exit_code == 0, result.output
282
+ assert json.loads(result.stdout) == canary
208
283
 
209
284
 
210
285
  # --- argument surface -------------------------------------------------------
@@ -590,3 +665,164 @@ def test_whoami_prints_email_when_present(tmp_path):
590
665
  result = CliRunner().invoke(cli, ["whoami"], obj=ctx)
591
666
  assert result.exit_code == 0
592
667
  assert "alice@example.com" in result.output
668
+
669
+
670
+ # --- login resilience: non-interactive flags --------------------------------
671
+
672
+
673
+ class _StartErrorClient(_FakeClient):
674
+ """Email-login fake whose /auth/login/start raises a canned ApiError, so a
675
+ test can drive the rate-limit (429) path."""
676
+
677
+ def __init__(self, *, start_error: ApiError):
678
+ super().__init__(result=None)
679
+ self._start_error = start_error
680
+
681
+ def login_start(self, email):
682
+ self.login_starts.append(email)
683
+ raise self._start_error
684
+
685
+
686
+ class _CodeErrorClient(_FakeClient):
687
+ """Email-login fake whose /auth/login (code exchange) raises a canned
688
+ ApiError, so a test can drive the server-error (5xx) path. start() succeeds
689
+ so the test reaches the exchange."""
690
+
691
+ def __init__(self, *, code_error: ApiError):
692
+ super().__init__(result=None)
693
+ self._code_error = code_error
694
+
695
+ def login_with_code(self, email, code):
696
+ self.code_logins.append((email, code))
697
+ raise self._code_error
698
+
699
+
700
+ _GOOD_LOGIN = {
701
+ "user_id": "usr_alice",
702
+ "access_token": "tok",
703
+ "expires_at": 1700000000,
704
+ "role": "user",
705
+ }
706
+
707
+
708
+ def test_login_email_and_code_flags_skip_prompts_and_start(tmp_path, monkeypatch):
709
+ # Both --email and --code given: a fully non-interactive code exchange that
710
+ # does NOT call the rate-limited start and never prompts.
711
+ monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
712
+ client = _FakeClient(result=_GOOD_LOGIN)
713
+ ctx = _context(tmp_path, client)
714
+ result = CliRunner().invoke(
715
+ cli,
716
+ ["login", "--email", "ci@example.com", "--code", "123456"],
717
+ input="", # no TTY input available
718
+ obj=ctx,
719
+ )
720
+ assert result.exit_code == 0, result.output
721
+ assert client.login_starts == [] # start is rate-limited; must be skipped
722
+ assert client.code_logins == [("ci@example.com", "123456")]
723
+ loaded = ctx.store.load()
724
+ assert loaded.access_token == "tok"
725
+ assert loaded.email == "ci@example.com"
726
+
727
+
728
+ def test_login_email_flag_skips_email_prompt_only(tmp_path, monkeypatch):
729
+ # --email without --code: start is sent (no code yet) and only the code is
730
+ # prompted for.
731
+ monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
732
+ client = _FakeClient(result=_GOOD_LOGIN)
733
+ ctx = _context(tmp_path, client)
734
+ result = CliRunner().invoke(
735
+ cli, ["login", "--email", "ci@example.com"], input="123456\n", obj=ctx
736
+ )
737
+ assert result.exit_code == 0, result.output
738
+ assert client.login_starts == ["ci@example.com"]
739
+ assert client.code_logins == [("ci@example.com", "123456")]
740
+
741
+
742
+ # --- login resilience: start rate limit (429) -------------------------------
743
+
744
+
745
+ def test_login_start_rate_limit_gives_friendly_message(tmp_path, monkeypatch):
746
+ monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
747
+ client = _StartErrorClient(start_error=ApiError(429, "rate_limited"))
748
+ ctx = _context(tmp_path, client)
749
+ result = CliRunner().invoke(cli, ["login"], input="alice@example.com\n", obj=ctx)
750
+ assert result.exit_code != 0
751
+ # Friendly, actionable text rather than a raw "429: rate_limited".
752
+ assert "too many login attempts from this network" in result.output
753
+ assert "~10 minutes" in result.output
754
+ assert "429: rate_limited" not in result.output
755
+ # No token cached on a failed login.
756
+ assert not (tmp_path / "credentials.json").exists()
757
+
758
+
759
+ def test_login_start_non_429_error_is_not_masked(tmp_path, monkeypatch):
760
+ # A non-rate-limit start failure keeps the generic API-error surface.
761
+ monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
762
+ client = _StartErrorClient(start_error=ApiError(503, "upstream_down"))
763
+ ctx = _context(tmp_path, client)
764
+ result = CliRunner().invoke(cli, ["login"], input="alice@example.com\n", obj=ctx)
765
+ assert result.exit_code != 0
766
+ assert "503: upstream_down" in result.output
767
+
768
+
769
+ # --- login resilience: 5xx on the code exchange -----------------------------
770
+
771
+
772
+ @pytest.mark.parametrize("status", [500, 502, 503])
773
+ def test_login_code_exchange_5xx_tells_user_code_may_be_spent(
774
+ tmp_path, monkeypatch, status
775
+ ):
776
+ # A 5xx during the code exchange may have consumed the code server-side;
777
+ # retrying the same code is futile, so the CLI exits cleanly and tells the
778
+ # user to re-run login for a fresh code.
779
+ monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
780
+ client = _CodeErrorClient(code_error=ApiError(status, "internal_error"))
781
+ ctx = _context(tmp_path, client)
782
+ result = CliRunner().invoke(
783
+ cli, ["login"], input="alice@example.com\n123456\n", obj=ctx
784
+ )
785
+ assert result.exit_code != 0
786
+ # start() was called exactly once; the code was attempted exactly once (no
787
+ # silent retry of a possibly-spent code).
788
+ assert client.login_starts == ["alice@example.com"]
789
+ assert client.code_logins == [("alice@example.com", "123456")]
790
+ assert "may already be spent" in result.output
791
+ assert "run `tripwire login` again" in result.output
792
+ assert not (tmp_path / "credentials.json").exists()
793
+
794
+
795
+ # --- login resilience: 401 detail polish ------------------------------------
796
+
797
+
798
+ @pytest.mark.parametrize(
799
+ "raw_detail",
800
+ [
801
+ "Invalid header padding",
802
+ "Invalid token",
803
+ "Not enough segments",
804
+ "Signature verification failed",
805
+ "token expired",
806
+ "Failed to decrypt token",
807
+ ],
808
+ )
809
+ def test_unauthorized_message_maps_token_errors_to_session_expired(raw_detail):
810
+ assert _unauthorized_message(raw_detail) == "session expired; run `tripwire login`"
811
+
812
+
813
+ def test_unauthorized_message_keeps_meaningful_detail():
814
+ assert _unauthorized_message("forbidden_scope") == (
815
+ "401: forbidden_scope\nhint: run `tripwire login`"
816
+ )
817
+
818
+
819
+ def test_list_maps_opaque_401_to_session_expired(tmp_path):
820
+ # A 401 whose detail is a raw token-decode error becomes a clean
821
+ # session-expired prompt instead of leaking "Invalid header padding".
822
+ client = _FakeClient(error=ApiError(401, "Invalid header padding"))
823
+ result = CliRunner().invoke(
824
+ cli, ["canaries", "list"], obj=_logged_in_context(tmp_path, client)
825
+ )
826
+ assert result.exit_code != 0
827
+ assert "session expired; run `tripwire login`" in result.output
828
+ assert "Invalid header padding" not in result.output
@@ -50,7 +50,22 @@ def _git_user_email() -> str | None:
50
50
 
51
51
  DEFAULT_SERVER = "https://tripwire.so/api/v1"
52
52
 
53
- CANARY_TYPES = ["dns_label", "aws_access_key", "anthropic_api_key", "github_pat"]
53
+ # The canary types the API's POST /canary accepts. The provider-minted types
54
+ # (aws/anthropic/github) take ~2 min to provision; the request-path types
55
+ # (web_login_credential, browser_session_cookie, postgres_login,
56
+ # kubernetes_kubeconfig) inline their artifact fields directly in the create
57
+ # response. The CLI prints the server JSON verbatim, so new inlined fields flow
58
+ # through unchanged.
59
+ CANARY_TYPES = [
60
+ "dns_label",
61
+ "aws_access_key",
62
+ "anthropic_api_key",
63
+ "github_pat",
64
+ "web_login_credential",
65
+ "browser_session_cookie",
66
+ "postgres_login",
67
+ "kubernetes_kubeconfig",
68
+ ]
54
69
 
55
70
  # How long the create read timeout may run, in seconds. Must stay above the
56
71
  # server's synchronous create wait window (180s) so the client never abandons a
@@ -113,6 +128,29 @@ class Context:
113
128
  return None
114
129
 
115
130
 
131
+ # Substrings the server (or its JWT/Fernet token layer) leaks on a 401 when the
132
+ # cached token is malformed or undecodable. These are opaque to users, so we map
133
+ # them to a plain "session expired" message instead of echoing the raw detail.
134
+ _EXPIRED_SESSION_TOKEN_MARKERS = (
135
+ "invalid header padding",
136
+ "invalid token",
137
+ "not enough segments",
138
+ "signature",
139
+ "expired",
140
+ "decrypt",
141
+ )
142
+
143
+
144
+ def _unauthorized_message(detail: str) -> str:
145
+ """User-facing text for a 401. Raw token/header decode errors (e.g. "Invalid
146
+ header padding") are meaningless to users, so map them to a clear
147
+ session-expired prompt; otherwise keep the server detail and append a hint."""
148
+ lowered = detail.lower()
149
+ if any(marker in lowered for marker in _EXPIRED_SESSION_TOKEN_MARKERS):
150
+ return "session expired; run `tripwire login`"
151
+ return f"401: {detail}\nhint: run `tripwire login`"
152
+
153
+
116
154
  def _handle_errors(func):
117
155
  """Translate API/credential errors into a clean CLI error and exit code."""
118
156
 
@@ -121,10 +159,9 @@ def _handle_errors(func):
121
159
  try:
122
160
  return func(*args, **kwargs)
123
161
  except ApiError as exc:
124
- message = f"{exc.status}: {exc.detail}"
125
162
  if exc.status == 401:
126
- message += "\nhint: run `tripwire login`"
127
- raise click.ClickException(message) from exc
163
+ raise click.ClickException(_unauthorized_message(exc.detail)) from exc
164
+ raise click.ClickException(f"{exc.status}: {exc.detail}") from exc
128
165
  except (credentials.NoCredentialsError, ValueError) as exc:
129
166
  raise click.ClickException(str(exc)) from exc
130
167
 
@@ -148,21 +185,42 @@ def cli(ctx: click.Context) -> None:
148
185
  @click.option(
149
186
  "--password", help="operator password (selects password login; prefer the prompt)"
150
187
  )
188
+ @click.option(
189
+ "--email",
190
+ help="email for passwordless login (skips the prompt; for headless/CI use)",
191
+ )
192
+ @click.option(
193
+ "--code",
194
+ help=(
195
+ "6-digit code for passwordless login (skips the code prompt; pair with "
196
+ "--email for a non-interactive exchange of an already-sent code)"
197
+ ),
198
+ )
151
199
  @click.pass_obj
152
200
  @_handle_errors
153
- def login(obj: Context, user_id: str | None, password: str | None) -> None:
201
+ def login(
202
+ obj: Context,
203
+ user_id: str | None,
204
+ password: str | None,
205
+ email: str | None,
206
+ code: str | None,
207
+ ) -> None:
154
208
  """Log in and cache a token.
155
209
 
156
210
  Defaults to passwordless email-code login. Passing --user-id or --password
157
211
  selects the operator (user-id + password) login instead, so operator
158
212
  scripts keep working unchanged.
213
+
214
+ For headless/CI use, pass --email (and optionally --code) to skip the
215
+ interactive prompts. With both --email and --code, a code that was already
216
+ emailed is exchanged directly without re-sending one or prompting.
159
217
  """
160
218
  server = resolve_login_server(dict(os.environ), obj.cached_server())
161
219
  client = obj.client(server)
162
220
  if user_id is not None or password is not None:
163
221
  creds = _password_login(client, server, user_id, password)
164
222
  else:
165
- creds = _email_login(client, server, obj.git_email())
223
+ creds = _email_login(client, server, obj.git_email(), email=email, code=code)
166
224
  path = obj.store.save(creds)
167
225
  click.echo(f"logged in as {creds.user_id} ({creds.role}); token cached at {path}")
168
226
 
@@ -178,19 +236,40 @@ def _password_login(
178
236
 
179
237
 
180
238
  def _email_login(
181
- client: ApiClient, server: str, default_email: str | None
239
+ client: ApiClient,
240
+ server: str,
241
+ default_email: str | None,
242
+ *,
243
+ email: str | None = None,
244
+ code: str | None = None,
182
245
  ) -> credentials.Credentials:
183
- """Passwordless email-code login. Calls /auth/login/start once (it is
184
- rate-limited), then re-prompts for the 6-digit code in-band on an
185
- invalid/expired code without re-calling start."""
186
- email = click.prompt("email", default=default_email)
187
- client.login_start(email)
246
+ """Passwordless email-code login.
247
+
248
+ Interactive path (no ``code``): calls /auth/login/start once (it is
249
+ rate-limited), then prompts for the 6-digit code, re-prompting in-band on an
250
+ invalid/expired code without re-calling start.
251
+
252
+ Non-interactive path (``code`` supplied): exchanges that code directly and
253
+ does NOT call /auth/login/start, since the code was already emailed and
254
+ re-sending would burn the rate-limited start and invalidate the held code.
255
+
256
+ ``email`` (e.g. from ``--email``) skips the email prompt for headless/CI use.
257
+ """
258
+ email = email or click.prompt("email", default=default_email)
259
+
260
+ if code is not None:
261
+ # Headless: a code was supplied, so do not (re)send one. Exchange it
262
+ # directly; a single attempt, no prompt loop.
263
+ response = _exchange_code(client, email, code)
264
+ return _credentials_from_login(server, response, email=email)
265
+
266
+ _start_email_login(client, email)
188
267
  click.echo(f"sent a 6-digit sign-in code to {email}; check your inbox.")
189
268
  last_error: ApiError | None = None
190
269
  for attempt in range(EMAIL_CODE_ATTEMPTS):
191
- code = click.prompt("code")
270
+ entered = click.prompt("code")
192
271
  try:
193
- response = client.login_with_code(email, code)
272
+ response = _exchange_code(client, email, entered)
194
273
  except ApiError as exc:
195
274
  if exc.status == 400 and exc.detail == "invalid_or_expired_code":
196
275
  last_error = exc
@@ -207,6 +286,37 @@ def _email_login(
207
286
  raise last_error if last_error is not None else ApiError(400, "invalid_or_expired_code")
208
287
 
209
288
 
289
+ def _start_email_login(client: ApiClient, email: str) -> None:
290
+ """Send the sign-in code, turning the rate-limit 429 into a friendly,
291
+ actionable message instead of a raw ``429: rate_limited``."""
292
+ try:
293
+ client.login_start(email)
294
+ except ApiError as exc:
295
+ if exc.status == 429:
296
+ raise click.ClickException(
297
+ "too many login attempts from this network; wait ~10 minutes and "
298
+ "try `tripwire login` again."
299
+ ) from exc
300
+ raise
301
+
302
+
303
+ def _exchange_code(client: ApiClient, email: str, code: str) -> dict[str, Any]:
304
+ """Exchange a code for a token. A server-side 5xx here is the dangerous case:
305
+ the code may already have been consumed, so retrying the same code is futile
306
+ and silently re-sending one would burn the rate-limited start. Surface a
307
+ clear message and have the user re-run `tripwire login` for a fresh code."""
308
+ try:
309
+ return client.login_with_code(email, code)
310
+ except ApiError as exc:
311
+ if exc.status >= 500:
312
+ raise click.ClickException(
313
+ "the server errored while verifying your code "
314
+ f"({exc.status}: {exc.detail}); your code may already be spent. "
315
+ "run `tripwire login` again to request a fresh code."
316
+ ) from exc
317
+ raise
318
+
319
+
210
320
  def _credentials_from_login(
211
321
  server: str, response: dict[str, Any], email: str | None = None
212
322
  ) -> credentials.Credentials:
@@ -111,13 +111,17 @@ class ApiClient:
111
111
  def login_start(self, email: str) -> dict[str, Any]:
112
112
  """Begin an email-code login: the server emails a 6-digit code. The
113
113
  response is intentionally neutral (``{"status": "ok"}``) and never
114
- reveals whether the address is known. This endpoint is rate-limited, so
115
- call it once per login and re-prompt for the code in-band on failure."""
114
+ reveals whether the address is known. This endpoint is IP rate-limited
115
+ (~5 starts / 10 min, plus a new-user cap), returning
116
+ ``429 rate_limited`` when exceeded, so call it once per login and
117
+ re-prompt for the code in-band on failure."""
116
118
  return self._request("POST", "/auth/login/start", {"email": email})
117
119
 
118
120
  def login_with_code(self, email: str, code: str) -> dict[str, Any]:
119
121
  """Exchange an emailed 6-digit code for a token. A wrong/expired/used
120
- code returns ``400 invalid_or_expired_code``."""
122
+ code returns ``400 invalid_or_expired_code``. A ``5xx`` here can leave
123
+ the code consumed server-side, so the caller treats it as spent and
124
+ sends the user back to request a fresh code rather than retrying."""
121
125
  return self._request("POST", "/auth/login", {"email": email, "code": code})
122
126
 
123
127
  def list_canaries(self) -> dict[str, Any]:
@@ -145,7 +145,7 @@ wheels = [
145
145
 
146
146
  [[package]]
147
147
  name = "tripwire-cli"
148
- version = "0.2.0"
148
+ version = "0.3.0"
149
149
  source = { editable = "." }
150
150
  dependencies = [
151
151
  { name = "click" },
File without changes