Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,23 @@ async function main() {
// internally references these Node.js modules for environment detection
// posthog-node uses Node.js APIs, so telemetry is disabled in browser
// util and child_process are used for version detection but not in browser
// os/path/fs/promises are imported by env.ts; loadEnvironment's
// try/catch swallows the require() failure at runtime in vscode.dev,
// so the user falls back to default URLs in the browser environment
external: [
"vscode",
"fs/promises",
"node:fs/promises",
"module",
"posthog-node",
"util",
"child_process",
"os",
"path",
"node:util",
"node:child_process",
"node:os",
"node:path",
],
})

Expand Down
29 changes: 16 additions & 13 deletions src/cloud/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vscode from "vscode"
import { DEFAULT_BASE_URL, DEFAULT_DASHBOARD_URL } from "../env"
import { getExtensionVersion } from "../extension"
import { log } from "../utils/logger"
import { AUTH_PROVIDER_ID } from "./auth"
Expand All @@ -11,9 +12,6 @@ import type {
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()}` }
Expand All @@ -33,8 +31,13 @@ export class StreamLogError extends Error {
}

export class ApiService {
static getDashboardUrl(teamSlug: string, appSlug: string): string {
return `${DASHBOARD_URL}/${teamSlug}/apps/${appSlug}/general`
constructor(
public readonly baseUrl: string = DEFAULT_BASE_URL,
public readonly dashboardUrl: string = DEFAULT_DASHBOARD_URL,
) {}

getDashboardUrl(teamSlug: string, appSlug: string): string {
return `${this.dashboardUrl}/${teamSlug}/apps/${appSlug}/general`
}

private async request<T>(
Expand All @@ -51,7 +54,7 @@ export class ApiService {
}
const token = session.accessToken

const response = await fetch(`${BASE_URL}${endpoint}`, {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
Expand All @@ -77,9 +80,9 @@ export class ApiService {
return (await response.json()) as T
}

static async getUser(token: string): Promise<User | null> {
async getUser(token: string): Promise<User | null> {
try {
const response = await fetch(`${BASE_URL}/users/me`, {
const response = await fetch(`${this.baseUrl}/users/me`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Expand Down Expand Up @@ -170,7 +173,7 @@ export class ApiService {
follow: String(follow),
})
const response = await fetch(
`${BASE_URL}/apps/${appId}/logs/stream?${params}`,
`${this.baseUrl}/apps/${appId}/logs/stream?${params}`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
Expand Down Expand Up @@ -236,15 +239,15 @@ export class ApiService {
}
}

static async requestDeviceCode(clientId: string): Promise<{
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`, {
const response = await fetch(`${this.baseUrl}/login/device/authorization`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Expand Down Expand Up @@ -281,7 +284,7 @@ export class ApiService {
}
}

static async pollDeviceToken(
async pollDeviceToken(
clientId: string,
deviceCode: string,
intervalMs = 5000,
Expand All @@ -292,7 +295,7 @@ export class ApiService {
throw new Error("Sign-in cancelled")
}

const response = await fetch(`${BASE_URL}/login/device/token`, {
const response = await fetch(`${this.baseUrl}/login/device/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Expand Down
18 changes: 10 additions & 8 deletions src/cloud/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
workspace,
} from "vscode"
import { trackCloudSignIn } from "../utils/telemetry"
import { ApiService } from "./api"
import type { ApiService } from "./api"

export const AUTH_PROVIDER_ID = "fastapi-vscode"
export const NAME = "FastAPI Cloud"
Expand Down Expand Up @@ -58,7 +58,10 @@ export class CloudAuthenticationProvider
new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>()
private _disposable: Disposable

constructor(private readonly context: ExtensionContext) {
constructor(
private readonly context: ExtensionContext,
private readonly apiService: ApiService,
) {
this._disposable = Disposable.from(
authentication.registerAuthenticationProvider(
AUTH_PROVIDER_ID,
Expand Down Expand Up @@ -142,7 +145,7 @@ export class CloudAuthenticationProvider
}

if (!this.cachedLabel) {
const info = await ApiService.getUser(token)
const info = await this.apiService.getUser(token)
if (info?.email) {
this.cachedLabel = info.email
}
Expand Down Expand Up @@ -200,11 +203,10 @@ export class CloudAuthenticationProvider
return sessions[0]
}

let deviceCodeResponse: Awaited<
ReturnType<typeof ApiService.requestDeviceCode>
>
let deviceCodeResponse: Awaited<ReturnType<ApiService["requestDeviceCode"]>>
try {
deviceCodeResponse = await ApiService.requestDeviceCode(AUTH_PROVIDER_ID)
deviceCodeResponse =
await this.apiService.requestDeviceCode(AUTH_PROVIDER_ID)
} catch (error) {
if (
error instanceof TypeError &&
Expand Down Expand Up @@ -234,7 +236,7 @@ export class CloudAuthenticationProvider
const abortController = new AbortController()
cancellationToken.onCancellationRequested(() => abortController.abort())

return await ApiService.pollDeviceToken(
return await this.apiService.pollDeviceToken(
AUTH_PROVIDER_ID,
deviceCodeResponse.device_code,
intervalMs,
Expand Down
1 change: 1 addition & 0 deletions src/cloud/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class CloudController {
deploy: (uri) => this.deploy(uri),
viewLogs: () => this.viewLogs(),
},
this.apiService,
)
}

Expand Down
5 changes: 3 additions & 2 deletions src/cloud/ui/menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
trackCloudAppOpened,
trackCloudDashboardOpened,
} from "../../utils/telemetry"
import { ApiService } from "../api"
import type { ApiService } from "../api"
import { AUTH_PROVIDER_ID } from "../auth"
import type { WorkspaceState } from "../types"
import { ui } from "./dialogs"
Expand All @@ -24,6 +24,7 @@ export class MenuHandler {
private getState: (uri: vscode.Uri) => WorkspaceState,
private getActiveWorkspaceFolder: () => vscode.Uri | null,
private actions: MenuActions,
private apiService: ApiService,
) {}

async showMenu(): Promise<void> {
Expand Down Expand Up @@ -65,7 +66,7 @@ export class MenuHandler {
if (state.status !== "linked") return

const { app, team } = state
const dashboardUrl = ApiService.getDashboardUrl(team.slug, app.slug)
const dashboardUrl = this.apiService.getDashboardUrl(team.slug, app.slug)
const items = [
{
label: "$(rocket) Deploy App",
Expand Down
90 changes: 90 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export const DEFAULT_BASE_URL = "https://api.fastapicloud.com/api/v1"
export const DEFAULT_DASHBOARD_URL = "https://dashboard.fastapicloud.com"

/**
* Test injection seam for `loadEnvironment`. In production all fields default
* to real `os` / `fs` / `process.env` reads — overrides are for unit tests.
*
* In the browser (vscode.dev) the dynamic Node imports throw and the
* try/catch returns defaults, so this function is effectively a no-op there.
*/
export interface EnvironmentDeps {
homedir?: () => string
platform?: () => NodeJS.Platform
getAppData?: () => string | undefined
readFile?: (path: string) => Promise<string>
pathJoin?: (...parts: string[]) => string
}

export function deriveDashboardUrl(baseUrl: string): string {
try {
const url = new URL(baseUrl)
const dashboardHostname = url.hostname.replace(/^api\./, "dashboard.")
return `https://${dashboardHostname}`
} catch {
return DEFAULT_DASHBOARD_URL
}
}

function buildConfigPath(deps: {
homedir: () => string
platform: () => NodeJS.Platform
getAppData: () => string | undefined
pathJoin: (...parts: string[]) => string
}): string {
const home = deps.homedir()
if (!home) {
throw new Error("Unable to determine home directory for config file")
}
const plat = deps.platform()
if (plat === "darwin") {
return deps.pathJoin(
home,
"Library",
"Application Support",
"fastapi-cli",
"cli.json",
)
}
if (plat === "win32") {
return deps.pathJoin(deps.getAppData() || home, "fastapi-cli", "cli.json")
}
return deps.pathJoin(home, ".config", "fastapi-cli", "cli.json")
}

export async function loadEnvironment(
deps: EnvironmentDeps = {},
): Promise<{ baseUrl: string; dashboardUrl: string }> {
try {
// Dynamic imports so this module loads cleanly in the browser bundle
// (vscode.dev). On failure the catch returns defaults.
const os = await import("node:os")
const fsp = await import("node:fs/promises")
const path = await import("node:path")

const homedir = deps.homedir ?? os.homedir
const platform = deps.platform ?? os.platform
const getAppData = deps.getAppData ?? (() => process.env.APPDATA)
const pathJoin = deps.pathJoin ?? path.join
const readFile = deps.readFile ?? ((p: string) => fsp.readFile(p, "utf-8"))

const configPath = buildConfigPath({
homedir,
platform,
getAppData,
pathJoin,
})
const raw = await readFile(configPath)
const config = JSON.parse(raw)
const baseUrl = config.base_api_url || DEFAULT_BASE_URL
return {
baseUrl,
dashboardUrl: deriveDashboardUrl(baseUrl),
}
} catch {
return {
baseUrl: DEFAULT_BASE_URL,
dashboardUrl: DEFAULT_DASHBOARD_URL,
}
}
}
11 changes: 7 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Parser } from "./core/parser"
import { stripLeadingDynamicSegments } from "./core/pathUtils"
import { collectRoutes, countRouters } from "./core/treeUtils"
import type { AppDefinition, SourceLocation } from "./core/types"
import { loadEnvironment } from "./env"
import { disposeLogger, log } from "./utils/logger"
import {
createTimer,
Expand Down Expand Up @@ -227,9 +228,14 @@ export async function activate(context: vscode.ExtensionContext) {
.get<boolean>("cloud.enabled", true)

if (cloudEnabled) {
const env = await loadEnvironment()

const configService = new ConfigService()
const apiService = new ApiService(env.baseUrl, env.dashboardUrl)

// Auth provider must be registered regardless of workspace,
// so sign-in works from command palette and Accounts menu in vscode.dev
const authProvider = new CloudAuthenticationProvider(context)
const authProvider = new CloudAuthenticationProvider(context, apiService)
authProvider.startWatching()

context.subscriptions.push(
Expand All @@ -241,9 +247,6 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)

const configService = new ConfigService()
const apiService = new ApiService()

const logsViewProvider = new LogsViewProvider(
context.extensionUri,
configService,
Expand Down
Loading
Loading