Skip to content

Commit 85bc254

Browse files
✨ Add support for deployment to FastAPI Cloud (#34)
1 parent 1693f24 commit 85bc254

22 files changed

Lines changed: 1012 additions & 504 deletions

bun.lock

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

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "fastapi-vscode",
33
"displayName": "FastAPI Extension",
44
"description": "VS Code extension for FastAPI development",
5-
"version": "0.0.4",
5+
"version": "0.0.5",
66
"publisher": "FastAPILabs",
77
"license": "MIT",
88
"repository": {
@@ -91,6 +91,11 @@
9191
"command": "fastapi-vscode.signOut",
9292
"title": "Sign Out",
9393
"category": "FastAPI Cloud"
94+
},
95+
{
96+
"command": "fastapi-vscode.deploy",
97+
"title": "Deploy Application",
98+
"category": "FastAPI Cloud"
9499
}
95100
],
96101
"keybindings": [
@@ -122,6 +127,10 @@
122127
"command": "fastapi-vscode.signIn",
123128
"when": "config.fastapi.cloud.enabled"
124129
},
130+
{
131+
"command": "fastapi-vscode.deploy",
132+
"when": "config.fastapi.cloud.enabled"
133+
},
125134
{
126135
"command": "fastapi-vscode.signOut",
127136
"when": "config.fastapi.cloud.enabled"
@@ -298,6 +307,7 @@
298307
},
299308
"dependencies": {
300309
"posthog-node": "^5.24.1",
310+
"tinytar": "^0.1.0",
301311
"toml": "^3.0.0",
302312
"web-tree-sitter": "^0.26.3"
303313
},

src/cloud/commands/auth.ts

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,19 @@
1-
import * as vscode from "vscode"
21
import { trackCloudSignOut } from "../../utils/telemetry"
3-
import { AUTH_PROVIDER_ID } from "../auth"
4-
import { Auth, Button } from "../constants"
52
import type { AuthProvider } from "../types"
3+
import { ui } from "../ui/dialogs"
64

7-
export class AuthCommands {
8-
constructor(
9-
private authProvider: AuthProvider,
10-
private onStateChanged: () => void,
11-
) {}
5+
export async function signOut(authProvider: AuthProvider): Promise<boolean> {
6+
const confirm = await ui.showWarningMessage(
7+
"Sign out of FastAPI Cloud?",
8+
{ modal: true },
9+
"Sign Out",
10+
)
1211

13-
async signIn(): Promise<void> {
14-
await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], {
15-
createIfNone: true,
16-
})
12+
if (confirm === "Sign Out") {
13+
await authProvider.signOut()
14+
trackCloudSignOut()
15+
return true
1716
}
1817

19-
async signOut(): Promise<boolean> {
20-
const confirm = await vscode.window.showWarningMessage(
21-
Auth.MSG_SIGN_OUT_CONFIRM,
22-
{ modal: true },
23-
Button.SIGN_OUT,
24-
)
25-
26-
if (confirm === Button.SIGN_OUT) {
27-
await this.authProvider.signOut()
28-
trackCloudSignOut()
29-
this.onStateChanged()
30-
return true
31-
}
32-
33-
return false
34-
}
18+
return false
3519
}

