exchange-keyshare 0.1.0__tar.gz → 0.1.2__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.
Files changed (30) hide show
  1. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/.claude/settings.local.json +6 -1
  2. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/CLAUDE.md +6 -0
  3. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/PKG-INFO +2 -1
  4. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/pyproject.toml +2 -1
  5. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/cli.py +4 -0
  6. exchange_keyshare-0.1.2/src/exchange_keyshare/commands/config.py +34 -0
  7. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/commands/setup.py +1 -0
  8. exchange_keyshare-0.1.2/src/exchange_keyshare/commands/teardown.py +208 -0
  9. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/config.py +4 -0
  10. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/setup.py +133 -1
  11. exchange_keyshare-0.1.2/tests/test_cli.py +257 -0
  12. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/tests/test_config.py +17 -0
  13. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/uv.lock +30 -1
  14. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/.github/workflows/ci.yaml +0 -0
  15. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/.github/workflows/publish.yml +0 -0
  16. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/.gitignore +0 -0
  17. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/README.md +0 -0
  18. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/__init__.py +0 -0
  19. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/cfn.py +0 -0
  20. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/commands/__init__.py +0 -0
  21. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/commands/keys.py +0 -0
  22. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/keys.py +0 -0
  23. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/schema.py +0 -0
  24. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/src/exchange_keyshare/templates/stack.yaml +0 -0
  25. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/templates +0 -0
  26. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/tests/__init__.py +0 -0
  27. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/tests/test_cfn.py +0 -0
  28. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/tests/test_keys.py +0 -0
  29. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/tests/test_schema.py +0 -0
  30. {exchange_keyshare-0.1.0 → exchange_keyshare-0.1.2}/tests/test_setup.py +0 -0
@@ -6,7 +6,12 @@
6
6
  "Bash(git commit -m \"$\\(cat <<''EOF''\nFix S3 upload AccessDenied by specifying KMS encryption\n\nThe bucket policy requires explicit KMS encryption on uploads, but\nupload_credential wasn''t passing ServerSideEncryption or SSEKMSKeyId\nparameters. Also, kms_key_arn was retrieved from CloudFormation but\nnever saved to the config file.\n\n- Add kms_key_arn field to Config class\n- Save kms_key_arn during setup\n- Pass kms_key_arn to all upload_credential calls\n- Remove outdated placeholder ARN test\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
7
7
  "Bash(git push)",
8
8
  "Bash(uv sync:*)",
9
- "Bash(git commit:*)"
9
+ "Bash(git commit:*)",
10
+ "Bash(uv pip install:*)",
11
+ "WebFetch(domain:github.com)",
12
+ "WebSearch",
13
+ "WebFetch(domain:raw.githubusercontent.com)",
14
+ "WebFetch(domain:pypi.org)"
10
15
  ]
11
16
  }
12
17
  }
@@ -26,6 +26,8 @@ src/exchange_keyshare/
26
26
  │ └── stack.yaml # CloudFormation template
27
27
  └── commands/
28
28
  ├── setup.py # `exchange-keyshare setup` command
29
+ ├── config.py # `exchange-keyshare config` command
30
+ ├── teardown.py # `exchange-keyshare teardown` command
29
31
  └── keys.py # `exchange-keyshare keys` subcommands
30
32
  ```
31
33
 
@@ -33,6 +35,8 @@ src/exchange_keyshare/
33
35
 
34
36
  ```bash
35
37
  exchange-keyshare setup # Create AWS infrastructure
38
+ exchange-keyshare config # Display current configuration
39
+ exchange-keyshare teardown # Delete AWS infrastructure (revoke access)
36
40
  exchange-keyshare keys list # List credentials
37
41
  exchange-keyshare keys create # Create credential (interactive)
38
42
  exchange-keyshare keys delete # Delete credential
@@ -47,8 +51,10 @@ Created by `setup` command with:
47
51
  - `bucket`: S3 bucket name
48
52
  - `region`: AWS region
49
53
  - `stack_name`: CloudFormation stack name
54
+ - `stack_id`: CloudFormation stack ID (ARN)
50
55
  - `role_arn`: IAM role ARN
51
56
  - `external_id`: External ID for role assumption
57
+ - `kms_key_arn`: KMS key ARN
52
58
 
53
59
  ## Environment Variables
54
60
 
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exchange-keyshare
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: CLI for market makers to securely share exchange API credentials
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: boto3>=1.34.0
7
+ Requires-Dist: botocore[crt]>=1.34.0
7
8
  Requires-Dist: click>=8.1.0
8
9
  Requires-Dist: pyyaml>=6.0
9
10
  Requires-Dist: questionary>=2.0.0
@@ -1,11 +1,12 @@
1
1
  [project]
2
2
  name = "exchange-keyshare"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "CLI for market makers to securely share exchange API credentials"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
7
  "click>=8.1.0",
8
8
  "boto3>=1.34.0",
9
+ "botocore[crt]>=1.34.0",
9
10
  "pyyaml>=6.0",
10
11
  "rich>=13.0.0",
11
12
  "questionary>=2.0.0",
@@ -4,8 +4,10 @@ from pathlib import Path
4
4
 
5
5
  import click
6
6
 
7
+ from exchange_keyshare.commands.config import config
7
8
  from exchange_keyshare.commands.keys import keys
8
9
  from exchange_keyshare.commands.setup import setup
