diff --git a/apps/code/package.json b/apps/code/package.json index 90681b23a1..048c475b4e 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -131,6 +131,7 @@ "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", "@posthog/agent": "workspace:*", + "@posthog/api-client": "workspace:*", "@posthog/electron-trpc": "workspace:*", "@posthog/enricher": "workspace:*", "@posthog/git": "workspace:*", diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index e47c4e63e2..505f04b600 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,6 +1,11 @@ import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { PermissionMode } from "@posthog/agent/execution-mode"; +import { + buildApiFetcher, + createApiClient, + type Schemas, +} from "@posthog/api-client"; import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, @@ -36,8 +41,6 @@ import type { SeatData } from "@shared/types/seat"; import { SEAT_PRODUCT_KEY } from "@shared/types/seat"; import type { StoredLogEntry } from "@shared/types/session-events"; import { logger } from "@utils/logger"; -import { buildApiFetcher } from "./fetcher"; -import { createApiClient, type Schemas } from "./generated"; export class SeatSubscriptionRequiredError extends Error { redirectUrl: string; @@ -573,6 +576,8 @@ export class PostHogAPIClient { buildApiFetcher({ getAccessToken, refreshAccessToken, + appVersion: + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown", }), baseUrl, ); diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index 6d797512f4..9745dd8eba 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -7,7 +7,7 @@ import { useSessionStore, } from "@features/sessions/stores/sessionStore"; import { taskKeys } from "@features/tasks/hooks/taskKeys"; -import type { Schemas } from "@renderer/api/generated"; +import type { Schemas } from "@posthog/api-client"; import type { Task } from "@shared/types"; import { enrichDescriptionWithFileContent, diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index c7a49b150a..2323121d7c 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -8,7 +8,7 @@ import { useTasks, } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import type { Schemas } from "@renderer/api/generated"; +import type { Schemas } from "@posthog/api-client"; import type { Task, TaskRunStatus } from "@shared/types"; import { useEffect, useMemo, useRef } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx index 12181d777c..b99a971651 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx @@ -1,4 +1,4 @@ -import type { Schemas } from "@renderer/api/generated"; +import type { Schemas } from "@posthog/api-client"; import type { Task } from "@shared/types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook } from "@testing-library/react"; diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index d598060dfa..3bf0f73a8d 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -5,7 +5,7 @@ import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { useMeQuery } from "@hooks/useMeQuery"; -import type { Schemas } from "@renderer/api/generated"; +import type { Schemas } from "@posthog/api-client"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useNavigationStore } from "@renderer/stores/navigationStore"; import { trpcClient } from "@renderer/trpc/client"; diff --git a/biome.jsonc b/biome.jsonc index 7c7536d332..e5698c67ca 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -110,7 +110,10 @@ }, { // API client generates a lot of Any and uses codegen-specific formatting - "includes": ["apps/code/src/renderer/api/generated.ts"], + "includes": [ + "apps/code/src/renderer/api/generated.ts", + "packages/api-client/src/generated.ts" + ], "linter": { "enabled": false }, diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 1934a926f0..6e4102513e 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -4,20 +4,24 @@ "description": "Client for the PostHog API (auth, projects, task metadata, billing). Pure HTTPS, runs in any JS environment. Constructed via factory function — no DI container.", "private": true, "type": "module", - "scripts": { - "clean": "node ../../scripts/rimraf.mjs dist .turbo" + "exports": { + ".": "./src/index.ts", + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] }, - "dependencies": { - "@posthog/core": "workspace:*" + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "node ../../scripts/rimraf.mjs .turbo" }, "devDependencies": { "@posthog/tsconfig": "workspace:*", - "@posthog/tsup-config": "workspace:*", - "tsup": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^2.1.9" }, "files": [ - "dist/**/*", "src/**/*" ] } diff --git a/apps/code/src/renderer/api/fetcher.test.ts b/packages/api-client/src/fetcher.test.ts similarity index 85% rename from apps/code/src/renderer/api/fetcher.test.ts rename to packages/api-client/src/fetcher.test.ts index b74b324094..b75e4bdff2 100644 --- a/apps/code/src/renderer/api/fetcher.test.ts +++ b/packages/api-client/src/fetcher.test.ts @@ -37,7 +37,11 @@ describe("buildApiFetcher", () => { const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); mockFetch.mockResolvedValueOnce(ok()); - const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const fetcher = buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: "test", + }); await fetcher.fetch(mockInput); expect(getAccessToken).toHaveBeenCalledTimes(1); @@ -52,7 +56,11 @@ describe("buildApiFetcher", () => { const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); mockFetch.mockResolvedValueOnce(err(401)).mockResolvedValueOnce(ok()); - const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const fetcher = buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: "test", + }); const response = await fetcher.fetch(mockInput); expect(response.ok).toBe(true); @@ -68,7 +76,11 @@ describe("buildApiFetcher", () => { const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); mockFetch.mockResolvedValueOnce(err(403, { detail: "Permission denied." })); - const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const fetcher = buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: "test", + }); await expect(fetcher.fetch(mockInput)).rejects.toThrow("[403]"); expect(getAccessToken).toHaveBeenCalledTimes(1); @@ -88,7 +100,11 @@ describe("buildApiFetcher", () => { ) .mockResolvedValueOnce(ok()); - const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const fetcher = buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: "test", + }); const response = await fetcher.fetch(mockInput); expect(response.ok).toBe(true); @@ -105,6 +121,7 @@ describe("buildApiFetcher", () => { const fetcher = buildApiFetcher({ getAccessToken: vi.fn().mockResolvedValue("token"), refreshAccessToken, + appVersion: "test", }); await expect(fetcher.fetch(mockInput)).rejects.toThrow("[400]"); @@ -116,7 +133,11 @@ describe("buildApiFetcher", () => { const refreshAccessToken = vi.fn().mockResolvedValue("token-2"); mockFetch.mockResolvedValueOnce(err(401)).mockResolvedValueOnce(err(401)); - const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const fetcher = buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: "test", + }); await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]"); }); @@ -128,7 +149,11 @@ describe("buildApiFetcher", () => { .mockRejectedValueOnce(new Error("failed")); mockFetch.mockResolvedValueOnce(err(401)); - const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const fetcher = buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: "test", + }); await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]"); }); @@ -138,6 +163,7 @@ describe("buildApiFetcher", () => { const fetcher = buildApiFetcher({ getAccessToken: vi.fn().mockResolvedValue("token"), refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "test", }); await expect(fetcher.fetch(mockInput)).rejects.toThrow( diff --git a/apps/code/src/renderer/api/fetcher.ts b/packages/api-client/src/fetcher.ts similarity index 87% rename from apps/code/src/renderer/api/fetcher.ts rename to packages/api-client/src/fetcher.ts index 2978f0b1ca..b9a7a4ed50 100644 --- a/apps/code/src/renderer/api/fetcher.ts +++ b/packages/api-client/src/fetcher.ts @@ -1,11 +1,16 @@ import type { createApiClient } from "./generated"; -const USER_AGENT = `posthog/desktop.hog.dev; version: ${typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown"}`; - -export const buildApiFetcher: (config: { +export type ApiFetcherConfig = { getAccessToken: () => Promise; refreshAccessToken: () => Promise; -}) => Parameters[0] = (config) => { + appVersion: string; +}; + +export const buildApiFetcher: ( + config: ApiFetcherConfig, +) => Parameters[0] = (config) => { + const userAgent = `posthog/desktop.hog.dev; version: ${config.appVersion}`; + const makeRequest = async ( input: Parameters[0]["fetch"]>[0], token: string, @@ -13,7 +18,7 @@ export const buildApiFetcher: (config: { const headers = new Headers(); headers.set("Authorization", `Bearer ${token}`); headers.set("Content-Type", "application/json"); - headers.set("User-Agent", USER_AGENT); + headers.set("User-Agent", userAgent); if (input.urlSearchParams) { input.url.search = input.urlSearchParams.toString(); @@ -56,7 +61,10 @@ export const buildApiFetcher: (config: { if (response.status === 401) return true; if (response.status !== 403) return false; try { - const body = await response.clone().json(); + const body = (await response.clone().json()) as { + code?: string; + type?: string; + } | null; return ( body?.code === "authentication_failed" || body?.type === "authentication_error" diff --git a/apps/code/src/renderer/api/generated.augment.d.ts b/packages/api-client/src/generated.augment.ts similarity index 69% rename from apps/code/src/renderer/api/generated.augment.d.ts rename to packages/api-client/src/generated.augment.ts index df92bcfb59..e6d7340259 100644 --- a/apps/code/src/renderer/api/generated.augment.d.ts +++ b/packages/api-client/src/generated.augment.ts @@ -1,12 +1,12 @@ -import type { Schemas } from "./generated"; - // typed-openapi omits the `Schemas.` prefix when referencing // underscore-prefixed schema types from query-parameter positions inside // the Endpoints namespace. Re-declare them as members of Endpoints so the // unqualified references in `generated.ts` resolve via declaration merging. declare module "./generated" { namespace Endpoints { - type _DateRange = Schemas._DateRange; - type _LogPropertyFilter = Schemas._LogPropertyFilter; + type _DateRange = import("./generated").Schemas._DateRange; + type _LogPropertyFilter = import("./generated").Schemas._LogPropertyFilter; } } + +export {}; diff --git a/apps/code/src/renderer/api/generated.ts b/packages/api-client/src/generated.ts similarity index 100% rename from apps/code/src/renderer/api/generated.ts rename to packages/api-client/src/generated.ts diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 0000000000..d1cf7861c4 --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,4 @@ +import "./generated.augment"; + +export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher"; +export { createApiClient, type Schemas } from "./generated"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c58ef72dd4..b5a13d8ebf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@posthog/agent': specifier: workspace:* version: link:../../packages/agent + '@posthog/api-client': + specifier: workspace:* + version: link:../../packages/api-client '@posthog/electron-trpc': specifier: workspace:* version: link:../../packages/electron-trpc @@ -876,23 +879,16 @@ importers: version: 2.1.9(@types/node@25.2.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(terser@5.46.0) packages/api-client: - dependencies: - '@posthog/core': - specifier: workspace:* - version: link:../core devDependencies: '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript - '@posthog/tsup-config': - specifier: workspace:* - version: link:../../tooling/tsup-config - tsup: - specifier: 'catalog:' - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.2.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(terser@5.46.0) packages/core: devDependencies: