Skip to content

Commit 6d297e9

Browse files
✨ Cloud authentication, basic status bar and project linking/unlinking (#32)
1 parent 2075bc9 commit 6d297e9

40 files changed

Lines changed: 5350 additions & 79 deletions

.vscode-test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default defineConfig({
2222
"**/core/filesystem.js",
2323
"**/core/index.js",
2424
"**/telemetry/types.js",
25+
"**/cloud/types.js",
2526
// VSCode-dependent files (require mocking, not unit testable)
2627
"**/extension.js",
2728
"**/appDiscovery.js",

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ CodeLens links appear above HTTP client calls like `client.get('/items')`, letti
3030
|---------|-------------|---------|
3131
| `fastapi.entryPoint` | Path to the main FastAPI application file (e.g., `src/main.py`). If not set, the extension searches common locations: `main.py`, `app/main.py`, `api/main.py`, `src/main.py`, `backend/app/main.py`. | `""` (auto-detect) |
3232
| `fastapi.codeLens.enabled` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` |
33+
| `fastapi.cloud.enabled` | Enable FastAPI Cloud integration (status bar, deploy commands). | `true` |
3334
| `fastapi.telemetry.enabled` | Send anonymous usage data to help improve the extension. See [TELEMETRY.md](TELEMETRY.md) for details on what is collected. | `true` |
3435

3536
**Note:** Currently the extension discovers one FastAPI app per workspace folder. If you have multiple apps, use separate workspace folders or configure `fastapi.entryPoint` to point to your primary app.

bun.lock

Lines changed: 56 additions & 52 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

esbuild.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ async function main() {
9494
"posthog-node",
9595
"util",
9696
"child_process",
97+
"node:util",
98+
"node:child_process",
9799
],
98100
})
99101

package.json

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"main": "./dist/extension.js",
2020
"browser": "./dist/web/extension.js",
2121
"activationEvents": [
22-
"workspaceContains:**/*.py"
22+
"workspaceContains:**/*.py",
23+
"workspaceContains:.fastapicloud/cloud.json"
2324
],
2425
"categories": [
2526
"Other"
@@ -31,6 +32,12 @@
3132
}
3233
},
3334
"contributes": {
35+
"authentication": [
36+
{
37+
"id": "fastapi-vscode",
38+
"label": "FastAPI Cloud"
39+
}
40+
],
3441
"commands": [
3542
{
3643
"command": "fastapi-vscode.refreshEndpoints",
@@ -64,6 +71,26 @@
6471
"title": "Search Endpoints...",
6572
"category": "FastAPI",
6673
"icon": "$(search)"
74+
},
75+
{
76+
"command": "fastapi-vscode.linkApp",
77+
"title": "Link Project",
78+
"category": "FastAPI Cloud"
79+
},
80+
{
81+
"command": "fastapi-vscode.unlinkApp",
82+
"title": "Unlink Project",
83+
"category": "FastAPI Cloud"
84+
},
85+
{
86+
"command": "fastapi-vscode.signIn",
87+
"title": "Sign In",
88+
"category": "FastAPI Cloud"
89+
},
90+
{
91+
"command": "fastapi-vscode.signOut",
92+
"title": "Sign Out",
93+
"category": "FastAPI Cloud"
6794
}
6895
],
6996
"keybindings": [
@@ -82,6 +109,22 @@
82109
{
83110
"command": "fastapi-vscode.goToRouter",
84111
"when": "false"
112+
},
113+
{
114+
"command": "fastapi-vscode.linkApp",
115+
"when": "config.fastapi.cloud.enabled"
116+
},
117+
{
118+
"command": "fastapi-vscode.unlinkApp",
119+
"when": "config.fastapi.cloud.enabled"
120+
},
121+
{
122+
"command": "fastapi-vscode.signIn",
123+
"when": "config.fastapi.cloud.enabled"
124+
},
125+
{
126+
"command": "fastapi-vscode.signOut",
127+
"when": "config.fastapi.cloud.enabled"
85128
}
86129
],
87130
"view/title": [
@@ -209,6 +252,12 @@
209252
"scope": "resource",
210253
"description": "Show CodeLens links above test client calls (e.g., client.get('/items')) to navigate to the corresponding route definition."
211254
},
255+
"fastapi.cloud.enabled": {
256+
"type": "boolean",
257+
"default": true,
258+
"scope": "window",
259+
"description": "Enable FastAPI Cloud integration (status bar, link/unlink commands)."
260+
},
212261
"fastapi.telemetry.enabled": {
213262
"type": "boolean",
214263
"default": true,
@@ -234,6 +283,7 @@
234283
"@biomejs/biome": "^2.3.11",
235284
"@types/bun": "latest",
236285
"@types/mocha": "^10.0.10",
286+
"@types/sinon": "^21.0.0",
237287
"@types/vscode": "^1.85.0",
238288
"@vscode/test-cli": "^0.0.12",
239289
"@vscode/test-electron": "^2.5.2",
@@ -242,6 +292,7 @@
242292
"husky": "^9.1.7",
243293
"lint-staged": "^16.2.7",
244294
"path-browserify": "^1.0.1",
295+
"sinon": "^21.0.1",
245296
"typescript": "^5.0.0"
246297
},
247298
"dependencies": {
@@ -250,7 +301,7 @@
250301
"web-tree-sitter": "^0.26.3"
251302
},
252303
"lint-staged": {
253-
"*.{ts,js,json}": [
304+
"**/*.{ts,js,json}": [
254305
"biome check --write"
255306
]
256307
}

src/appDiscovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import { buildRouterGraph } from "./core/routerResolver"
1212
import { routerNodeToAppDefinition } from "./core/transformer"
1313
import { collectRoutes, countRouters } from "./core/treeUtils"
1414
import type { AppDefinition } from "./core/types"
15-
import { vscodeFileSystem } from "./providers/vscodeFileSystem"
1615
import { log } from "./utils/logger"
1716
import { createTimer, trackEntrypointDetected } from "./utils/telemetry"
17+
import { vscodeFileSystem } from "./vscode/vscodeFileSystem"
1818

1919
export type { EntryPoint }
2020

src/cloud/api.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import * as vscode from "vscode"
2+
import { getExtensionVersion } from "../extension"
3+
import { AUTH_PROVIDER_ID } from "./auth"
4+
import type {
5+
App,
6+
Deployment,
7+
ListResponse,
8+
Team,
9+
UploadInfo,
10+
User,
11+
} from "./types"
12+
13+
export const BASE_URL = "https://api.fastapicloud.com/api/v1"
14+
export const DASHBOARD_URL = "https://dashboard.fastapicloud.com"
15+
16+
function getUserAgentHeaders(): Record<string, string> {
17+
if (vscode.env.uiKind === vscode.UIKind.Web) return {}
18+
return { "User-Agent": `fastapi-vscode/${getExtensionVersion()}` }
19+
}
20+
21+
export class ApiService {
22+
static getDashboardUrl(teamSlug: string, appSlug: string): string {
23+
return `${DASHBOARD_URL}/${teamSlug}/apps/${appSlug}/general`
24+
}
25+
26+
private async request<T>(
27+
endpoint: string,
28+
options: RequestInit = {},
29+
): Promise<T> {
30+
const session = await vscode.authentication.getSession(
31+
AUTH_PROVIDER_ID,
32+
[],
33+
{ silent: true },
34+
)
35+
if (!session) {
36+
throw new Error("Not authenticated")
37+
}
38+
const token = session.accessToken
39+
40+
const response = await fetch(`${BASE_URL}${endpoint}`, {
41+
...options,
42+
headers: {
43+
Authorization: `Bearer ${token}`,
44+
"Content-Type": "application/json",
45+
...getUserAgentHeaders(),
46+
...options.headers,
47+
},
48+
})
49+
50+
if (!response.ok) {
51+
throw new Error(
52+
`API request failed: ${options.method || "GET"} ${endpoint} returned ${response.status}`,
53+
)
54+
}
55+
56+
return response.json() as Promise<T>
57+
}
58+
59+
static async getUser(token: string): Promise<User | null> {
60+
try {
61+
const response = await fetch(`${BASE_URL}/users/me`, {
62+
headers: {
63+
Authorization: `Bearer ${token}`,
64+
"Content-Type": "application/json",
65+
...getUserAgentHeaders(),
66+
},
67+
})
68+
if (!response.ok) return null
69+
return (await response.json()) as User
70+
} catch {
71+
return null
72+
}
73+
}
74+
75+
async getTeams(): Promise<Team[]> {
76+
const data = await this.request<ListResponse<Team>>("/teams")
77+
return data.data
78+
}
79+
80+
async getTeam(teamId: string): Promise<Team> {
81+
return this.request<Team>(`/teams/${teamId}/`)
82+
}
83+
84+
async getApps(teamId: string): Promise<App[]> {
85+
const data = await this.request<ListResponse<App>>(
86+
`/apps/?team_id=${teamId}`,
87+
)
88+
return data.data
89+
}
90+
91+
async getApp(appId: string): Promise<App> {
92+
return this.request<App>(`/apps/${appId}`)
93+
}
94+
95+
async createApp(teamId: string, name: string): Promise<App> {
96+
return this.request<App>("/apps/", {
97+
method: "POST",
98+
body: JSON.stringify({ team_id: teamId, name }),
99+
})
100+
}
101+
102+
async createDeployment(appId: string): Promise<Deployment> {
103+
return this.request<Deployment>(`/apps/${appId}/deployments/`, {
104+
method: "POST",
105+
})
106+
}
107+
108+
async getUploadUrl(deploymentId: string): Promise<UploadInfo> {
109+
return this.request<UploadInfo>(`/deployments/${deploymentId}/upload`, {
110+
method: "POST",
111+
})
112+
}
113+
114+
async completeUpload(deploymentId: string): Promise<void> {
115+
await this.request<void>(`/deployments/${deploymentId}/upload-complete`, {
116+
method: "POST",
117+
})
118+
}
119+
120+
async getDeployment(
121+
appId: string,
122+
deploymentId: string,
123+
): Promise<Deployment> {
124+
return this.request<Deployment>(
125+
`/apps/${appId}/deployments/${deploymentId}/`,
126+
)
127+
}
128+
129+
static async requestDeviceCode(clientId: string): Promise<{
130+
device_code: string
131+
user_code: string
132+
verification_uri: string
133+
verification_uri_complete?: string
134+
expires_in?: number
135+
interval?: number
136+
}> {
137+
const response = await fetch(`${BASE_URL}/login/device/authorization`, {
138+
method: "POST",
139+
headers: {
140+
"Content-Type": "application/x-www-form-urlencoded",
141+
...getUserAgentHeaders(),
142+
},
143+
body: new URLSearchParams({ client_id: clientId }).toString(),
144+
})
145+
146+
if (!response.ok) {
147+
throw new Error(
148+
`Device code request failed: ${response.status} ${response.statusText}`,
149+
)
150+
}
151+
152+
const data = (await response.json()) as {
153+
device_code?: string
154+
user_code?: string
155+
verification_uri?: string
156+
verification_uri_complete?: string
157+
expires_in?: number
158+
interval?: number
159+
}
160+
161+
if (!data.device_code || !data.user_code || !data.verification_uri) {
162+
throw new Error("Invalid response from device code endpoint")
163+
}
164+
return {
165+
device_code: data.device_code,
166+
user_code: data.user_code,
167+
verification_uri: data.verification_uri,
168+
verification_uri_complete: data.verification_uri_complete ?? "",
169+
expires_in: data.expires_in,
170+
interval: data.interval,
171+
}
172+
}
173+
174+
static async pollDeviceToken(
175+
clientId: string,
176+
deviceCode: string,
177+
intervalMs = 5000,
178+
signal?: AbortSignal,
179+
): Promise<string> {
180+
while (true) {
181+
if (signal?.aborted) {
182+
throw new Error("Sign-in cancelled")
183+
}
184+
185+
const response = await fetch(`${BASE_URL}/login/device/token`, {
186+
method: "POST",
187+
headers: {
188+
"Content-Type": "application/x-www-form-urlencoded",
189+
...getUserAgentHeaders(),
190+
},
191+
body: new URLSearchParams({
192+
client_id: clientId,
193+
device_code: deviceCode,
194+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
195+
}).toString(),
196+
signal,
197+
})
198+
199+
const data = (await response.json()) as {
200+
access_token?: string
201+
error?: string
202+
}
203+
204+
if (response.ok && data.access_token) {
205+
return data.access_token
206+
}
207+
if (data.error === "authorization_pending") {
208+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
209+
} else if (data.error === "expired_token") {
210+
throw new Error("Device code has expired")
211+
} else {
212+
throw new Error(
213+
`Device token request failed: ${data.error || response.statusText}`,
214+
)
215+
}
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)