10
+ from exchange_keyshare.commands.teardown import teardown
9
11
  from exchange_keyshare.config import Config
10
12
 
11
13
 
@@ -30,6 +32,8 @@ def main(ctx: click.Context, config_path: str | None) -> None:
30
32
 
31
33
 
32
34
  main.add_command(setup)
35
+ main.add_command(config)
36
+ main.add_command(teardown)
33
37
  main.add_command(keys)
34
38
 
35
39
 
@@ -0,0 +1,34 @@
1
+ """Config command for displaying current configuration."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from exchange_keyshare.config import Config
8
+
9
+
10
+ @click.command()
11
+ @click.pass_context
12
+ def config(ctx: click.Context) -> None:
13
+ """Display current configuration."""
14
+ cfg: Config = ctx.obj["config"]
15
+ console = Console()
16
+
17
+ if not cfg.stack_name:
18
+ console.print("Not configured. Run [cyan]exchange-keyshare setup[/cyan] first.")
19
+ return
20
+
21
+ table = Table(show_header=False, box=None)
22
+ table.add_column("Key", style="dim")
23
+ table.add_column("Value", style="cyan")
24
+
25
+ table.add_row("Bucket", cfg.bucket or "")
26
+ table.add_row("Region", cfg.region or "")
27
+ table.add_row("Stack Name", cfg.stack_name or "")
28
+ table.add_row("Stack ID", cfg.stack_id or "")
29
+ table.add_row("Role ARN", cfg.role_arn or "")
30
+ table.add_row("External ID", cfg.external_id or "")
31
+ table.add_row("KMS Key ARN", cfg.kms_key_arn or "")
32
+ table.add_row("Config Path", str(cfg.config_path))
33
+
34
+ console.print(table)
@@ -155,6 +155,7 @@ def setup(ctx: click.Context) -> None:
155
155
  config.bucket = result.bucket
156
156
  config.region = result.region
157
157
  config.stack_name = result.stack_name
158
+ config.stack_id = result.stack_id
158
159
  config.role_arn = result.role_arn
159
160
  config.external_id = result.external_id
160
161
  config.kms_key_arn = result.kms_key_arn
@@ -0,0 +1,208 @@
1
+ """Teardown command for deleting AWS infrastructure."""
2
+
3
+ import click
4
+ from rich.console import Console, Group, RenderableType
5
+ from rich.live import Live
6
+ from rich.panel import Panel
7
+ from rich.spinner import Spinner
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from exchange_keyshare.config import Config
12
+ from exchange_keyshare.keys import CredentialInfo, list_credentials
13
+ from exchange_keyshare.setup import (
14
+ ResourceStatus,
15
+ StackProgress,
16
+ get_friendly_type,
17
+ get_stack_console_url,
18
+ poll_stack_deletion,
19
+ start_stack_deletion,
20
+ )
21
+
22
+
23
+ def get_delete_status_style(status: str) -> str:
24
+ """Get rich style for a deletion status."""
25
+ if "DELETE_COMPLETE" in status or "DELETE_SKIPPED" in status:
26
+ return "green"
27
+ elif "IN_PROGRESS" in status:
28
+ return "yellow"
29
+ elif "FAILED" in status:
30
+ return "red"
31
+ return "white"
32
+
33
+
34
+ def get_delete_status_icon(status: str) -> str:
35
+ """Get icon for a deletion status."""
36
+ if "DELETE_COMPLETE" in status or "DELETE_SKIPPED" in status:
37
+ return "[green]✓[/green]"
38
+ elif "IN_PROGRESS" in status:
39
+ return "[yellow]⋯[/yellow]"
40
+ elif "FAILED" in status:
41
+ return "[red]✗[/red]"
42
+ return " "
43
+
44
+
45
+ def build_deletion_progress_display(progress: StackProgress) -> RenderableType:
46
+ """Build a rich display showing resource deletion progress with spinner."""
47
+ table = Table(show_header=True, header_style="bold", box=None)
48
+ table.add_column("", width=2)
49
+ table.add_column("Resource", style="cyan", min_width=25)
50
+ table.add_column("Type", style="dim")
51
+ table.add_column("Status")
52
+
53
+ # Sort resources: in_progress first, then by name
54
+ def sort_key(item: tuple[str, ResourceStatus]) -> tuple[int, str]:
55
+ _, r = item
56
+ if "IN_PROGRESS" in r.status:
57
+ return (0, r.logical_id)
58
+ elif "COMPLETE" in r.status or "SKIPPED" in r.status:
59
+ return (2, r.logical_id)
60
+ else:
61
+ return (1, r.logical_id)
62
+
63
+ sorted_resources = sorted(progress.resources.items(), key=sort_key)
64
+
65
+ for _logical_id, resource in sorted_resources:
66
+ icon = get_delete_status_icon(resource.status)
67
+ style = get_delete_status_style(resource.status)
68
+ friendly_type = get_friendly_type(resource.resource_type)
69
+
70
+ # Simplify status text
71
+ status_text = resource.status.replace("_", " ").title()
72
+
73
+ table.add_row(
74
+ icon,
75
+ resource.logical_id,
76
+ friendly_type,
77
+ f"[{style}]{status_text}[/{style}]",
78
+ )
79
+
80
+ # Add spinner header if still in progress
81
+ if not progress.is_complete and not progress.is_failed:
82
+ spinner = Spinner("dots", text=Text(" Deleting infrastructure...", style="bold"))
83
+ return Group(spinner, Text(""), table)
84
+
85
+ return table
86
+
87
+
88
+ @click.command()
89
+ @click.pass_context
90
+ def teardown(ctx: click.Context) -> None:
91
+ """Delete AWS infrastructure and revoke access.
92
+
93
+ This permanently deletes all infrastructure created by setup.
94
+ The S3 bucket and KMS key are retained but access is revoked.
95
+ """
96
+ cfg: Config = ctx.obj["config"]
97
+ console = Console()
98
+
99
+ if not cfg.stack_name:
100
+ console.print("Nothing to teardown. Run [cyan]exchange-keyshare setup[/cyan] first.")
101
+ return
102
+
103
+ region = cfg.region or "us-east-1"
104
+
105
+ # Try to list existing credentials (gracefully handle failure)
106
+ credentials: list[CredentialInfo] = []
107
+ if cfg.bucket and cfg.region:
108
+ try:
109
+ credentials = list_credentials(cfg.bucket, cfg.region)
110
+ except Exception:
111
+ pass # Gracefully ignore - permissions may already be revoked
112
+
113
+ # Build credential list for warning
114
+ cred_text = ""
115
+ if credentials:
116
+ cred_text = "\n[bold]Credentials that will become inaccessible:[/bold]\n"
117
+ for cred in credentials:
118
+ cred_text += f" - [cyan]{cred.exchange}[/cyan]\n"
119
+ if cred.pairs:
120
+ cred_text += f" Pairs: {', '.join(cred.pairs)}\n"
121
+ if cred.labels:
122
+ labels_str = ", ".join(f"{lbl['key']}={lbl['value']}" for lbl in cred.labels)
123
+ cred_text += f" Labels: {labels_str}\n"
124
+ cred_text += "\n"
125
+
126
+ # Build console URL if we have a stack ID
127
+ console_url = ""
128
+ if cfg.stack_id:
129
+ console_url = get_stack_console_url(cfg.stack_id, region)
130
+
131
+ # Show scary warning with specific resources
132
+ manual_cmd = f"aws cloudformation delete-stack --stack-name {cfg.stack_name} --region {region}"
133
+
134
+ warning = Panel(
135
+ "[bold red]WARNING: This action cannot be undone![/bold red]\n\n"
136
+ "[bold]Resources that will be DELETED:[/bold]\n"
137
+ f" - IAM Role: [cyan]{cfg.role_arn}[/cyan]\n"
138
+ f" - KMS Key Alias: [cyan]alias/{cfg.bucket}[/cyan]\n"
139
+ " - S3 Bucket Policies\n\n"
140
+ "[bold]Resources that will be RETAINED (but inaccessible):[/bold]\n"
141
+ f" - S3 Bucket: [cyan]{cfg.bucket}[/cyan]\n"
142
+ f" - KMS Key: [cyan]{cfg.kms_key_arn}[/cyan]\n"
143
+ f"{cred_text}\n"
144
+ f"Stack: [cyan]{cfg.stack_name}[/cyan]\n"
145
+ f"Region: [cyan]{region}[/cyan]\n\n"
146
+ f"[dim]Manual command: {manual_cmd}[/dim]",
147
+ title="[bold red]Teardown Infrastructure[/bold red]",
148
+ border_style="red",
149
+ )
150
+ console.print(warning)
151
+ console.print()
152
+
153
+ # Require typing the stack name to confirm
154
+ console.print(f"To confirm, type the stack name: [bold]{cfg.stack_name}[/bold]")
155
+ confirmation = click.prompt("", default="", show_default=False)
156
+
157
+ if confirmation != cfg.stack_name:
158
+ console.print("[yellow]Aborted.[/yellow] Stack name did not match.")
159
+ raise SystemExit(1)
160
+
161
+ console.print()
162
+
163
+ # Show console URL before starting deletion
164
+ if console_url:
165
+ console.print(f"[dim]Monitor progress in AWS Console:[/dim]")
166
+ console.print(f"[link={console_url}]{console_url}[/link]")
167
+ console.print()
168
+ console.print("[dim]You can safely cancel this command (Ctrl+C) - deletion will continue in AWS.[/dim]")
169
+ console.print()
170
+
171
+ # Start deletion and poll with live display
172
+ try:
173
+ cfn = start_stack_deletion(cfg.stack_name, region)
174
+ except Exception as e:
175
+ console.print(f"[red]Error starting deletion: {e}[/red]")
176
+ raise SystemExit(1)
177
+
178
+ final_progress: StackProgress | None = None
179
+
180
+ try:
181
+ with Live(console=console, refresh_per_second=10) as live:
182
+ for progress in poll_stack_deletion(cfg.stack_name, cfn):
183
+ display = build_deletion_progress_display(progress)
184
+ live.update(display)
185
+ final_progress = progress
186
+
187
+ if progress.is_complete or progress.is_failed:
188
+ break
189
+ except KeyboardInterrupt:
190
+ console.print()
191
+ console.print("[yellow]Interrupted.[/yellow] Deletion continues in AWS.")
192
+ if console_url:
193
+ console.print(f"[dim]Monitor progress:[/dim] {console_url}")
194
+ raise SystemExit(0)
195
+
196
+ if final_progress is None or final_progress.is_failed:
197
+ console.print()
198
+ console.print(f"[red]Error: {final_progress.failure_reason if final_progress else 'Unknown error'}[/red]")
199
+ if console_url:
200
+ console.print(f"[dim]Check AWS Console:[/dim] {console_url}")
201
+ raise SystemExit(1)
202
+
203
+ # Delete config file
204
+ if cfg.config_path.exists():
205
+ cfg.config_path.unlink()
206
+
207
+ console.print()
208
+ console.print("[green]Teardown complete.[/green] All access has been revoked.")
@@ -26,6 +26,7 @@ class Config:
26
26
  bucket: str | None = None
27
27
  region: str | None = None
28
28
  stack_name: str | None = None
29
+ stack_id: str | None = None
29
30
  role_arn: str | None = None
30
31
  external_id: str | None = None
31
32
  kms_key_arn: str | None = None
@@ -36,6 +37,7 @@ class Config:
36
37
  self.bucket = data.get("bucket")
37
38
  self.region = data.get("region")
38
39
  self.stack_name = data.get("stack_name")
40
+ self.stack_id = data.get("stack_id")
39
41
  self.role_arn = data.get("role_arn")
40
42
  self.external_id = data.get("external_id")
41
43
  self.kms_key_arn = data.get("kms_key_arn")
@@ -49,6 +51,8 @@ class Config:
49
51
  data["region"] = self.region
50
52
  if self.stack_name:
51
53
  data["stack_name"] = self.stack_name
54
+ if self.stack_id:
55
+ data["stack_id"] = self.stack_id
52
56
  if self.role_arn:
53
57
  data["role_arn"] = self.role_arn
54
58
  if self.external_id:
@@ -40,6 +40,7 @@ class SetupResult:
40
40
  role_arn: str
41
41
  external_id: str
42
42
  stack_name: str
43
+ stack_id: str
43
44
  kms_key_arn: str
44
45
 
45
46
 
@@ -216,12 +217,142 @@ def _is_newer_status(new_status: str, old_status: str) -> bool:
216
217
  return True
217
218
 
218
219
 
220
+ def get_stack_console_url(stack_id: str, region: str) -> str:
221
+ """Get AWS Console URL for a CloudFormation stack."""
222
+ # URL-encode the stack ID (it contains special characters)
223
+ from urllib.parse import quote
224
+ encoded_stack_id = quote(stack_id, safe="")
225
+ return f"https://{region}.console.aws.amazon.com/cloudformation/home?region={region}#/stacks/stackinfo?stackId={encoded_stack_id}"
226
+
227
+
228
+ def start_stack_deletion(stack_name: str, region: str) -> "CloudFormationClient":
229
+ """Start CloudFormation stack deletion. Returns cfn_client for polling."""
230
+ cfn = cast("CloudFormationClient", boto3.client("cloudformation", region_name=region)) # pyright: ignore[reportUnknownMemberType]
231
+
232
+ try:
233
+ cfn.delete_stack(StackName=stack_name) # pyright: ignore[reportUnknownMemberType]
234
+ except ClientError as e:
235
+ raise Exception(f"Failed to delete stack: {e}") from e
236
+
237
+ return cfn
238
+
239
+
240
+ def poll_stack_deletion(
241
+ stack_name: str,
242
+ cfn: "CloudFormationClient",
243
+ poll_interval: float = 2.0,
244
+ max_attempts: int = 300,
245
+ ) -> Generator[StackProgress, None, None]:
246
+ """Poll stack deletion progress and yield updates."""
247
+ resources: dict[str, ResourceStatus] = {}
248
+
249
+ for _ in range(max_attempts):
250
+ # Get current stack status
251
+ try:
252
+ stacks_response = cfn.describe_stacks(StackName=stack_name)
253
+ except ClientError as e:
254
+ # Stack not found means deletion complete
255
+ if "does not exist" in str(e):
256
+ yield StackProgress(
257
+ resources=resources,
258
+ stack_status="DELETE_COMPLETE",
259
+ is_complete=True,
260
+ is_failed=False,
261
+ )
262
+ return
263
+ raise
264
+
265
+ stack = stacks_response["Stacks"][0]
266
+ stack_status = stack.get("StackStatus", "UNKNOWN")
267
+
268
+ # Get resource events (only delete-related)
269
+ try:
270
+ events_response = cfn.describe_stack_events(StackName=stack_name)
271
+ for event in events_response["StackEvents"]:
272
+ logical_id = event.get("LogicalResourceId", "")
273
+ resource_type = event.get("ResourceType", "")
274
+ status = event.get("ResourceStatus", "")
275
+ reason = event.get("ResourceStatusReason")
276
+
277
+ # Skip the stack itself
278
+ if resource_type == "AWS::CloudFormation::Stack":
279
+ continue
280
+
281
+ # Only track delete-related statuses
282
+ if "DELETE" not in status:
283
+ continue
284
+
285
+ # Update if this is a newer status for this resource
286
+ if logical_id not in resources or _is_newer_delete_status(status, resources[logical_id].status):
287
+ resources[logical_id] = ResourceStatus(
288
+ logical_id=logical_id,
289
+ resource_type=resource_type,
290
+ status=status,
291
+ reason=reason,
292
+ )
293
+ except ClientError:
294
+ pass # Stack events may not be available
295
+
296
+ # Check for completion
297
+ is_complete = stack_status == "DELETE_COMPLETE"
298
+ is_failed = stack_status == "DELETE_FAILED"
299
+
300
+ failure_reason: str | None = None
301
+ if is_failed:
302
+ failed_resources = [r for r in resources.values() if "FAILED" in r.status]
303
+ if failed_resources:
304
+ reasons = [r.reason for r in failed_resources if r.reason]
305
+ failure_reason = "; ".join(reasons[:3]) if reasons else "Unknown error"
306
+
307
+ yield StackProgress(
308
+ resources=resources,
309
+ stack_status=stack_status,
310
+ is_complete=is_complete,
311
+ is_failed=is_failed,
312
+ failure_reason=failure_reason,
313
+ )
314
+
315
+ if is_complete or is_failed:
316
+ return
317
+
318
+ time.sleep(poll_interval)
319
+
320
+ # Timeout
321
+ yield StackProgress(
322
+ resources=resources,
323
+ stack_status="TIMEOUT",
324
+ is_complete=False,
325
+ is_failed=True,
326
+ failure_reason="Stack deletion timed out",
327
+ )
328
+
329
+
330
+ def _is_newer_delete_status(new_status: str, old_status: str) -> bool:
331
+ """Check if new_status is more recent than old_status for deletion."""
332
+ status_order = [
333
+ "CREATE_COMPLETE",
334
+ "DELETE_IN_PROGRESS",
335
+ "DELETE_COMPLETE",
336
+ "DELETE_FAILED",
337
+ "DELETE_SKIPPED",
338
+ ]
339
+
340
+ try:
341
+ new_idx = status_order.index(new_status)
342
+ old_idx = status_order.index(old_status)
343
+ return new_idx > old_idx
344
+ except ValueError:
345
+ return True
346
+
347
+
219
348
  def get_stack_outputs(stack_name: str, region: str) -> SetupResult:
220
349
  """Get outputs from a completed stack."""
221
350
  cfn = cast("CloudFormationClient", boto3.client("cloudformation", region_name=region)) # pyright: ignore[reportUnknownMemberType]
222
351
 
223
352
  response = cfn.describe_stacks(StackName=stack_name)
224
- stack_outputs = response["Stacks"][0].get("Outputs", [])
353
+ stack = response["Stacks"][0]
354
+ stack_id = stack.get("StackId", "")
355
+ stack_outputs = stack.get("Outputs", [])
225
356
  outputs: dict[str, str] = {
226
357
  o["OutputKey"]: o["OutputValue"]
227
358
  for o in stack_outputs
@@ -234,6 +365,7 @@ def get_stack_outputs(stack_name: str, region: str) -> SetupResult:
234
365
  role_arn=outputs["RoleArn"],
235
366
  external_id=outputs["ExternalId"],
236
367
  stack_name=stack_name,
368
+ stack_id=stack_id,
237
369
  kms_key_arn=outputs["KmsKeyArn"],
238
370
  )
239
371
 
@@ -0,0 +1,257 @@
1
+ """Tests for CLI commands."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from click.testing import CliRunner
7
+
8
+ from exchange_keyshare.cli import main
9
+ from exchange_keyshare.config import save_config
10
+
11
+
12
+ def test_config_command_displays_configuration(tmp_path: Path) -> None:
13
+ """Config command displays all configuration fields."""
14
+ config_path = tmp_path / "config.yaml"
15
+
16
+ save_config(
17
+ config_path,
18
+ {
19
+ "bucket": "test-bucket",
20
+ "region": "us-east-1",
21
+ "stack_name": "test-stack",
22
+ "stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
23
+ "role_arn": "arn:aws:iam::123456789:role/test-role",
24
+ "external_id": "test-external-id",
25
+ "kms_key_arn": "arn:aws:kms:us-east-1:123456789:key/abc123",
26
+ },
27
+ )
28
+
29
+ runner = CliRunner()
30
+ result = runner.invoke(main, ["--config", str(config_path), "config"])
31
+
32
+ assert result.exit_code == 0
33
+ assert "test-bucket" in result.output
34
+ assert "us-east-1" in result.output
35
+ assert "test-stack" in result.output
36
+ # Stack ID may be truncated by Rich table rendering, just check it starts showing
37
+ assert "arn:aws:cloudformation" in result.output
38
+ assert "arn:aws:iam" in result.output
39
+ assert "test-external-id" in result.output
40
+
41
+
42
+ def test_config_command_shows_message_when_not_configured(tmp_path: Path) -> None:
43
+ """Config command shows helpful message when no config exists."""
44
+ config_path = tmp_path / "config.yaml"
45
+
46
+ runner = CliRunner()
47
+ result = runner.invoke(main, ["--config", str(config_path), "config"])
48
+
49
+ assert result.exit_code == 0
50
+ assert "not configured" in result.output.lower() or "setup" in result.output.lower()
51
+
52
+
53
+ def test_teardown_requires_confirmation(tmp_path: Path) -> None:
54
+ """Teardown command requires typing confirmation to proceed."""
55
+ config_path = tmp_path / "config.yaml"
56
+ save_config(
57
+ config_path,
58
+ {
59
+ "bucket": "test-bucket",
60
+ "region": "us-east-1",
61
+ "stack_name": "test-stack",
62
+ "stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
63
+ },
64
+ )
65
+
66
+ runner = CliRunner()
67
+ # User types wrong confirmation
68
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="wrong\n")
69
+
70
+ assert result.exit_code == 1
71
+ assert "aborted" in result.output.lower() or "cancelled" in result.output.lower()
72
+ # Config file should still exist
73
+ assert config_path.exists()
74
+
75
+
76
+ def test_teardown_shows_scary_warning(tmp_path: Path) -> None:
77
+ """Teardown command shows a scary warning about irreversibility."""
78
+ config_path = tmp_path / "config.yaml"
79
+ save_config(
80
+ config_path,
81
+ {
82
+ "bucket": "test-bucket",
83
+ "region": "us-east-1",
84
+ "stack_name": "test-stack",
85
+ "stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
86
+ },
87
+ )
88
+
89
+ runner = CliRunner()
90
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="\n")
91
+
92
+ # Should show warning text about irreversibility
93
+ output_lower = result.output.lower()
94
+ assert "warning" in output_lower or "cannot be undone" in output_lower or "permanent" in output_lower
95
+
96
+
97
+ @patch("exchange_keyshare.commands.teardown.poll_stack_deletion")
98
+ @patch("exchange_keyshare.commands.teardown.start_stack_deletion")
99
+ def test_teardown_deletes_stack_and_config_on_confirmation(
100
+ mock_start_deletion: MagicMock,
101
+ mock_poll_deletion: MagicMock,
102
+ tmp_path: Path,
103
+ ) -> None:
104
+ """Teardown deletes CloudFormation stack and config file when confirmed."""
105
+ from exchange_keyshare.setup import StackProgress
106
+
107
+ # Mock successful deletion
108
+ mock_start_deletion.return_value = MagicMock()
109
+ mock_poll_deletion.return_value = iter([
110
+ StackProgress(
111
+ resources={},
112
+ stack_status="DELETE_COMPLETE",
113
+ is_complete=True,
114
+ is_failed=False,
115
+ )
116
+ ])
117
+
118
+ config_path = tmp_path / "config.yaml"
119
+ save_config(
120
+ config_path,
121
+ {
122
+ "bucket": "test-bucket",
123
+ "region": "us-east-1",
124
+ "stack_name": "test-stack",
125
+ "stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
126
+ },
127
+ )
128
+
129
+ runner = CliRunner()
130
+ # User types correct confirmation (the stack name)
131
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="test-stack\n")
132
+
133
+ assert result.exit_code == 0
134
+ mock_start_deletion.assert_called_once_with("test-stack", "us-east-1")
135
+ # Config file should be deleted
136
+ assert not config_path.exists()
137
+
138
+
139
+ def test_teardown_shows_message_when_not_configured(tmp_path: Path) -> None:
140
+ """Teardown shows helpful message when no config exists."""
141
+ config_path = tmp_path / "config.yaml"
142
+
143
+ runner = CliRunner()
144
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"])
145
+
146
+ assert result.exit_code == 0
147
+ assert "not configured" in result.output.lower() or "nothing to teardown" in result.output.lower()
148
+
149
+
150
+ @patch("exchange_keyshare.commands.teardown.list_credentials")
151
+ def test_teardown_shows_existing_credentials_with_pairs_and_labels(
152
+ mock_list_credentials: MagicMock, tmp_path: Path
153
+ ) -> None:
154
+ """Teardown shows existing credentials with pairs and labels in the warning."""
155
+ from exchange_keyshare.keys import CredentialInfo
156
+
157
+ mock_list_credentials.return_value = [
158
+ CredentialInfo(
159
+ key="exchange-credentials/binance-abc123.yaml",
160
+ exchange="binance",
161
+ pairs=["BTC/USDT", "ETH/USDT"],
162
+ labels=[{"key": "environment", "value": "production"}],
163
+ ),
164
+ CredentialInfo(
165
+ key="exchange-credentials/coinbase-def456.yaml",
166
+ exchange="coinbase",
167
+ pairs=None,
168
+ labels=None,
169
+ ),
170
+ ]
171
+
172
+ config_path = tmp_path / "config.yaml"
173
+ save_config(
174
+ config_path,
175
+ {
176
+ "bucket": "test-bucket",
177
+ "region": "us-east-1",
178
+ "stack_name": "test-stack",
179
+ },
180
+ )
181
+
182
+ runner = CliRunner()
183
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="\n")
184
+
185
+ # Should show credentials in output
186
+ assert "binance" in result.output.lower()
187
+ assert "coinbase" in result.output.lower()
188
+ # Should show pairs
189
+ assert "BTC/USDT" in result.output
190
+ assert "ETH/USDT" in result.output
191
+ # Should show labels
192
+ assert "environment=production" in result.output
193
+
194
+
195
+ @patch("exchange_keyshare.commands.teardown.list_credentials")
196
+ def test_teardown_gracefully_handles_list_credentials_failure(
197
+ mock_list_credentials: MagicMock, tmp_path: Path
198
+ ) -> None:
199
+ """Teardown continues gracefully if listing credentials fails."""
200
+ mock_list_credentials.side_effect = Exception("Access denied")
201
+
202
+ config_path = tmp_path / "config.yaml"
203
+ save_config(
204
+ config_path,
205
+ {
206
+ "bucket": "test-bucket",
207
+ "region": "us-east-1",
208
+ "stack_name": "test-stack",
209
+ },
210
+ )
211
+
212
+ runner = CliRunner()
213
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="\n")
214
+
215
+ # Should still show the warning and prompt (not crash)
216
+ assert "warning" in result.output.lower() or "cannot be undone" in result.output.lower()
217
+
218
+
219
+ @patch("exchange_keyshare.commands.teardown.poll_stack_deletion")
220
+ @patch("exchange_keyshare.commands.teardown.start_stack_deletion")
221
+ def test_teardown_shows_console_url_and_cancel_message(
222
+ mock_start_deletion: MagicMock,
223
+ mock_poll_deletion: MagicMock,
224
+ tmp_path: Path,
225
+ ) -> None:
226
+ """Teardown shows AWS Console URL and message about safe cancellation."""
227
+ from exchange_keyshare.setup import StackProgress
228
+
229
+ mock_start_deletion.return_value = MagicMock()
230
+ mock_poll_deletion.return_value = iter([
231
+ StackProgress(
232
+ resources={},
233
+ stack_status="DELETE_COMPLETE",
234
+ is_complete=True,
235
+ is_failed=False,
236
+ )
237
+ ])
238
+
239
+ config_path = tmp_path / "config.yaml"
240
+ save_config(
241
+ config_path,
242
+ {
243
+ "bucket": "test-bucket",
244
+ "region": "us-east-1",
245
+ "stack_name": "test-stack",
246
+ "stack_id": "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123",
247
+ },
248
+ )
249
+
250
+ runner = CliRunner()
251
+ result = runner.invoke(main, ["--config", str(config_path), "teardown"], input="test-stack\n")
252
+
253
+ assert result.exit_code == 0
254
+ # Should show console URL
255
+ assert "console.aws.amazon.com" in result.output
256
+ # Should show message about safe cancellation
257
+ assert "ctrl+c" in result.output.lower() or "cancel" in result.output.lower()
@@ -69,3 +69,20 @@ def test_save_config_sets_restrictive_permissions(tmp_path: Path) -> None:
69
69
  assert mode & stat.S_IRWXU == stat.S_IRUSR | stat.S_IWUSR # owner rw