src/cloud/commands/deploy.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// @ts-expect-error - tinytar has no type definitions
2+
import { tar } from "tinytar"
3+
import * as vscode from "vscode"
4+
import {
5+
trackCloudAppOpened,
6+
trackCloudDashboardOpened,
7+
} from "../../utils/telemetry"
8+
import type { ApiService } from "../api"
9+
import { AUTH_PROVIDER_ID } from "../auth"
10+
import type { ConfigService } from "../config"
11+
import {
12+
type App,
13+
type Config,
14+
type Deployment,
15+
DeploymentStatus,
16+
failedStatuses,
17+
statusMessages,
18+
} from "../types"
19+
import { ui } from "../ui/dialogs"
20+
import { createNewApp, pickExistingApp, pickTeam } from "../ui/pickers"
21+
22+
// Exclusion patterns - aligned with fastapi-cloud-cli
23+
// See: https://github.com/fastapilabs/fastapi-cloud-cli/blob/main/src/fastapi_cloud_cli/commands/deploy.py
24+
const EXCLUDE_PARTS = [
25+
".venv",
26+
"__pycache__",
27+
".mypy_cache",
28+
".pytest_cache",
29+
".git",
30+
".gitignore",
31+
".fastapicloudignore",
32+
]
33+
34+
// 300 attempts x 2 seconds = 10 minutes maximum
35+
const MAX_POLL_ATTEMPTS = 300
36+
const DEPLOYMENT_POLL_INTERVAL_MS = 2000
37+
38+
export function shouldExclude(relativePath: string): boolean {
39+
const parts = relativePath.split("/")
40+
41+
if (parts.some((part) => EXCLUDE_PARTS.includes(part))) {
42+
return true
43+
}
44+
45+
if (relativePath.endsWith(".pyc")) {
46+
return true
47+
}
48+
49+
const fileName = parts[parts.length - 1]
50+
if (fileName === ".env" || fileName.startsWith(".env.")) {
51+
return true
52+
}
53+
54+
return false
55+
}
56+
57+
export interface DeployContext {
58+
workspaceRoot: vscode.Uri | null
59+
configService: ConfigService
60+
apiService: ApiService
61+
statusBarItem: vscode.StatusBarItem
62+
}
63+
64+
export async function deploy(context: DeployContext): Promise<boolean> {
65+
const { workspaceRoot, configService, apiService, statusBarItem } = context
66+
67+
if (!workspaceRoot) {
68+
ui.showErrorMessage("No workspace folder open")
69+
return false
70+
}
71+
72+
const updateStatus = (text: string) => {
73+
statusBarItem.text = `$(sync~spin) ${text}`
74+
}
75+
76+
const session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], {
77+
silent: true,
78+
})
79+
if (!session) {
80+
const result = await ui.showErrorMessage(
81+
"Please sign in to FastAPI Cloud first.",
82+
"Sign In",
83+
)
84+
if (result === "Sign In") {
85+
vscode.commands.executeCommand("fastapi-vscode.signIn")
86+
}
87+
return false
88+
}
89+
90+
const existingConfig = await configService.getConfig(workspaceRoot)
91+
const config: Config = existingConfig ?? { app_id: "", team_id: "" }
92+
if (!config.app_id) {
93+
const team = await pickTeam(apiService)
94+
if (!team) return false
95+
96+
const choice = await ui.showQuickPick(
97+
[
98+
{
99+
label: "$(link) Link Existing App",
100+
description: "Connect to an app on FastAPI Cloud",
101+
id: "link",
102+
},
103+
{
104+
label: "$(add) Create New App",
105+
description: "Create a new app and link it",
106+
id: "create",
107+
},
108+
],
109+
{ placeHolder: "Set up FastAPI Cloud" },
110+
)
111+
if (!choice) return false
112+
113+
let app: App | null
114+
if (choice.id === "create") {
115+
const folderName = workspaceRoot.path.split("/").pop() || "my-app"
116+
app = await createNewApp(apiService, team, folderName)
117+
} else {
118+
app = await pickExistingApp(apiService, team)
119+
}
120+
if (!app) return false
121+
122+
config.app_id = app.id
123+
config.team_id = team.id
124+
config.app_slug = app.slug
125+
await configService.writeConfig(workspaceRoot, config)
126+
}
127+
128+
try {
129+
updateStatus("Creating deployment...")
130+
const deployment = await apiService.createDeployment(config.app_id)
131+
132+
updateStatus("Preparing files...")
133+
const archive = await createArchive(workspaceRoot)
134+
135+
updateStatus("Uploading...")
136+
const uploadInfo = await apiService.getUploadUrl(deployment.id)
137+
await uploadToS3(uploadInfo.url, uploadInfo.fields, archive)
138+
139+
updateStatus("Starting build...")
140+
await apiService.completeUpload(deployment.id)
141+
142+
// Poll for deployment status
143+
const result = await pollDeploymentStatus(
144+
apiService,
145+
config.app_id,
146+
deployment.id,
147+
updateStatus,
148+
)
149+
150+
if (result) {
151+
const action = await ui.showInformationMessage(
152+
"Deployed successfully!",
153+
"Open App",
154+
"View Dashboard",
155+
)
156+
157+
if (action === "Open App" && result.url) {
158+
vscode.env.openExternal(vscode.Uri.parse(result.url))
159+
trackCloudAppOpened(config.app_id)
160+
} else if (action === "View Dashboard" && result.dashboard_url) {
161+
vscode.env.openExternal(vscode.Uri.parse(result.dashboard_url))
162+
trackCloudDashboardOpened(config.app_id)
163+
}
164+
return true
165+
}
166+
if (statusBarItem) {
167+
statusBarItem.text = "$(cloud) Deploy failed"
168+
}
169+
const action = await vscode.window.showErrorMessage(
170+
"Deployment failed.",
171+
"View Logs",
172+
)
173+
if (action === "View Logs") {
174+
vscode.commands.executeCommand("fastapi-vscode.viewLogs")
175+
}
176+
return false
177+
} catch (error) {
178+
if (statusBarItem) {
179+
statusBarItem.text = "$(cloud) Deploy failed"
180+
}
181+
vscode.window.showErrorMessage(
182+
`Deploy failed: ${error instanceof Error ? error.message : "Unknown error"}`,
183+
)
184+
return false
185+
}
186+
}
187+
188+
async function createArchive(workspaceRoot: vscode.Uri): Promise<Uint8Array> {
189+
const files = await vscode.workspace.findFiles(
190+
new vscode.RelativePattern(workspaceRoot, "**/*"),
191+
"{**/.venv/**,**/__pycache__/**,**/.git/**}",
192+
)
193+
194+
const tarFiles: Array<{ name: string; data: Uint8Array }> = []
195+
196+
for (const file of files) {
197+
const relativePath = file.path.replace(`${workspaceRoot.path}/`, "")
198+
199+
if (shouldExclude(relativePath)) continue
200+
201+
try {
202+
const content = await vscode.workspace.fs.readFile(file)
203+
tarFiles.push({
204+
name: relativePath,
205+
data: new Uint8Array(content),
206+
})
207+
} catch {
208+
// Skip files we can't read
209+
}
210+
}
211+
212+
return tar(tarFiles) as Uint8Array
213+
}
214+
215+
async function uploadToS3(
216+
url: string,
217+
fields: Record<string, string>,
218+
archive: Uint8Array,
219+
): Promise<void> {
220+
const formData = new FormData()
221+
222+
for (const [key, value] of Object.entries(fields)) {
223+
formData.append(key, value)
224+
}
225+
226+
formData.append("file", new Blob([archive]))
227+
228+
const response = await fetch(url, {
229+
method: "POST",
230+
body: formData,
231+
})
232+
233+
if (!response.ok) {
234+
throw new Error(`Upload failed: ${response.status}`)
235+
}
236+
}
237+
238+
async function pollDeploymentStatus(
239+
apiService: ApiService,
240+
appId: string,
241+
deploymentId: string,
242+
updateStatus: (text: string) => void,
243+
): Promise<Deployment | null> {
244+
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
245+
const deployment = await apiService.getDeployment(appId, deploymentId)
246+
247+
if (
248+
deployment.status === DeploymentStatus.success ||
249+
deployment.status === DeploymentStatus.verifying_skipped
250+
) {
251+
return deployment
252+
}
253+
254+
if (failedStatuses.includes(deployment.status)) {
255+
return null
256+
}
257+
258+
const message =
259+
statusMessages[deployment.status] || `Status: ${deployment.status}`
260+
updateStatus(message)
261+
262+
await new Promise((resolve) =>
263+
setTimeout(resolve, DEPLOYMENT_POLL_INTERVAL_MS),
264+
)
265+
}
266+
267+
return null
268+
}

0 commit comments

Comments
 (0)