Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9613e1e
Pull auth, linking into separate PR
savannahostrowski Jan 29, 2026
500ddcd
Simplify auth and make it work in vscode.dev
savannahostrowski Jan 29, 2026
c59546c
Fix signin for vscode.dev
savannahostrowski Jan 30, 2026
4bdbf6f
Fix account menu
savannahostrowski Jan 30, 2026
ca4b3c6
Clean up extension.ts comments
savannahostrowski Jan 30, 2026
d7a0f46
Clean up telemetry
savannahostrowski Jan 30, 2026
89ee8a9
Simplify testUtils for cloud
savannahostrowski Jan 30, 2026
dcfc94f
Update test to remove logs
savannahostrowski Jan 30, 2026
9acf670
Clean up constants
savannahostrowski Jan 30, 2026
3ea95f6
Move getExtensionVersion to extension.ts
savannahostrowski Jan 30, 2026
8506ac3
Move fetch user call to api.ts
savannahostrowski Jan 30, 2026
0bec24b
Rename to getUser and User
savannahostrowski Jan 30, 2026
767c6ee
Move upload
savannahostrowski Jan 30, 2026
3d6d55f
Clean up api.ts
savannahostrowski Jan 30, 2026
f2015bc
Clean up cloud controller
savannahostrowski Jan 30, 2026
7ffc3df
Clean up config.ts
savannahostrowski Jan 30, 2026
62e27b4
More cleanup for config logging
savannahostrowski Jan 30, 2026
74c41b7
More test cleanup
savannahostrowski Jan 30, 2026
ddcb14e
Fix polling
savannahostrowski Jan 30, 2026
e120bac
More test cleanup
savannahostrowski Jan 30, 2026
dbe831c
Remove noisy logging
savannahostrowski Jan 30, 2026
d5df298
Big big big cleanup for multi-root
savannahostrowski Feb 2, 2026
f8b732a
Use constants
savannahostrowski Feb 2, 2026
c042f19
Create a constants file
savannahostrowski Feb 2, 2026
0e910e2
Remove duplicate guard
savannahostrowski Feb 2, 2026
e270a8c
Group constants
savannahostrowski Feb 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default defineConfig({
"**/core/filesystem.js",
"**/core/index.js",
"**/telemetry/types.js",
"**/cloud/types.js",
// VSCode-dependent files (require mocking, not unit testable)
"**/extension.js",
"**/appDiscovery.js",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ CodeLens links appear above HTTP client calls like `client.get('/items')`, letti
|---------|-------------|---------|
| `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) |
| `fastapi.codeLens.enabled` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` |
| `fastapi.cloud.enabled` | Enable FastAPI Cloud integration (status bar, deploy commands). | `true` |
| `fastapi.telemetry.enabled` | Send anonymous usage data to help improve the extension. See [TELEMETRY.md](TELEMETRY.md) for details on what is collected. | `true` |

**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.
Expand Down
108 changes: 56 additions & 52 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ async function main() {
"posthog-node",
"util",
"child_process",
"node:util",
"node:child_process",
],
})