70
70
  assert mode & stat.S_IRWXG == 0 # no group permissions
71
71
  assert mode & stat.S_IRWXO == 0 # no other permissions
72
+
73
+
74
+ def test_config_saves_and_loads_stack_id(tmp_path: Path) -> None:
75
+ """Config can save and load stack_id field."""
76
+ config_path = tmp_path / "config.yaml"
77
+
78
+ config = Config(config_path=config_path)
79
+ config.bucket = "test-bucket"
80
+ config.region = "us-east-1"
81
+ config.stack_name = "test-stack"
82
+ config.stack_id = "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123"
83
+ config.save()
84
+
85
+ loaded = Config(config_path=config_path)
86
+ loaded.load()
87
+
88
+ assert loaded.stack_id == "arn:aws:cloudformation:us-east-1:123456789:stack/test-stack/abc123"
@@ -6,6 +6,28 @@ resolution-markers = [
6
6
  "python_full_version < '3.14'",
7
7
  ]
8
8
 
9
+ [[package]]
10
+ name = "awscrt"
11
+ version = "0.29.2"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ sdist = { url = "https://files.pythonhosted.org/packages/1c/90/f985002a50859ea39841e66bc816c224b623c03f943b34bfe08fee17165c/awscrt-0.29.2.tar.gz", hash = "sha256:c78d81b1308d42fda1eb21d27fcf26579137b821043e528550f2cfc6c09ab9ff", size = 38013553, upload-time = "2025-12-04T00:16:36.777Z" }
14
+ wheels = [
15
+ { url = "https://files.pythonhosted.org/packages/c8/7f/9d7b3d3f72369f80730ee5a0e964a1cd6ccf83d8c117a4d1df629e3ed6f9/awscrt-0.29.2-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:3ff819a542acc5f11c46223204362c6b065031df0e4a37bf1ce2fc233a7145e4", size = 3407922, upload-time = "2025-12-04T00:15:49.071Z" },
16
+ { url = "https://files.pythonhosted.org/packages/17/f2/3e61a4683ff8e9bc59871214e833bf3f67e9f08b1884aae9e1166ef06ac3/awscrt-0.29.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa3d2f7fc0218a04707384afd871a8c3e97251185038eb921ae2fae4f61b7d90", size = 3817179, upload-time = "2025-12-04T00:15:50.965Z" },
17
+ { url = "https://files.pythonhosted.org/packages/3b/13/a7366b0465b998b1d05f8b3a05e5897a431ec101b9a389be6058fbbe5e26/awscrt-0.29.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f86d5a1a3c6d817dc0087d4e25abae1a7a1dd678d31fbcd226a15515719bdac", size = 4091388, upload-time = "2025-12-04T00:15:52.182Z" },
18
+ { url = "https://files.pythonhosted.org/packages/dd/90/a34b1b29612d91baad1273ea1d4e31ab3a90e2db4e516345329fecfbaeb2/awscrt-0.29.2-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a6451a730c961b73b57dccdcf8599bf8058740053b531d3724efbf3a89e2d191", size = 3719628, upload-time = "2025-12-04T00:15:53.356Z" },
19
+ { url = "https://files.pythonhosted.org/packages/dd/94/2d93803e93cff7da0f5c9b6422b9f919d86db83640cdfbae569bdf22e606/awscrt-0.29.2-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:66a82b0960d281e14e7bfb95e9d6934cbc8e949b3f3e829709736b14bf0e6760", size = 3945694, upload-time = "2025-12-04T00:15:54.879Z" },
20
+ { url = "https://files.pythonhosted.org/packages/ec/26/e2517fe8eb2f565ba52274a59f301bc6fac25494e0d28b6257fde237190a/awscrt-0.29.2-cp311-abi3-win32.whl", hash = "sha256:c1c243a7d7b9ed9c1e10acfb44eaab81b88eec24b50915ce3db45a8ffa4f2edb", size = 3959540, upload-time = "2025-12-04T00:15:57.138Z" },
21
+ { url = "https://files.pythonhosted.org/packages/9a/49/bc9f3bcf2d49c58b97dd357f617c8331c1be02e5907316eb0ac39096941d/awscrt-0.29.2-cp311-abi3-win_amd64.whl", hash = "sha256:cd7349596f8f7b05805e047d29bfceb2304f5f0277fe0088e13ea8fe41ac3064", size = 4092749, upload-time = "2025-12-04T00:15:58.414Z" },
22
+ { url = "https://files.pythonhosted.org/packages/1f/41/a564e4537c8e56259d9a9a86239d6b89448a742a5a5769b8cb81e2db7b26/awscrt-0.29.2-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:2377b9adf0db47fcf74ad6c89afcd901f6cf97f24ba4f0e5e352f5ffe12affef", size = 3406616, upload-time = "2025-12-04T00:15:59.966Z" },
23
+ { url = "https://files.pythonhosted.org/packages/9e/f2/4f88475ea7a9e4954871ebe188bfc9b5d29a60e1101e50a984afde904c3b/awscrt-0.29.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e0cb67ce813577679dab7ab56cc8bb16a546328589db87a55f7b52314f54504f", size = 3809168, upload-time = "2025-12-04T00:16:01.288Z" },
24
+ { url = "https://files.pythonhosted.org/packages/22/6f/f08b3b646198d9a5fb45bc411e6a102ec976efc4acc34c01ac86cf557216/awscrt-0.29.2-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb7d44ed31baeb1b5720674a0249f48d8d6e548ea544ddfb93000b6bff559f34", size = 4084414, upload-time = "2025-12-04T00:16:03.504Z" },
25
+ { url = "https://files.pythonhosted.org/packages/3e/40/fd45b53bfc5486a31080a767947396f717534dde0ace1c9ee48b96c079b9/awscrt-0.29.2-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e455776fd0a04929c586a66e590c6eb6f2ca08b96ec13323bfb087ee17fd6e99", size = 3710752, upload-time = "2025-12-04T00:16:04.777Z" },
26
+ { url = "https://files.pythonhosted.org/packages/53/25/179278c03b84e09332ef4a7770878b93b43f10564b6a94a82faa8d9329e6/awscrt-0.29.2-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:70fd197fe83b78c25c2c57293466808df6f63287a480984834b4af7b9d18d4c0", size = 3939687, upload-time = "2025-12-04T00:16:06.342Z" },
27
+ { url = "https://files.pythonhosted.org/packages/4f/5c/8330d3f8f5f080a85dc79c376c7125f24579dfb9c4e200224292062a36bb/awscrt-0.29.2-cp313-abi3-win32.whl", hash = "sha256:b3c46e4808ce7cfb6a63878e45b30b3410f5c314e352396d1210380787cafee2", size = 3954574, upload-time = "2025-12-04T00:16:07.539Z" },
28
+ { url = "https://files.pythonhosted.org/packages/e8/8f/630f3083d07a75e258aae67c0d06c7ed69098d4ca85550e9cd344d3f613d/awscrt-0.29.2-cp313-abi3-win_amd64.whl", hash = "sha256:74f8944e04bfc1508cce784b75927b4fb9895ff6a57310abb523c4b446b4f34f", size = 4085860, upload-time = "2025-12-04T00:16:08.752Z" },
29
+ ]
30
+
9
31
  [[package]]
