Skip to content
Open
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
188 changes: 186 additions & 2 deletions cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SupabaseClient<Database>, CapgoSupabaseClientMetadata>()

export type ArrayElement<ArrayType extends readonly unknown[]>
= ArrayType extends readonly (infer ElementType)[] ? ElementType : never
export type Organization = ArrayElement<Database['public']['Functions']['get_orgs_v7']['Returns']>
Expand Down Expand Up @@ -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) {
Expand All @@ -654,18 +666,26 @@ 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<Database>(normalizedSupaHost, config.supaKey, { // NOSONAR
const client = createClient<Database>(normalizedSupaHost, config.supaKey, { // NOSONAR
auth: {
persistSession: false,
},
global: {
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<Database>, orgId: string): Promise<boolean> {
Expand Down Expand Up @@ -2018,6 +2038,146 @@ export async function getRemoteDependencies(supabase: SupabaseClient<Database>,
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
}[]
}

interface BundleCompatibilityCompareRequest {
appId: string
candidate: {
nativePackages: NativePackage[]
}
baseline: {
bundleId: number
}
}

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,
}
}

function logBackendCompatibilityFallback(reason: string, details: Record<string, string | number | undefined> = {}) {
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)
}

async function getChannelBaselineBundleId(supabase: SupabaseClient<Database>, appId: string, channel: string): Promise<number | null> {
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 version = data.version as { id?: number } | null
const bundleId = version?.id
return typeof bundleId === 'number' ? bundleId : null
}

async function fetchBundleCompatibilityComparison(
supabase: SupabaseClient<Database>,
body: BundleCompatibilityCompareRequest,
): Promise<BundleCompatibilityCompareResponse | null> {
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<Database>,
appId: string,
channel: string,
nativePackages: NativePackage[],
): Promise<Compatibility[] | null> {
if (nativePackages.length === 0)
return null

const baselineBundleId = await getChannelBaselineBundleId(supabase, appId, channel)
if (!baselineBundleId) {
logBackendCompatibilityFallback('missing baseline bundle', { appId, channel })
return null
}

try {
const data = await fetchBundleCompatibilityComparison(supabase, {
appId,
candidate: {
nativePackages,
},
baseline: {
bundleId: baselineBundleId,
},
})

if (!data)
return null

return mapBackendCompatibilityResponse(data)
}
catch (error) {
logBackendCompatibilityFallback('request threw', {
appId,
channel,
bundleId: baselineBundleId,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function checkChecksum(supabase: SupabaseClient<Database>, appId: string, channel: string, currentChecksum: string) {
const s = spinnerC()
s.start(`Checking bundle checksum compatibility with channel ${channel}`)
Expand Down Expand Up @@ -2146,6 +2306,22 @@ export function isCompatible(pkg: Compatibility): boolean {

export async function checkCompatibilityCloud(supabase: SupabaseClient<Database>, 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
Expand Down Expand Up @@ -2194,6 +2370,14 @@ export async function checkCompatibilityCloud(supabase: SupabaseClient<Database>
}

export async function checkCompatibilityNativePackages(supabase: SupabaseClient<Database>, 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
Expand Down
141 changes: 141 additions & 0 deletions tests/cli-backend-compatibility-map.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { checkCompatibilityNativePackages, createSupabaseClient, getCompatibilityDetails, mapBackendCompatibilityResponse } from '../cli/src/utils.ts'

function fetchUrl(input: Parameters<typeof fetch>[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: [
{
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)
})

it('uses capgkey-only auth for the backend compatibility endpoint', async () => {
const fetchCalls: { url: string, init?: Parameters<typeof fetch>[1] }[] = []
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[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,
},
})
})
})
Loading