Expand Down
55 changes: 53 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"main": "./dist/extension.js",
"browser": "./dist/web/extension.js",
"activationEvents": [
"workspaceContains:**/*.py"
"workspaceContains:**/*.py",
"workspaceContains:.fastapicloud/cloud.json"
],
"categories": [
"Other"
Expand All @@ -31,6 +32,12 @@
}
},
"contributes": {
"authentication": [
{
"id": "fastapi-vscode",
"label": "FastAPI Cloud"
}
],
"commands": [
{
"command": "fastapi-vscode.refreshEndpoints",
Expand Down Expand Up @@ -64,6 +71,26 @@
"title": "Search Endpoints...",
"category": "FastAPI",
"icon": "$(search)"
},
{
"command": "fastapi-vscode.linkApp",
"title": "Link Project",
"category": "FastAPI Cloud"
},
{
"command": "fastapi-vscode.unlinkApp",
"title": "Unlink Project",
"category": "FastAPI Cloud"
},
{
"command": "fastapi-vscode.signIn",
"title": "Sign In",
"category": "FastAPI Cloud"
},
{
"command": "fastapi-vscode.signOut",
"title": "Sign Out",
"category": "FastAPI Cloud"
}
],
"keybindings": [
Expand All @@ -82,6 +109,22 @@
{
"command": "fastapi-vscode.goToRouter",
"when": "false"
},
{
"command": "fastapi-vscode.linkApp",
"when": "config.fastapi.cloud.enabled"
},
{
"command": "fastapi-vscode.unlinkApp",
"when": "config.fastapi.cloud.enabled"
},
{
"command": "fastapi-vscode.signIn",
"when": "config.fastapi.cloud.enabled"
},
{
"command": "fastapi-vscode.signOut",
"when": "config.fastapi.cloud.enabled"
}
],
"view/title": [
Expand Down Expand Up @@ -209,6 +252,12 @@
"scope": "resource",
"description": "Show CodeLens links above test client calls (e.g., client.get('/items')) to navigate to the corresponding route definition."
},
"fastapi.cloud.enabled": {
"type": "boolean",
"default": true,
"scope": "window",
"description": "Enable FastAPI Cloud integration (status bar, link/unlink commands)."
},
"fastapi.telemetry.enabled": {
"type": "boolean",
"default": true,
Expand All @@ -234,6 +283,7 @@
"@biomejs/biome": "^2.3.11",
"@types/bun": "latest",
"@types/mocha": "^10.0.10",
"@types/sinon": "^21.0.0",
"@types/vscode": "^1.85.0",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
Expand All @@ -242,6 +292,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"path-browserify": "^1.0.1",
"sinon": "^21.0.1",
"typescript": "^5.0.0"
},
"dependencies": {
Expand All @@ -250,7 +301,7 @@
"web-tree-sitter": "^0.26.3"
},
"lint-staged": {
"*.{ts,js,json}": [
"**/*.{ts,js,json}": [
"biome check --write"
]
}
Expand Down
2 changes: 1 addition & 1 deletion src/appDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { buildRouterGraph } from "./core/routerResolver"
import { routerNodeToAppDefinition } from "./core/transformer"
import { collectRoutes, countRouters } from "./core/treeUtils"
import type { AppDefinition } from "./core/types"
import { vscodeFileSystem } from "./providers/vscodeFileSystem"
import { log } from "./utils/logger"
import { createTimer, trackEntrypointDetected } from "./utils/telemetry"
import { vscodeFileSystem } from "./vscode/vscodeFileSystem"

export type { EntryPoint }

Expand Down
218 changes: 218 additions & 0 deletions src/cloud/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import * as vscode from "vscode"
import { getExtensionVersion } from "../extension"
import { AUTH_PROVIDER_ID } from "./auth"
import type {
App,
Deployment,
ListResponse,
Team,
UploadInfo,
User,
} from "./types"

export const BASE_URL = "https://api.fastapicloud.com/api/v1"
export const DASHBOARD_URL = "https://dashboard.fastapicloud.com"

function getUserAgentHeaders(): Record<string, string> {
if (vscode.env.uiKind === vscode.UIKind.Web) return {}
return { "User-Agent": `fastapi-vscode/${getExtensionVersion()}` }
}

export class ApiService {
static getDashboardUrl(teamSlug: string, appSlug: string): string {
return `${DASHBOARD_URL}/${teamSlug}/apps/${appSlug}/general`
}

private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const session = await vscode.authentication.getSession(
AUTH_PROVIDER_ID,
[],
{ silent: true },
)
if (!session) {
throw new Error("Not authenticated")
}
const token = session.accessToken

const response = await fetch(`${BASE_URL}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...getUserAgentHeaders(),
...options.headers,
},
})

if (!response.ok) {
throw new Error(
`API request failed: ${options.method || "GET"} ${endpoint} returned ${response.status}`,
)
}

return response.json() as Promise<T>
}

static async getUser(token: string): Promise<User | null> {
try {
const response = await fetch(`${BASE_URL}/users/me`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...getUserAgentHeaders(),
},
})
if (!response.ok) return null
return (await response.json()) as User
} catch {
return null
}
}

async getTeams(): Promise<Team[]> {
const data = await this.request<ListResponse<Team>>("/teams")
return data.data
}

async getTeam(teamId: string): Promise<Team> {
return this.request<Team>(`/teams/${teamId}/`)
}

async getApps(teamId: string): Promise<App[]> {
const data = await this.request<ListResponse<App>>(
`/apps/?team_id=${teamId}`,
)
return data.data
}

async getApp(appId: string): Promise<App> {
return this.request<App>(`/apps/${appId}`)
}

async createApp(teamId: string, name: string): Promise<App> {
return this.request<App>("/apps/", {
method: "POST",
body: JSON.stringify({ team_id: teamId, name }),
})
}

async createDeployment(appId: string): Promise<Deployment> {
return this.request<Deployment>(`/apps/${appId}/deployments/`, {
method: "POST",
})
}

async getUploadUrl(deploymentId: string): Promise<UploadInfo> {
return this.request<UploadInfo>(`/deployments/${deploymentId}/upload`, {
method: "POST",
})
}

async completeUpload(deploymentId: string): Promise<void> {
await this.request<void>(`/deployments/${deploymentId}/upload-complete`, {
method: "POST",
})
}

async getDeployment(
appId: string,
deploymentId: string,
): Promise<Deployment> {
return this.request<Deployment>(
`/apps/${appId}/deployments/${deploymentId}/`,
)
}

static async requestDeviceCode(clientId: string): Promise<{
device_code: string
user_code: string
verification_uri: string
verification_uri_complete?: string
expires_in?: number
interval?: number
}> {
const response = await fetch(`${BASE_URL}/login/device/authorization`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...getUserAgentHeaders(),
},
body: new URLSearchParams({ client_id: clientId }).toString(),
})

if (!response.ok) {
throw new Error(
`Device code request failed: ${response.status} ${response.statusText}`,
)
}

const data = (await response.json()) as {
device_code?: string
user_code?: string
verification_uri?: string
verification_uri_complete?: string
expires_in?: number
interval?: number
}

if (!data.device_code || !data.user_code || !data.verification_uri) {
throw new Error("Invalid response from device code endpoint")
}
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
verification_uri_complete: data.verification_uri_complete ?? "",
expires_in: data.expires_in,
interval: data.interval,
}
}

static async pollDeviceToken(
clientId: string,
deviceCode: string,
intervalMs = 5000,
signal?: AbortSignal,
): Promise<string> {
while (true) {
if (signal?.aborted) {
throw new Error("Sign-in cancelled")
}

const response = await fetch(`${BASE_URL}/login/device/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...getUserAgentHeaders(),
},
body: new URLSearchParams({
client_id: clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}).toString(),
signal,
})

const data = (await response.json()) as {
access_token?: string
error?: string
}

if (response.ok && data.access_token) {
return data.access_token
}
if (data.error === "authorization_pending") {
await new Promise((resolve) => setTimeout(resolve, intervalMs))
} else if (data.error === "expired_token") {
throw new Error("Device code has expired")
} else {
throw new Error(
`Device token request failed: ${data.error || response.statusText}`,
)
}
}
}
}
Loading