10
32
  name = "boto3"
11
33
  version = "1.42.37"
@@ -55,6 +77,11 @@ wheels = [
55
77
  { url = "https://files.pythonhosted.org/packages/72/30/54042dd3ad8161964f8f47aa418785079bd8d2f17053c40d65bafb9f6eed/botocore-1.42.37-py3-none-any.whl", hash = "sha256:f13bb8b560a10714d96fb7b0c7f17828dfa6e6606a1ead8c01c6ebb8765acbd8", size = 14589390, upload-time = "2026-01-28T20:38:31.306Z" },
56
78
  ]
57
79
 
80
+ [package.optional-dependencies]
81
+ crt = [
82
+ { name = "awscrt" },
83
+ ]
84
+
58
85
  [[package]]
59
86
  name = "botocore-stubs"
60
87
  version = "1.42.40"
@@ -90,10 +117,11 @@ wheels = [
90
117
 
91
118
  [[package]]
92
119
  name = "exchange-keyshare"
93
- version = "0.1.0"
120
+ version = "0.1.1"
94
121
  source = { editable = "." }
95
122
  dependencies = [
96
123
  { name = "boto3" },
124
+ { name = "botocore", extra = ["crt"] },
97
125
  { name = "click" },
98
126
  { name = "pyyaml" },
99
127
  { name = "questionary" },
@@ -110,6 +138,7 @@ dev = [
110
138
  [package.metadata]
111
139
  requires-dist = [
112
140
  { name = "boto3", specifier = ">=1.34.0" },
141
+ { name = "botocore", extras = ["crt"], specifier = ">=1.34.0" },
113
142
  { name = "click", specifier = ">=8.1.0" },
114
143
  { name = "pyyaml", specifier = ">=6.0" },
115
144
  { name = "questionary", specifier = ">=2.0.0" },