thoughtleaders-cli 0.5.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.
- thoughtleaders_cli-0.5.0.dist-info/METADATA +215 -0
- thoughtleaders_cli-0.5.0.dist-info/RECORD +59 -0
- thoughtleaders_cli-0.5.0.dist-info/WHEEL +4 -0
- thoughtleaders_cli-0.5.0.dist-info/entry_points.txt +2 -0
- thoughtleaders_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- tl_cli/__init__.py +3 -0
- tl_cli/_completions.py +4 -0
- tl_cli/_plugin/.claude-plugin/marketplace.json +17 -0
- tl_cli/_plugin/.claude-plugin/plugin.json +12 -0
- tl_cli/_plugin/agents/tl-analyst.md +66 -0
- tl_cli/_plugin/commands/tl-balance.md +10 -0
- tl_cli/_plugin/commands/tl-brands.md +16 -0
- tl_cli/_plugin/commands/tl-channels.md +31 -0
- tl_cli/_plugin/commands/tl-reports.md +16 -0
- tl_cli/_plugin/commands/tl-sponsorships.md +23 -0
- tl_cli/_plugin/commands/tl.md +28 -0
- tl_cli/_plugin/hooks/hooks.json +26 -0
- tl_cli/_plugin/hooks/scripts/post-usage.sh +26 -0
- tl_cli/_plugin/hooks/scripts/pre-check.sh +30 -0
- tl_cli/_plugin/skills/tl/SKILL.md +413 -0
- tl_cli/_plugin/skills/tl/references/business-glossary.md +159 -0
- tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +259 -0
- tl_cli/_plugin/skills/tl/references/firebolt-schema.md +208 -0
- tl_cli/_plugin/skills/tl/references/postgres-schema.md +269 -0
- tl_cli/auth/__init__.py +0 -0
- tl_cli/auth/commands.py +49 -0
- tl_cli/auth/login.py +328 -0
- tl_cli/auth/pkce.py +21 -0
- tl_cli/auth/token_store.py +98 -0
- tl_cli/client/__init__.py +0 -0
- tl_cli/client/errors.py +72 -0
- tl_cli/client/http.py +109 -0
- tl_cli/commands/__init__.py +0 -0
- tl_cli/commands/ask.py +54 -0
- tl_cli/commands/balance.py +68 -0
- tl_cli/commands/brands.py +174 -0
- tl_cli/commands/changelog.py +119 -0
- tl_cli/commands/channels.py +291 -0
- tl_cli/commands/comments.py +63 -0
- tl_cli/commands/db.py +104 -0
- tl_cli/commands/deals.py +52 -0
- tl_cli/commands/describe.py +166 -0
- tl_cli/commands/doctor.py +70 -0
- tl_cli/commands/matches.py +69 -0
- tl_cli/commands/proposals.py +69 -0
- tl_cli/commands/reports.py +346 -0
- tl_cli/commands/schema.py +55 -0
- tl_cli/commands/setup.py +401 -0
- tl_cli/commands/snapshots.py +93 -0
- tl_cli/commands/sponsorships.py +193 -0
- tl_cli/commands/uploads.py +84 -0
- tl_cli/commands/whoami.py +206 -0
- tl_cli/config.py +55 -0
- tl_cli/filters.py +88 -0
- tl_cli/hints.py +53 -0
- tl_cli/main.py +209 -0
- tl_cli/output/__init__.py +0 -0
- tl_cli/output/formatter.py +436 -0
- tl_cli/self_update.py +173 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# ThoughtLeaders PostgreSQL Schema Reference
|
|
2
|
+
|
|
3
|
+
## How to query
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
tl db pg "SELECT id, weighted_price FROM thoughtleaders_adlink
|
|
7
|
+
WHERE publish_status = 2
|
|
8
|
+
LIMIT 50 OFFSET 0"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`tl schema pg` prints the live table/column listing visible to your user.
|
|
12
|
+
|
|
13
|
+
Accepted SQL:
|
|
14
|
+
- **SELECT only**, single statement. No DDL/DML/transactions/SET/COPY/MERGE.
|
|
15
|
+
- Functions accepted from an explicit list (aggregates, window, string, JSON, math, date-time, array). Catalog-resolving casts (`::regclass`, `::regprocedure`, …) are not accepted.
|
|
16
|
+
- `LIMIT` and `OFFSET` are optional. Omit them and the server fills in `LIMIT 50 OFFSET 0`. Explicit `LIMIT` must be an integer literal ≤ 500. Explicit `OFFSET` ≥ 10,000 is rejected with HTTP 403 (`OFFSET_TOO_DEEP`); paginate with the response's `next_offset`/breadcrumbs instead of jumping deep.
|
|
17
|
+
- SQL ≤ 50,000 chars; AST depth ≤ 64; node count ≤ 5,000.
|
|
18
|
+
|
|
19
|
+
## Core Tables
|
|
20
|
+
|
|
21
|
+
### `thoughtleaders_adlink` (Deals/Sponsorships)
|
|
22
|
+
|
|
23
|
+
The main deals table. Each row = one sponsorship deal between a brand and a YouTube channel. Also called "AdLink" in code, exposed as **sponsorship** in the CLI.
|
|
24
|
+
|
|
25
|
+
> 🚨 **Columns that DO NOT exist on `thoughtleaders_adlink` — common hallucinations:**
|
|
26
|
+
> - ❌ `brand_id` — there is NO direct brand FK. Brand is reached via `creator_profile_id → profile → profile_brands → brand`.
|
|
27
|
+
> - ❌ `organization_id` — there is NO direct org FK. Org is reached via `creator_profile_id → profile.organization_id → organization`.
|
|
28
|
+
> - ❌ `channel_id` — channel is reached via `ad_spot_id → adspot.channel_id → channel`.
|
|
29
|
+
> - ❌ `youtube_id` (on channel) — use `external_channel_id`.
|
|
30
|
+
|
|
31
|
+
#### Key Columns
|
|
32
|
+
|
|
33
|
+
| Column | Type | Description |
|
|
34
|
+
|--------|------|-------------|
|
|
35
|
+
| `id` | int | Primary key |
|
|
36
|
+
| `created_at` | timestamptz | When the deal was created |
|
|
37
|
+
| `updated_at` | timestamptz | Last modification |
|
|
38
|
+
| `publish_status` | int | Deal status (see constants below) |
|
|
39
|
+
| `price` | numeric | Deal price (USD) |
|
|
40
|
+
| `price_currency` | varchar | Always USD |
|
|
41
|
+
| `weighted_price` | numeric | `price * (status_weight/100)`, pre-calculated on save |
|
|
42
|
+
| `weighted_price_currency` | varchar | Always USD |
|
|
43
|
+
| `cost` | numeric | Cost to TL |
|
|
44
|
+
| `ad_spot_id` | int FK | → `thoughtleaders_adspot.id` |
|
|
45
|
+
| `creator_profile_id` | int FK | → brand/advertiser profile |
|
|
46
|
+
| `owner_advertiser_id` | int FK | → `auth_user.id` (brand-side owner) |
|
|
47
|
+
| `owner_publisher_id` | int FK | → `auth_user.id` (channel-side owner) |
|
|
48
|
+
| `owner_sales_id` | int FK | → `auth_user.id` (sales rep) |
|
|
49
|
+
| `send_date` | timestamptz | Scheduled send/publish date |
|
|
50
|
+
| `publish_date` | timestamptz | Actual publish date |
|
|
51
|
+
| `outreach_date` | timestamptz | When outreach was sent |
|
|
52
|
+
| `purchase_date` | timestamptz | When deal was purchased/sold |
|
|
53
|
+
| `presented_date` | timestamptz | When presented to brand |
|
|
54
|
+
| `rejected_date` | timestamptz | When rejected |
|
|
55
|
+
| `proposal_approved_date` | timestamptz | When proposal was approved |
|
|
56
|
+
| `draft_expected_date` | date | Expected draft delivery |
|
|
57
|
+
| `actual_end_date` | timestamptz | Actual end date |
|
|
58
|
+
| `scheduled_end_date` | timestamptz | Scheduled end date |
|
|
59
|
+
| `rejection_reason` | int | Rejection reason code |
|
|
60
|
+
| `rejection_reason_details` | text | Free-text rejection details |
|
|
61
|
+
| `payment_status` | int | 0=Unpaid, 1=Paid |
|
|
62
|
+
| `performance_grade` | int | Performance rating (see business-glossary) |
|
|
63
|
+
| `article_id` | varchar | Compound `<channel_id>:<youtube_id>` — links to ES `_id` and ES `id` field |
|
|
64
|
+
| `dashboard_campaign_id` | int FK | Campaign grouping |
|
|
65
|
+
| `created_where` | varchar | Where the deal originated |
|
|
66
|
+
| `tx_data` | jsonb | Transaction metadata |
|
|
67
|
+
|
|
68
|
+
#### `publish_status` Constants
|
|
69
|
+
|
|
70
|
+
| Value | Constant | Label | Pipeline Weight |
|
|
71
|
+
|-------|----------|-------|----------------|
|
|
72
|
+
| 0 | PREVIEW | Proposed | 10% |
|
|
73
|
+
| 1 | UNAVAILABLE | Unavailable | — |
|
|
74
|
+
| 2 | PENDING | Pending | 70% |
|
|
75
|
+
| 3 | SOLD | Sold | — |
|
|
76
|
+
| 4 | DENY | Rejected by Advertiser | 0% |
|
|
77
|
+
| 5 | REJECT | Rejected by Publisher | 0% |
|
|
78
|
+
| 6 | PROPOSAL_APPROVED | Proposal Approved | 25% |
|
|
79
|
+
| 7 | MATCHED | Matched (default) | 1% |
|
|
80
|
+
| 8 | OUTREACH | Reached Out | 5% |
|
|
81
|
+
| 9 | REJECTED_AGENCY | Rejected by Agency | 0% |
|
|
82
|
+
| -1 | CLIENT_SIDE_AVAILABLE | Client Side Available | — |
|
|
83
|
+
| -2 | CLIENT_SIDE_TAKEN | Client Side Taken | — |
|
|
84
|
+
|
|
85
|
+
#### Pipeline Stages
|
|
86
|
+
|
|
87
|
+
- **Active pipeline** = statuses with weight > 0: 0, 2, 6, 7, 8.
|
|
88
|
+
- **Won** = 3 (Sold).
|
|
89
|
+
- **Lost** = 4, 5, 9.
|
|
90
|
+
|
|
91
|
+
### `thoughtleaders_brand`
|
|
92
|
+
|
|
93
|
+
| Column | Type | Description |
|
|
94
|
+
|--------|------|-------------|
|
|
95
|
+
| `id` | int | Primary key |
|
|
96
|
+
| `name` | varchar | Brand name |
|
|
97
|
+
| `description` | text | Brand description |
|
|
98
|
+
| `creator_id` | int FK | User who created it |
|
|
99
|
+
|
|
100
|
+
#### Junction Tables
|
|
101
|
+
|
|
102
|
+
| Table | Columns | Purpose |
|
|
103
|
+
|-------|---------|---------|
|
|
104
|
+
| `thoughtleaders_profile_brands` | `profile_id`, `brand_id` | Profile↔Brand M2M (Django field `profile.brands`). In practice each profile has one brand attached. |
|
|
105
|
+
| `thoughtleaders_brand_brands` | `from_brand_id`, `to_brand_id` | Self-referential: related brands. |
|
|
106
|
+
|
|
107
|
+
### `thoughtleaders_adspot` (Ad Catalogue)
|
|
108
|
+
|
|
109
|
+
Buyable ad placements. Each adspot links a channel to a seller. Price/cost here are **list prices** — actual deal values live on the adlink.
|
|
110
|
+
|
|
111
|
+
A channel can have multiple adspots (different sellers: talent manager, direct, multiple agencies).
|
|
112
|
+
|
|
113
|
+
| Column | Type | Description |
|
|
114
|
+
|--------|------|-------------|
|
|
115
|
+
| `id` | int | Primary key |
|
|
116
|
+
| `channel_id` | int FK | → `thoughtleaders_channel.id` |
|
|
117
|
+
| `price` | numeric | List/catalogue price |
|
|
118
|
+
| `cost` | numeric | List/catalogue cost |
|
|
119
|
+
| `integration` | int | 1=YouTube Mentions (live reads). Only one active mention-type adspot per channel. |
|
|
120
|
+
| `is_active` | boolean | Active flag |
|
|
121
|
+
| `publisher_id` | int FK | → `auth_user.id` (NOT `thoughtleaders_profile.id` — see gotcha below) |
|
|
122
|
+
|
|
123
|
+
### `thoughtleaders_channel` (YouTube Channels)
|
|
124
|
+
|
|
125
|
+
| Column | Type | Description |
|
|
126
|
+
|--------|------|-------------|
|
|
127
|
+
| `id` | int | Primary key |
|
|
128
|
+
| `channel_name` | varchar | Display name |
|
|
129
|
+
| `external_channel_id` | varchar | YouTube channel ID (`UCxxxxxx`). ⚠️ There is NO `youtube_id` column. |
|
|
130
|
+
| `url` | varchar | Channel URL |
|
|
131
|
+
| `media_selling_network_join_date` | date/timestamptz | When channel joined MSN |
|
|
132
|
+
| `is_tl_channel` | boolean | True = TPP/VIP channel |
|
|
133
|
+
| `evergreenness` | float | Cached evergreen score |
|
|
134
|
+
|
|
135
|
+
### `auth_user` (Django Users)
|
|
136
|
+
|
|
137
|
+
Standard Django user table. Used for owner lookups.
|
|
138
|
+
|
|
139
|
+
| Column | Type | Description |
|
|
140
|
+
|--------|------|-------------|
|
|
141
|
+
| `id` | int | Primary key |
|
|
142
|
+
| `first_name` | varchar | First name |
|
|
143
|
+
| `last_name` | varchar | Last name |
|
|
144
|
+
| `email` | varchar | Email |
|
|
145
|
+
|
|
146
|
+
## Top Tables by Row Count
|
|
147
|
+
|
|
148
|
+
| Rows | Table | Purpose |
|
|
149
|
+
|------|-------|---------|
|
|
150
|
+
| 1.3M | `thoughtleaders_channel` | YouTube channels |
|
|
151
|
+
| 1.2M | `thoughtleaders_historicaladlink` | Audit trail for adlink changes |
|
|
152
|
+
| 150K | `thoughtleaders_adlink` | Deals/sponsorships |
|
|
153
|
+
| 43K | `thoughtleaders_adspot` | Ad placements |
|
|
154
|
+
| 20K | `auth_user` | Users (team + external) |
|
|
155
|
+
| 20K | `thoughtleaders_profile` | User profiles |
|
|
156
|
+
| 19K | `thoughtleaders_organization` | Organizations |
|
|
157
|
+
| 19K | `dashboard_campaign` | Campaign groupings |
|
|
158
|
+
| 13K | `thoughtleaders_dailymetric` | Daily performance metrics |
|
|
159
|
+
| 12K | `thoughtleaders_leads` | Sales leads |
|
|
160
|
+
|
|
161
|
+
## Key Relationships
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
thoughtleaders_adlink
|
|
165
|
+
├── ad_spot_id → thoughtleaders_adspot.id
|
|
166
|
+
│ └── channel_id → thoughtleaders_channel.id
|
|
167
|
+
├── owner_advertiser_id → auth_user.id
|
|
168
|
+
├── owner_publisher_id → auth_user.id
|
|
169
|
+
├── owner_sales_id → auth_user.id
|
|
170
|
+
└── creator_profile_id → thoughtleaders_profile.id
|
|
171
|
+
├── organization_id → thoughtleaders_organization.id
|
|
172
|
+
└── profile_brands.profile_id → brand.id
|
|
173
|
+
|
|
174
|
+
⚠️ thoughtleaders_adlink has NO direct brand_id, organization_id, or channel_id column.
|
|
175
|
+
⚠️ thoughtleaders_brand has NO organization_id column — org lives on profile.
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Common Join Paths
|
|
179
|
+
|
|
180
|
+
**Adlink → Channel name:**
|
|
181
|
+
```sql
|
|
182
|
+
JOIN thoughtleaders_adspot s ON a.ad_spot_id = s.id
|
|
183
|
+
JOIN thoughtleaders_channel ch ON s.channel_id = ch.id
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Adlink → Brand name:**
|
|
187
|
+
```sql
|
|
188
|
+
JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
|
|
189
|
+
JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
|
|
190
|
+
JOIN thoughtleaders_brand b ON pb.brand_id = b.id
|
|
191
|
+
-- NEVER: JOIN brand b ON b.id = a.creator_profile_id (different ID spaces, returns wrong data)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Adlink → Organization:**
|
|
195
|
+
```sql
|
|
196
|
+
JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
|
|
197
|
+
JOIN thoughtleaders_organization o ON p.organization_id = o.id
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
🚨 **`adspot.publisher_id` is a FK to `auth_user`, not `profile`.** To get the publisher's profile, join through user:
|
|
201
|
+
```sql
|
|
202
|
+
JOIN auth_user au ON au.id = adspot.publisher_id
|
|
203
|
+
JOIN thoughtleaders_profile p ON p.user_id = adspot.publisher_id
|
|
204
|
+
```
|
|
205
|
+
Joining `adspot.publisher_id → profile.id` directly mixes ID spaces and returns garbage.
|
|
206
|
+
|
|
207
|
+
## `thoughtleaders_profile` persona constants
|
|
208
|
+
|
|
209
|
+
| Value | Label |
|
|
210
|
+
|-------|-------|
|
|
211
|
+
| 1 | Direct Brand |
|
|
212
|
+
| 2 | Creator |
|
|
213
|
+
| 3 | Talent Manager |
|
|
214
|
+
| 4 | Media Agency |
|
|
215
|
+
| 5 | Creator Service |
|
|
216
|
+
|
|
217
|
+
## `thoughtleaders_profile_channels` (Profile ↔ Channel M2M)
|
|
218
|
+
|
|
219
|
+
| Column | Type |
|
|
220
|
+
|--------|------|
|
|
221
|
+
| `id` | int PK |
|
|
222
|
+
| `profile_id` | int FK |
|
|
223
|
+
| `channel_id` | int FK |
|
|
224
|
+
|
|
225
|
+
Note: separate from the adspot publisher relationship. Not always in sync.
|
|
226
|
+
|
|
227
|
+
## Example queries
|
|
228
|
+
|
|
229
|
+
**Total weighted pipeline by sales rep:**
|
|
230
|
+
```sql
|
|
231
|
+
SELECT owner_sales_id, SUM(weighted_price) AS pipeline
|
|
232
|
+
FROM thoughtleaders_adlink
|
|
233
|
+
WHERE publish_status IN (0, 2, 6, 7, 8)
|
|
234
|
+
GROUP BY owner_sales_id
|
|
235
|
+
ORDER BY pipeline DESC
|
|
236
|
+
LIMIT 100 OFFSET 0
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Sold deals this month:**
|
|
240
|
+
```sql
|
|
241
|
+
SELECT id, price, purchase_date, ad_spot_id, creator_profile_id
|
|
242
|
+
FROM thoughtleaders_adlink
|
|
243
|
+
WHERE publish_status = 3
|
|
244
|
+
AND purchase_date >= date_trunc('month', CURRENT_DATE)
|
|
245
|
+
ORDER BY purchase_date DESC
|
|
246
|
+
LIMIT 500 OFFSET 0
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**MSN channel joins this month:**
|
|
250
|
+
```sql
|
|
251
|
+
SELECT id, channel_name, media_selling_network_join_date
|
|
252
|
+
FROM thoughtleaders_channel
|
|
253
|
+
WHERE media_selling_network_join_date >= date_trunc('month', CURRENT_DATE)
|
|
254
|
+
ORDER BY media_selling_network_join_date DESC
|
|
255
|
+
LIMIT 500 OFFSET 0
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Deal with brand and channel name:**
|
|
259
|
+
```sql
|
|
260
|
+
SELECT a.id, a.price, a.publish_status, b.name AS brand, ch.channel_name
|
|
261
|
+
FROM thoughtleaders_adlink a
|
|
262
|
+
JOIN thoughtleaders_adspot s ON a.ad_spot_id = s.id
|
|
263
|
+
JOIN thoughtleaders_channel ch ON s.channel_id = ch.id
|
|
264
|
+
JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
|
|
265
|
+
JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
|
|
266
|
+
JOIN thoughtleaders_brand b ON pb.brand_id = b.id
|
|
267
|
+
WHERE a.id = 12345
|
|
268
|
+
LIMIT 1 OFFSET 0
|
|
269
|
+
```
|
tl_cli/auth/__init__.py
ADDED
|
File without changes
|
tl_cli/auth/commands.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Auth CLI commands: tl auth login/logout/status."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
|
|
7
|
+
from tl_cli.auth.login import login_browser, login_device_code
|
|
8
|
+
from tl_cli.auth.token_store import clear_tokens, load_tokens
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Authentication commands")
|
|
11
|
+
console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("login", help="Log in to ThoughtLeaders.")
|
|
15
|
+
def login_cmd() -> None:
|
|
16
|
+
"""Log in to ThoughtLeaders."""
|
|
17
|
+
console.print("[bold]How would you like to authenticate?[/bold]")
|
|
18
|
+
console.print(" [cyan]1[/cyan] — Browser on this machine (default)")
|
|
19
|
+
console.print(" [cyan]2[/cyan] — Device code (use a browser on another device)")
|
|
20
|
+
console.print()
|
|
21
|
+
choice = Prompt.ask("Choose", choices=["1", "2"], default="1", console=console)
|
|
22
|
+
|
|
23
|
+
if choice == "2":
|
|
24
|
+
login_device_code()
|
|
25
|
+
else:
|
|
26
|
+
login_browser()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("logout")
|
|
30
|
+
def logout_cmd() -> None:
|
|
31
|
+
"""Clear stored authentication tokens."""
|
|
32
|
+
clear_tokens()
|
|
33
|
+
console.print("[green]Logged out successfully.[/green]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("status")
|
|
37
|
+
def status_cmd() -> None:
|
|
38
|
+
"""Show current authentication status."""
|
|
39
|
+
tokens = load_tokens()
|
|
40
|
+
if not tokens:
|
|
41
|
+
console.print("[yellow]Not logged in.[/yellow] Run: tl auth login")
|
|
42
|
+
raise SystemExit(2)
|
|
43
|
+
|
|
44
|
+
if tokens.is_expired:
|
|
45
|
+
console.print(f"[yellow]Token expired.[/yellow] Logged in as: {tokens.email or 'unknown'}")
|
|
46
|
+
console.print("Run: tl auth login")
|
|
47
|
+
raise SystemExit(2)
|
|
48
|
+
|
|
49
|
+
console.print(f"[green]Authenticated[/green] as: {tokens.email or 'unknown'}")
|
tl_cli/auth/login.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Auth0 login flows: browser-based PKCE and headless device code."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import secrets
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import webbrowser
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from tl_cli.auth.pkce import generate_pkce_pair
|
|
15
|
+
from tl_cli.auth.token_store import StoredTokens, save_tokens
|
|
16
|
+
from tl_cli.config import get_config
|
|
17
|
+
|
|
18
|
+
console = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _CallbackResult:
|
|
23
|
+
"""Captured from the OAuth callback."""
|
|
24
|
+
|
|
25
|
+
code: str | None = None
|
|
26
|
+
error: str | None = None
|
|
27
|
+
state: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def login_browser() -> StoredTokens:
|
|
31
|
+
"""Run the Auth0 PKCE login flow with a local browser.
|
|
32
|
+
|
|
33
|
+
1. Generate PKCE pair + state
|
|
34
|
+
2. Start localhost callback server
|
|
35
|
+
3. Open browser to Auth0 /authorize
|
|
36
|
+
4. Wait for callback with authorization code
|
|
37
|
+
5. Exchange code for tokens
|
|
38
|
+
6. Store tokens
|
|
39
|
+
"""
|
|
40
|
+
config = get_config()
|
|
41
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
42
|
+
state = secrets.token_urlsafe(32)
|
|
43
|
+
result = _CallbackResult()
|
|
44
|
+
|
|
45
|
+
# Start callback server on the fixed port (must match Auth0 allowed callback URLs)
|
|
46
|
+
from tl_cli.config import DEFAULT_AUTH0_CALLBACK_PORT
|
|
47
|
+
server, port = _start_callback_server(result, state, DEFAULT_AUTH0_CALLBACK_PORT)
|
|
48
|
+
|
|
49
|
+
redirect_uri = f"http://localhost:{port}/callback"
|
|
50
|
+
|
|
51
|
+
# Build authorization URL
|
|
52
|
+
params = {
|
|
53
|
+
"response_type": "code",
|
|
54
|
+
"client_id": config.auth0_client_id,
|
|
55
|
+
"redirect_uri": redirect_uri,
|
|
56
|
+
"audience": config.auth0_audience,
|
|
57
|
+
"scope": "openid profile email offline_access",
|
|
58
|
+
"code_challenge": code_challenge,
|
|
59
|
+
"code_challenge_method": "S256",
|
|
60
|
+
"state": state,
|
|
61
|
+
}
|
|
62
|
+
auth_url = f"https://{config.auth0_domain}/authorize?{urllib.parse.urlencode(params)}"
|
|
63
|
+
|
|
64
|
+
console.print("[bold]Opening browser for login...[/bold]")
|
|
65
|
+
console.print(f"[dim]If the browser doesn't open, visit:[/dim]\n{auth_url}\n")
|
|
66
|
+
webbrowser.open(auth_url)
|
|
67
|
+
|
|
68
|
+
# Wait for callback (timeout after 120 seconds)
|
|
69
|
+
deadline = time.time() + 120
|
|
70
|
+
while result.code is None and result.error is None:
|
|
71
|
+
if time.time() > deadline:
|
|
72
|
+
server.shutdown()
|
|
73
|
+
console.print("[red]Login timed out. Please try again.[/red]")
|
|
74
|
+
raise SystemExit(1)
|
|
75
|
+
time.sleep(0.1)
|
|
76
|
+
|
|
77
|
+
server.shutdown()
|
|
78
|
+
|
|
79
|
+
if result.error:
|
|
80
|
+
console.print(f"[red]Login failed: {result.error}[/red]")
|
|
81
|
+
raise SystemExit(1)
|
|
82
|
+
|
|
83
|
+
# Exchange code for tokens
|
|
84
|
+
console.print("[dim]Exchanging authorization code...[/dim]")
|
|
85
|
+
tokens = _exchange_code(
|
|
86
|
+
code=result.code,
|
|
87
|
+
code_verifier=code_verifier,
|
|
88
|
+
redirect_uri=redirect_uri,
|
|
89
|
+
config=config,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
save_tokens(tokens)
|
|
93
|
+
console.print(f"[green]Logged in as {tokens.email or 'unknown'}[/green]")
|
|
94
|
+
return tokens
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login_device_code() -> StoredTokens:
|
|
98
|
+
"""Run the Auth0 Device Authorization Flow (RFC 8628).
|
|
99
|
+
|
|
100
|
+
Works on headless machines — the user authenticates via any browser on any device.
|
|
101
|
+
"""
|
|
102
|
+
config = get_config()
|
|
103
|
+
|
|
104
|
+
# Request a device code
|
|
105
|
+
response = httpx.post(
|
|
106
|
+
f"https://{config.auth0_domain}/oauth/device/code",
|
|
107
|
+
data={
|
|
108
|
+
"client_id": config.auth0_client_id,
|
|
109
|
+
"scope": "openid profile email offline_access",
|
|
110
|
+
"audience": config.auth0_audience,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if response.status_code != 200:
|
|
115
|
+
console.print(f"[red]Failed to start device login: {response.text}[/red]")
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
|
|
118
|
+
data = response.json()
|
|
119
|
+
device_code = data["device_code"]
|
|
120
|
+
user_code = data["user_code"]
|
|
121
|
+
verification_uri = data["verification_uri"]
|
|
122
|
+
verification_uri_complete = data.get("verification_uri_complete", verification_uri)
|
|
123
|
+
interval = data.get("interval", 5)
|
|
124
|
+
expires_in = data.get("expires_in", 900)
|
|
125
|
+
|
|
126
|
+
console.print()
|
|
127
|
+
console.print("[bold]To log in, open this URL on any device:[/bold]")
|
|
128
|
+
console.print(f" {verification_uri_complete}")
|
|
129
|
+
console.print()
|
|
130
|
+
console.print(f"[bold]And enter the code:[/bold] [cyan bold]{user_code}[/cyan bold]")
|
|
131
|
+
console.print()
|
|
132
|
+
console.print(f"[dim]The code expires in {expires_in // 60} minutes.[/dim]")
|
|
133
|
+
|
|
134
|
+
# Poll for token
|
|
135
|
+
deadline = time.time() + expires_in
|
|
136
|
+
while time.time() < deadline:
|
|
137
|
+
time.sleep(interval)
|
|
138
|
+
|
|
139
|
+
token_response = httpx.post(
|
|
140
|
+
f"https://{config.auth0_domain}/oauth/token",
|
|
141
|
+
data={
|
|
142
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
143
|
+
"device_code": device_code,
|
|
144
|
+
"client_id": config.auth0_client_id,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
token_data = token_response.json()
|
|
149
|
+
|
|
150
|
+
if token_response.status_code == 200:
|
|
151
|
+
# Extract email from ID token if present
|
|
152
|
+
email = None
|
|
153
|
+
id_token = token_data.get("id_token")
|
|
154
|
+
if id_token:
|
|
155
|
+
email = _extract_email_from_jwt(id_token)
|
|
156
|
+
|
|
157
|
+
tokens = StoredTokens(
|
|
158
|
+
access_token=token_data["access_token"],
|
|
159
|
+
refresh_token=token_data.get("refresh_token"),
|
|
160
|
+
expires_at=time.time() + token_data.get("expires_in", 3600),
|
|
161
|
+
email=email,
|
|
162
|
+
)
|
|
163
|
+
save_tokens(tokens)
|
|
164
|
+
console.print(f"\n[green]Logged in as {tokens.email or 'unknown'}[/green]")
|
|
165
|
+
return tokens
|
|
166
|
+
|
|
167
|
+
error = token_data.get("error")
|
|
168
|
+
if error == "authorization_pending":
|
|
169
|
+
continue
|
|
170
|
+
elif error == "slow_down":
|
|
171
|
+
interval += 5
|
|
172
|
+
continue
|
|
173
|
+
elif error == "expired_token":
|
|
174
|
+
console.print("[red]Device code expired. Please try again.[/red]")
|
|
175
|
+
raise SystemExit(1)
|
|
176
|
+
elif error == "access_denied":
|
|
177
|
+
console.print("[red]Login was denied.[/red]")
|
|
178
|
+
raise SystemExit(1)
|
|
179
|
+
else:
|
|
180
|
+
console.print(f"[red]Login failed: {token_data.get('error_description', error)}[/red]")
|
|
181
|
+
raise SystemExit(1)
|
|
182
|
+
|
|
183
|
+
console.print("[red]Login timed out. Please try again.[/red]")
|
|
184
|
+
raise SystemExit(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def refresh_access_token(refresh_token: str) -> StoredTokens:
|
|
188
|
+
"""Use a refresh token to get a new access token."""
|
|
189
|
+
config = get_config()
|
|
190
|
+
|
|
191
|
+
response = httpx.post(
|
|
192
|
+
f"https://{config.auth0_domain}/oauth/token",
|
|
193
|
+
json={
|
|
194
|
+
"grant_type": "refresh_token",
|
|
195
|
+
"client_id": config.auth0_client_id,
|
|
196
|
+
"refresh_token": refresh_token,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if response.status_code != 200:
|
|
201
|
+
console.print("[red]Token refresh failed. Please run: tl auth login[/red]")
|
|
202
|
+
raise SystemExit(2)
|
|
203
|
+
|
|
204
|
+
data = response.json()
|
|
205
|
+
tokens = StoredTokens(
|
|
206
|
+
access_token=data["access_token"],
|
|
207
|
+
refresh_token=data.get("refresh_token", refresh_token),
|
|
208
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
209
|
+
email=None, # Not returned on refresh
|
|
210
|
+
)
|
|
211
|
+
save_tokens(tokens)
|
|
212
|
+
return tokens
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _exchange_code(
|
|
216
|
+
code: str,
|
|
217
|
+
code_verifier: str,
|
|
218
|
+
redirect_uri: str,
|
|
219
|
+
config,
|
|
220
|
+
) -> StoredTokens:
|
|
221
|
+
"""Exchange authorization code for tokens."""
|
|
222
|
+
response = httpx.post(
|
|
223
|
+
f"https://{config.auth0_domain}/oauth/token",
|
|
224
|
+
json={
|
|
225
|
+
"grant_type": "authorization_code",
|
|
226
|
+
"client_id": config.auth0_client_id,
|
|
227
|
+
"code": code,
|
|
228
|
+
"code_verifier": code_verifier,
|
|
229
|
+
"redirect_uri": redirect_uri,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if response.status_code != 200:
|
|
234
|
+
console.print(f"[red]Token exchange failed: {response.text}[/red]")
|
|
235
|
+
raise SystemExit(1)
|
|
236
|
+
|
|
237
|
+
data = response.json()
|
|
238
|
+
|
|
239
|
+
# Decode email from ID token if present
|
|
240
|
+
email = None
|
|
241
|
+
id_token = data.get("id_token")
|
|
242
|
+
if id_token:
|
|
243
|
+
email = _extract_email_from_jwt(id_token)
|
|
244
|
+
|
|
245
|
+
return StoredTokens(
|
|
246
|
+
access_token=data["access_token"],
|
|
247
|
+
refresh_token=data.get("refresh_token"),
|
|
248
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
249
|
+
email=email,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_email_from_jwt(token: str) -> str | None:
|
|
254
|
+
"""Extract email from JWT payload without full verification (already trusted from Auth0)."""
|
|
255
|
+
import base64
|
|
256
|
+
import json
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
payload_part = token.split(".")[1]
|
|
260
|
+
# Add padding
|
|
261
|
+
padding = 4 - len(payload_part) % 4
|
|
262
|
+
payload_part += "=" * padding
|
|
263
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_part))
|
|
264
|
+
return payload.get("email")
|
|
265
|
+
except Exception:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _start_callback_server(
|
|
270
|
+
result: _CallbackResult, expected_state: str, port: int = 0
|
|
271
|
+
) -> tuple[http.server.HTTPServer, int]:
|
|
272
|
+
"""Start a temporary HTTP server to receive the OAuth callback."""
|
|
273
|
+
|
|
274
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
275
|
+
def do_GET(self):
|
|
276
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
277
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
278
|
+
|
|
279
|
+
if parsed.path != "/callback":
|
|
280
|
+
self.send_response(404)
|
|
281
|
+
self.end_headers()
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Check state
|
|
285
|
+
received_state = params.get("state", [None])[0]
|
|
286
|
+
if received_state != expected_state:
|
|
287
|
+
result.error = "State mismatch — possible CSRF attack"
|
|
288
|
+
self._respond("Login failed: state mismatch.")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
if "error" in params:
|
|
292
|
+
result.error = params["error"][0]
|
|
293
|
+
desc = params.get("error_description", [""])[0]
|
|
294
|
+
self._respond(f"Login failed: {desc or result.error}")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
code = params.get("code", [None])[0]
|
|
298
|
+
if not code:
|
|
299
|
+
result.error = "No authorization code received"
|
|
300
|
+
self._respond("Login failed: no code received.")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
result.code = code
|
|
304
|
+
self._respond(
|
|
305
|
+
"Login successful! You can close this tab and return to the terminal."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _respond(self, message: str):
|
|
309
|
+
self.send_response(200)
|
|
310
|
+
self.send_header("Content-Type", "text/html")
|
|
311
|
+
self.end_headers()
|
|
312
|
+
html = f"""<!DOCTYPE html>
|
|
313
|
+
<html><head><title>TL CLI Login</title></head>
|
|
314
|
+
<body style="font-family: system-ui; text-align: center; padding: 60px;">
|
|
315
|
+
<h2>{message}</h2>
|
|
316
|
+
</body></html>"""
|
|
317
|
+
self.wfile.write(html.encode())
|
|
318
|
+
|
|
319
|
+
def log_message(self, format, *args):
|
|
320
|
+
pass # Suppress HTTP logs
|
|
321
|
+
|
|
322
|
+
server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler)
|
|
323
|
+
port = server.server_address[1]
|
|
324
|
+
|
|
325
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
326
|
+
thread.start()
|
|
327
|
+
|
|
328
|
+
return server, port
|
tl_cli/auth/pkce.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""PKCE (Proof Key for Code Exchange) utilities for OAuth 2.1."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
9
|
+
"""Generate a PKCE code_verifier and code_challenge (S256).
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
(code_verifier, code_challenge) tuple.
|
|
13
|
+
"""
|
|
14
|
+
# code_verifier: 43-128 characters, URL-safe
|
|
15
|
+
code_verifier = secrets.token_urlsafe(64)
|
|
16
|
+
|
|
17
|
+
# code_challenge: SHA256 hash of verifier, base64url-encoded (no padding)
|
|
18
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
19
|
+
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
20
|
+
|
|
21
|
+
return code_verifier, code_challenge
|