From 81a8fb0a0e1c94d8fa5f03bbccb7659cfe0463c8 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 3 Jun 2026 23:38:44 +0200 Subject: [PATCH 1/2] fix(cli): use bundle compatibility endpoint --- cli/src/utils.ts | 102 ++++++++++++++++++ ...cli-backend-compatibility-map.unit.test.ts | 53 +++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/cli-backend-compatibility-map.unit.test.ts diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 977f513d60..86f10d0223 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -2018,6 +2018,84 @@ export async function getRemoteDependencies(supabase: SupabaseClient, return convertNativePackages(((remoteNativePackages.version as any)?.native_packages as any) ?? []) } +interface BundleCompatibilityCompareResponse { + comparisons: { + name: string + candidateVersion?: string + baselineVersion?: string + candidateIosChecksum?: string + baselineIosChecksum?: string + candidateAndroidChecksum?: string + baselineAndroidChecksum?: string + }[] +} + +function mapBackendCompatibilityComparison(entry: BundleCompatibilityCompareResponse['comparisons'][number]): Compatibility { + return { + name: entry.name, + localVersion: entry.candidateVersion, + remoteVersion: entry.baselineVersion, + localIosChecksum: entry.candidateIosChecksum, + remoteIosChecksum: entry.baselineIosChecksum, + localAndroidChecksum: entry.candidateAndroidChecksum, + remoteAndroidChecksum: entry.baselineAndroidChecksum, + } +} + +export function mapBackendCompatibilityResponse(response: BundleCompatibilityCompareResponse): Compatibility[] { + return response.comparisons.map(mapBackendCompatibilityComparison) +} + +async function getChannelBaselineBundleId(supabase: SupabaseClient, appId: string, channel: string): Promise { + const { data, error } = await supabase + .from('channels') + .select('version ( id )') + .eq('name', channel) + .eq('app_id', appId) + .single() + + if (error) { + log.error(`Error fetching native packages: ${error.message}`) + throw new Error(`Error fetching native packages: ${error.message}`) + } + + const bundleId = (data.version as any)?.id + return typeof bundleId === 'number' ? bundleId : null +} + +async function checkCompatibilityWithBackend( + supabase: SupabaseClient, + appId: string, + channel: string, + nativePackages: NativePackage[], +): Promise { + const baselineBundleId = await getChannelBaselineBundleId(supabase, appId, channel) + if (!baselineBundleId) + return null + + try { + const { data, error } = await supabase.functions.invoke('private/bundle_compatibility/compare', { + body: { + appId, + candidate: { + nativePackages, + }, + baseline: { + bundleId: baselineBundleId, + }, + }, + }) + + if (error || !data) + return null + + return mapBackendCompatibilityResponse(data) + } + catch { + return null + } +} + export async function checkChecksum(supabase: SupabaseClient, appId: string, channel: string, currentChecksum: string) { const s = spinnerC() s.start(`Checking bundle checksum compatibility with channel ${channel}`) @@ -2146,6 +2224,22 @@ export function isCompatible(pkg: Compatibility): boolean { export async function checkCompatibilityCloud(supabase: SupabaseClient, appId: string, channel: string, packageJsonPath: string | undefined, nodeModules: string | undefined) { const dependenciesObject = await getLocalDependencies(packageJsonPath, nodeModules) + const localNativePackages = dependenciesObject + .filter(a => !!a.native) + .map(({ name, version, ios_checksum, android_checksum }) => ({ + name, + version, + ...(ios_checksum && { ios_checksum }), + ...(android_checksum && { android_checksum }), + })) + const backendCompatibility = await checkCompatibilityWithBackend(supabase, appId, channel, localNativePackages) + if (backendCompatibility) { + return { + finalCompatibility: backendCompatibility, + localDependencies: dependenciesObject, + } + } + const mappedRemoteNativePackages = await getRemoteDependencies(supabase, appId, channel) const finalDependencies: Compatibility[] = dependenciesObject @@ -2194,6 +2288,14 @@ export async function checkCompatibilityCloud(supabase: SupabaseClient } export async function checkCompatibilityNativePackages(supabase: SupabaseClient, appId: string, channel: string, nativePackages: NativePackage[]) { + const backendCompatibility = await checkCompatibilityWithBackend(supabase, appId, channel, nativePackages) + if (backendCompatibility) { + return { + finalCompatibility: backendCompatibility, + localDependencies: nativePackages, + } + } + const mappedRemoteNativePackages = await getRemoteDependencies(supabase, appId, channel) const finalDependencies: Compatibility[] = nativePackages diff --git a/tests/cli-backend-compatibility-map.unit.test.ts b/tests/cli-backend-compatibility-map.unit.test.ts new file mode 100644 index 0000000000..ffc7894a12 --- /dev/null +++ b/tests/cli-backend-compatibility-map.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { getCompatibilityDetails, mapBackendCompatibilityResponse } from '../cli/src/utils.ts' + +describe('CLI backend compatibility response mapping', () => { + it.concurrent('maps backend package comparisons to CLI compatibility entries', () => { + const [entry] = mapBackendCompatibilityResponse({ + comparisons: [ + { + name: '@capacitor/camera', + candidateVersion: '6.0.0', + baselineVersion: '5.0.0', + candidateIosChecksum: 'ios-new', + baselineIosChecksum: 'ios-old', + candidateAndroidChecksum: 'android-new', + baselineAndroidChecksum: 'android-old', + }, + ], + }) + + expect(entry).toEqual({ + name: '@capacitor/camera', + localVersion: '6.0.0', + remoteVersion: '5.0.0', + localIosChecksum: 'ios-new', + remoteIosChecksum: 'ios-old', + localAndroidChecksum: 'android-new', + remoteAndroidChecksum: 'android-old', + }) + expect(getCompatibilityDetails(entry).compatible).toBe(false) + }) + + it.concurrent('keeps removed remote packages OTA-compatible in the CLI shape', () => { + const [entry] = mapBackendCompatibilityResponse({ + comparisons: [ + { + name: '@capacitor/camera', + baselineVersion: '5.0.0', + }, + ], + }) + + expect(entry).toEqual({ + name: '@capacitor/camera', + localVersion: undefined, + remoteVersion: '5.0.0', + localIosChecksum: undefined, + remoteIosChecksum: undefined, + localAndroidChecksum: undefined, + remoteAndroidChecksum: undefined, + }) + expect(getCompatibilityDetails(entry).compatible).toBe(true) + }) +}) From 498d2dad15a828eedf704fe6ec3d0c9361992d92 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 4 Jun 2026 00:09:01 +0200 Subject: [PATCH 2/2] fix(cli): send compatibility check with api key --- cli/src/utils.ts | 112 +++++++++++++++--- ...cli-backend-compatibility-map.unit.test.ts | 92 +++++++++++++- 2 files changed, 187 insertions(+), 17 deletions(-) diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 86f10d0223..0abd261f3d 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -44,6 +44,14 @@ export const MAX_CHUNK_SIZE_BYTES = 1024 * 1024 * 99 // 99MB export const PACKNAME = 'package.json' +interface CapgoSupabaseClientMetadata { + apikey: string + functionsUrl: string + fetch: typeof fetch +} + +const capgoSupabaseClientMetadata = new WeakMap, CapgoSupabaseClientMetadata>() + export type ArrayElement = ArrayType extends readonly (infer ElementType)[] ? ElementType : never export type Organization = ArrayElement @@ -640,6 +648,10 @@ function normalizeSupabaseHost(host: string): string { return `${parsed.origin}${normalizedPath}` } +function getSupabaseFunctionsUrl(host: string): string { + return new URL('functions/v1', host).href +} + export async function createSupabaseClient(apikey: string, supaHost?: string, supaKey?: string, silent = false, instrument = true) { const config = await getRemoteConfig(silent) if (supaHost && supaKey) { @@ -654,8 +666,10 @@ export async function createSupabaseClient(apikey: string, supaHost?: string, su throw new Error('Cannot connect to server please try again later') } const normalizedSupaHost = normalizeSupabaseHost(config.supaHost) + const instrumentedFetch = isSupabaseInstrumentationEnabled() && instrument ? createTimedFetch() : undefined + const requestFetch = instrumentedFetch ?? (((input, init) => globalThis.fetch(input, init)) as typeof fetch) // Custom Supabase hosts are an explicit CLI feature; normalizeSupabaseHost constrains the accepted URL shape first. - return createClient(normalizedSupaHost, config.supaKey, { // NOSONAR + const client = createClient(normalizedSupaHost, config.supaKey, { // NOSONAR auth: { persistSession: false, }, @@ -663,9 +677,15 @@ export async function createSupabaseClient(apikey: string, supaHost?: string, su headers: { capgkey: apikey, }, - ...(isSupabaseInstrumentationEnabled() && instrument ? { fetch: createTimedFetch() } : {}), + ...(instrumentedFetch ? { fetch: instrumentedFetch } : {}), }, }) + capgoSupabaseClientMetadata.set(client, { + apikey, + functionsUrl: getSupabaseFunctionsUrl(normalizedSupaHost), + fetch: requestFetch, + }) + return client } export async function isPayingOrg(supabase: SupabaseClient, orgId: string): Promise { @@ -2030,6 +2050,16 @@ interface BundleCompatibilityCompareResponse { }[] } +interface BundleCompatibilityCompareRequest { + appId: string + candidate: { + nativePackages: NativePackage[] + } + baseline: { + bundleId: number + } +} + function mapBackendCompatibilityComparison(entry: BundleCompatibilityCompareResponse['comparisons'][number]): Compatibility { return { name: entry.name, @@ -2042,6 +2072,17 @@ function mapBackendCompatibilityComparison(entry: BundleCompatibilityCompareResp } } +function logBackendCompatibilityFallback(reason: string, details: Record = {}) { + if (env.CAPGO_DEBUG !== 'true') + return + + const context = Object.entries(details) + .filter((entry): entry is [string, string | number] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(', ') + log.info(`[Debug] Bundle compatibility endpoint fallback: ${reason}${context ? ` (${context})` : ''}`) +} + export function mapBackendCompatibilityResponse(response: BundleCompatibilityCompareResponse): Compatibility[] { return response.comparisons.map(mapBackendCompatibilityComparison) } @@ -2059,39 +2100,80 @@ async function getChannelBaselineBundleId(supabase: SupabaseClient, ap throw new Error(`Error fetching native packages: ${error.message}`) } - const bundleId = (data.version as any)?.id + const version = data.version as { id?: number } | null + const bundleId = version?.id return typeof bundleId === 'number' ? bundleId : null } +async function fetchBundleCompatibilityComparison( + supabase: SupabaseClient, + body: BundleCompatibilityCompareRequest, +): Promise { + const metadata = capgoSupabaseClientMetadata.get(supabase) + if (!metadata) { + logBackendCompatibilityFallback('missing client metadata', { appId: body.appId, bundleId: body.baseline.bundleId }) + return null + } + + const response = await metadata.fetch(`${metadata.functionsUrl}/private/bundle_compatibility/compare`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'capgkey': metadata.apikey, + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + logBackendCompatibilityFallback('request failed', { + appId: body.appId, + bundleId: body.baseline.bundleId, + status: response.status, + }) + return null + } + + return await response.json() as BundleCompatibilityCompareResponse +} + async function checkCompatibilityWithBackend( supabase: SupabaseClient, appId: string, channel: string, nativePackages: NativePackage[], ): Promise { + if (nativePackages.length === 0) + return null + const baselineBundleId = await getChannelBaselineBundleId(supabase, appId, channel) - if (!baselineBundleId) + if (!baselineBundleId) { + logBackendCompatibilityFallback('missing baseline bundle', { appId, channel }) return null + } try { - const { data, error } = await supabase.functions.invoke('private/bundle_compatibility/compare', { - body: { - appId, - candidate: { - nativePackages, - }, - baseline: { - bundleId: baselineBundleId, - }, + const data = await fetchBundleCompatibilityComparison(supabase, { + appId, + candidate: { + nativePackages, + }, + baseline: { + bundleId: baselineBundleId, }, }) - if (error || !data) + if (!data) return null return mapBackendCompatibilityResponse(data) } - catch { + catch (error) { + logBackendCompatibilityFallback('request threw', { + appId, + channel, + bundleId: baselineBundleId, + error: error instanceof Error ? error.message : String(error), + }) return null } } diff --git a/tests/cli-backend-compatibility-map.unit.test.ts b/tests/cli-backend-compatibility-map.unit.test.ts index ffc7894a12..05afc26a46 100644 --- a/tests/cli-backend-compatibility-map.unit.test.ts +++ b/tests/cli-backend-compatibility-map.unit.test.ts @@ -1,7 +1,19 @@ -import { describe, expect, it } from 'vitest' -import { getCompatibilityDetails, mapBackendCompatibilityResponse } from '../cli/src/utils.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { checkCompatibilityNativePackages, createSupabaseClient, getCompatibilityDetails, mapBackendCompatibilityResponse } from '../cli/src/utils.ts' + +function fetchUrl(input: Parameters[0]): string { + if (typeof input === 'string') + return input + if (input instanceof URL) + return input.href + return input.url +} describe('CLI backend compatibility response mapping', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + it.concurrent('maps backend package comparisons to CLI compatibility entries', () => { const [entry] = mapBackendCompatibilityResponse({ comparisons: [ @@ -50,4 +62,80 @@ describe('CLI backend compatibility response mapping', () => { }) expect(getCompatibilityDetails(entry).compatible).toBe(true) }) + + it('uses capgkey-only auth for the backend compatibility endpoint', async () => { + const fetchCalls: { url: string, init?: Parameters[1] }[] = [] + const fetchMock = vi.fn(async (input: Parameters[0], init?: Parameters[1]) => { + const url = fetchUrl(input) + fetchCalls.push({ url, init }) + + if (url.endsWith('/private/config')) { + return Response.json({ + host: 'https://capgo.test', + hostApi: 'https://api.capgo.test', + hostFilesApi: 'https://files.capgo.test', + hostWeb: 'https://console.capgo.test', + }) + } + + if (url.includes('/rest/v1/channels')) { + return Response.json({ + version: { + id: 123, + }, + }) + } + + if (url === 'https://supabase.test/functions/v1/private/bundle_compatibility/compare') { + return Response.json({ + comparisons: [ + { + name: '@capacitor/camera', + candidateVersion: '6.0.0', + baselineVersion: '5.0.0', + }, + ], + }) + } + + throw new Error(`Unexpected fetch: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + + const supabase = await createSupabaseClient('capgo-api-key', 'https://supabase.test', 'anon-key', true) + const result = await checkCompatibilityNativePackages( + supabase, + 'com.test.app', + 'production', + [{ name: '@capacitor/camera', version: '6.0.0' }], + ) + + expect(result.finalCompatibility).toEqual([ + { + name: '@capacitor/camera', + localVersion: '6.0.0', + remoteVersion: '5.0.0', + localIosChecksum: undefined, + remoteIosChecksum: undefined, + localAndroidChecksum: undefined, + remoteAndroidChecksum: undefined, + }, + ]) + + const compatibilityRequest = fetchCalls.find(call => call.url === 'https://supabase.test/functions/v1/private/bundle_compatibility/compare') + expect(compatibilityRequest).toBeDefined() + const headers = new Headers(compatibilityRequest?.init?.headers) + expect(headers.get('capgkey')).toBe('capgo-api-key') + expect(headers.has('Authorization')).toBe(false) + expect(headers.has('apikey')).toBe(false) + expect(JSON.parse(compatibilityRequest?.init?.body as string)).toEqual({ + appId: 'com.test.app', + candidate: { + nativePackages: [{ name: '@capacitor/camera', version: '6.0.0' }], + }, + baseline: { + bundleId: 123, + }, + }) + }) })