import type { UploadOptions } from '@capgo/cli/sdk' import type { Database } from '../src/types/database.types' import { Buffer } from 'node:buffer' import { execFile } from 'node:child_process' import { constants, createCipheriv, createHash, createPublicKey, privateEncrypt, randomBytes } from 'node:crypto' import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { chdir, cwd, env } from 'node:process' import { CapgoSDK } from '@capgo/cli/sdk' import AdmZip from 'adm-zip' import { getChannelsToAssignByChecksum, parseUploadChannels } from '../cli/src/bundle/upload-channels' import { BASE_DEPENDENCIES, BASE_DEPENDENCIES_OLD, BASE_PACKAGE_JSON, TEMP_DIR_NAME } from './cli-utils' import { APIKEY_TEST_ORG_SUPER_ADMIN, getSupabaseClient, USER_ID } from './test-utils' const ROOT_DIR = cwd() // Path to the project's capacitor.config.ts that the SDK modifies during key generation const CAPACITOR_CONFIG_PATH = join(ROOT_DIR, 'capacitor.config.ts') // Supabase base URL (not including /functions/v1) const SUPABASE_URL = env.SUPABASE_URL || 'http://localhost:54321' /** * SDK-based CLI test utilities * This replaces process spawning with direct SDK function calls for faster test execution */ // Cache for prepared apps to avoid repeated setup const preparedApps = new Set() const knownApps = new Map() // Key generation mutates repo-level files, so keep those operations serialized. let keyGenerationQueue = Promise.resolve() // Uploads temporarily change cwd, so serialize them within the current process. let sdkCwdQueue = Promise.resolve() export const tempFileFolder = (appId: string) => join(ROOT_DIR, TEMP_DIR_NAME, appId) function generateDefaultJsonCliConfig(appId: string) { return { appId, appName: 'Test App', webDir: 'dist', plugins: { CapacitorUpdater: { autoUpdate: false, // Point TUS uploads to local Supabase instance for testing localApiFiles: `${SUPABASE_URL}/functions/v1/files`, }, }, } } /** * Create capacitor.config.json for test app */ async function createCapacitorConfig(appId: string, folderPath: string) { const capacitorConfigPath = join(folderPath, 'capacitor.config.json') const config = generateDefaultJsonCliConfig(appId) await writeFile(capacitorConfigPath, JSON.stringify(config, null, 2)) } /** * Create package.json for test app */ async function createPackageJson(appId: string, folderPath: string, dependencies: Record = BASE_DEPENDENCIES) { const packageJsonPath = join(folderPath, 'package.json') const packageJsonContent = BASE_PACKAGE_JSON .replace('%APPID%', appId) .replace('%DEPENDENCIES%', JSON.stringify(dependencies, null, 2)) await writeFile(packageJsonPath, packageJsonContent) } /** * Create dist folder with a simple index.html */ async function createDistFolder(folderPath: string) { const distPath = join(folderPath, 'dist') await mkdir(distPath, { recursive: true }) const indexHtmlPath = join(distPath, 'index.html') const indexHtmlContent = ` Test App

Test App v1.0.0

` await writeFile(indexHtmlPath, indexHtmlContent) } /** * Prepare a test app environment (creates folders and config files) * This is cached to avoid repeated setup */ export async function prepareCli(appId: string, dependencies?: Record) { if (preparedApps.has(appId)) { return // Already prepared } const folderPath = tempFileFolder(appId) // Create app directory await mkdir(folderPath, { recursive: true }) // Create necessary files await createCapacitorConfig(appId, folderPath) await createPackageJson(appId, folderPath, dependencies) await createDistFolder(folderPath) preparedApps.add(appId) } /** * Clean up test app directory */ export async function cleanupCli(appId: string) { const folderPath = tempFileFolder(appId) await rm(folderPath, { recursive: true, force: true }) preparedApps.delete(appId) } async function getAppRecord(appId: string) { const cached = knownApps.get(appId) if (cached) return cached const { data } = await getSupabaseClient() .from('apps') .select('app_id, owner_org, user_id') .eq('app_id', appId) .maybeSingle() if (data) knownApps.set(appId, data) return data } async function getChannelRecord(appId: string, channelId: string) { const { data } = await getSupabaseClient() .from('channels') .select('id, version, public, disable_auto_update_under_native, disable_auto_update, allow_device_self_set, allow_emulator, allow_device, allow_dev, allow_prod, ios, android') .eq('app_id', appId) .eq('name', channelId) .maybeSingle() return data } async function getFirstVersionId(appId: string) { const { data } = await getSupabaseClient() .from('app_versions') .select('id') .eq('app_id', appId) .order('id', { ascending: true }) .limit(1) .maybeSingle() return data?.id ?? null } async function getVersionId(appId: string, version: string) { const { data } = await getSupabaseClient() .from('app_versions') .select('id') .eq('app_id', appId) .eq('name', version) .maybeSingle() return data?.id ?? null } type ApiKeyRow = Pick< Database['public']['Tables']['apikeys']['Row'], 'expires_at' | 'id' | 'key' | 'key_hash' | 'rbac_id' | 'user_id' > type RbacPermissionKey = string interface NativePackage { name: string version: string } interface ChannelVersionRow { checksum: string | null id: number min_update_version: string | null name: string native_packages: NativePackage[] | null } interface CompatibilityEntry { localVersion: string | undefined name: string remoteVersion: string | undefined } function isApiKeyExpired(expiresAt: string | null) { return expiresAt != null && new Date(expiresAt).getTime() <= Date.now() } async function getApiKeyRecord(apikey: string) { const { data } = await getSupabaseClient().rpc('find_apikey_by_value' as any, { key_value: apikey, }) const row = data?.[0] as ApiKeyRow | undefined if (!row || isApiKeyExpired(row.expires_at)) return null return row } async function apiKeyHasAnyAppPermission( apikey: string, apiKey: ApiKeyRow, app: { app_id: string, owner_org: string | null }, requiredPermissions: RbacPermissionKey[], ) { if (!app.owner_org) return false for (const permissionKey of Array.from(new Set(requiredPermissions))) { const { data, error } = await getSupabaseClient().rpc('rbac_check_permission_direct' as any, { p_permission_key: permissionKey, p_user_id: apiKey.user_id, p_org_id: app.owner_org, p_app_id: app.app_id, p_channel_id: null, p_apikey: apikey, }) if (!error && data === true) return true } return false } async function apiKeyHasAnyChannelPermission( apikey: string, apiKey: ApiKeyRow, app: { app_id: string, owner_org: string | null }, channelId: number, requiredPermissions: RbacPermissionKey[], ) { if (!app.owner_org) return false for (const permissionKey of Array.from(new Set(requiredPermissions))) { const { data, error } = await getSupabaseClient().rpc('rbac_check_permission_direct' as any, { p_permission_key: permissionKey, p_user_id: apiKey.user_id, p_org_id: app.owner_org, p_app_id: app.app_id, p_channel_id: channelId, p_apikey: apikey, }) if (!error && data === true) return true } return false } async function getAuthorizedApp( apikey: string, appId: string, requiredPermissions: RbacPermissionKey[], ) { const apiKey = await getApiKeyRecord(apikey) if (!apiKey) return { error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' as const } const app = await getAppRecord(appId) if (!app) return { error: `App ${appId} does not exist` as const } if (!(await apiKeyHasAnyAppPermission(apikey, apiKey, app, requiredPermissions))) return { error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' as const } return { apiKey, app } as const } async function getAppsForApiKey(apikey: string, requiredPermissions: RbacPermissionKey[]) { const apiKey = await getApiKeyRecord(apikey) if (!apiKey) return { error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' as const } const { data, error } = await getSupabaseClient() .from('apps') .select('app_id, created_at, icon_url, name, owner_org, user_id') .order('created_at', { ascending: true }) if (error) return { error: error.message } const filteredApps = [] for (const app of data ?? []) { if (await apiKeyHasAnyAppPermission(apikey, apiKey, app, requiredPermissions)) filteredApps.push(app) } return { apiKey, apps: filteredApps, } as const } async function getChannelVersionRecord(appId: string, channelId: string) { const { data } = await getSupabaseClient() .from('channels') .select('version:app_versions!channels_version_fkey( id, checksum, min_update_version, name, native_packages )') .eq('app_id', appId) .eq('name', channelId) .maybeSingle() return (data?.version ?? null) as ChannelVersionRow | null } async function listFilesRecursive(folderPath: string): Promise { const entries = await readdir(folderPath, { withFileTypes: true }) const nested = await Promise.all(entries.map(async (entry) => { const entryPath = join(folderPath, entry.name) if (entry.isDirectory()) return listFilesRecursive(entryPath) return [entryPath] })) return nested.flat() } async function hasNotifyAppReady(distPath: string) { const files = await listFilesRecursive(distPath) for (const filePath of files) { if (!/\.(?:html|js|ts|tsx|jsx)$/i.test(filePath)) continue const fileContent = await readFile(filePath, 'utf8') if (fileContent.includes('notifyAppReady')) return true } return false } function buildZipBuffer(distPath: string) { const zip = new AdmZip() zip.addLocalFolder(distPath) return zip.toBuffer() } function checksumHex(buffer: Buffer) { return createHash('sha256').update(buffer).digest('hex') } function getPublicKeyFingerprint(publicKey: string) { return publicKey .replace(/-----BEGIN (RSA )?PUBLIC KEY-----/g, '') .replace(/-----END (RSA )?PUBLIC KEY-----/g, '') .replace(/\s+/g, '') .substring(0, 20) } function getKeyIdFromPrivateKey(privateKey: string) { const publicKey = createPublicKey(privateKey).export({ type: 'pkcs1', format: 'pem' }) return getPublicKeyFingerprint(publicKey.toString()) } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } async function resolveInstalledVersion(packageName: string, nodeModulesPaths: string[]) { const parts = packageName.split('/') for (const nodeModulesPath of nodeModulesPaths) { try { const packageJsonPath = join(nodeModulesPath, ...parts, 'package.json') const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { version?: string } if (packageJson.version) return packageJson.version } catch { // Ignore missing dependency paths and keep searching fallback locations. } } return null } function splitPaths(value: string | undefined, fallback: string[]) { if (!value) return fallback return value .split(',') .map(path => path.trim()) .filter(Boolean) } async function extractNativePackages(appId: string, packageJsonPath: string | undefined, nodeModulesPath: string | undefined) { const resolvedPackageJsonPath = splitPaths(packageJsonPath, [join(tempFileFolder(appId), 'package.json')])[0] ?? join(tempFileFolder(appId), 'package.json') const nodeModulesPaths = splitPaths(nodeModulesPath, [join(tempFileFolder(appId), 'node_modules'), join(ROOT_DIR, 'node_modules')]) const packageJson = JSON.parse(await readFile(resolvedPackageJsonPath, 'utf8')) as { dependencies?: Record } const dependencies = packageJson.dependencies ?? {} const nativePackages = await Promise.all(Object.entries(dependencies).map(async ([name, version]) => ({ name, version: await resolveInstalledVersion(name, nodeModulesPaths) ?? version, }))) return nativePackages } async function buildCompatibilityResult(appId: string, channel: string, packageJsonPath: string | undefined, nodeModulesPath: string | undefined) { const localDependencies = await extractNativePackages(appId, packageJsonPath, nodeModulesPath) const remoteVersion = await getChannelVersionRecord(appId, channel) const remoteDependencies = new Map((remoteVersion?.native_packages ?? []).map(pkg => [pkg.name, pkg])) const compatibility: CompatibilityEntry[] = localDependencies.map((pkg) => { const remotePackage = remoteDependencies.get(pkg.name) return { name: pkg.name, localVersion: pkg.version, remoteVersion: remotePackage?.version, } }) for (const remotePackage of remoteVersion?.native_packages ?? []) { if (!localDependencies.find(pkg => pkg.name === remotePackage.name)) { compatibility.push({ name: remotePackage.name, localVersion: undefined, remoteVersion: remotePackage.version, }) } } return compatibility } async function uploadBufferToStorage(filePath: string, zipBuffer: Buffer) { const capgo = getSupabaseClient() const bucket = capgo.storage.from('capgo') await bucket.remove([filePath]) const { data, error } = await bucket.createSignedUploadUrl(filePath) if (error || !data?.signedUrl) throw new Error(error?.message ?? 'Cannot get upload url') const uploadResponse = await fetch(data.signedUrl, { method: 'PUT', headers: { 'Content-Type': 'application/zip', }, body: new Uint8Array(zipBuffer), }) if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`) } async function deleteBundleRecord(versionId: number) { const { error } = await getSupabaseClient() .from('app_versions') .delete() .eq('id', versionId) if (error) throw new Error(error.message) } async function createBundleRecord( appId: string, version: string, app: NonNullable>>, apiKey: ApiKeyRow, fields: { checksum: string comment?: string key_id?: string | null min_update_version?: string | null native_packages?: NativePackage[] | null r2_path: string session_key?: string | null }, ) { if (!app.owner_org) throw new Error(`App ${appId} does not have an owner organization`) const { data, error } = await getSupabaseClient() .from('app_versions') .insert({ app_id: appId, checksum: fields.checksum, comment: fields.comment ?? null, key_id: fields.key_id ?? null, min_update_version: fields.min_update_version ?? null, name: version, native_packages: fields.native_packages as any, owner_org: app.owner_org, r2_path: fields.r2_path, session_key: fields.session_key ?? null, storage_provider: 'r2-direct', user_id: apiKey.user_id, }) .select('id') .single() if (error || !data) throw new Error(error?.message ?? 'Cannot create bundle') return data.id } async function resolveUploadMinUpdateVersion( appId: string, channel: string | undefined, autoMinUpdateVersion: boolean | undefined, explicitMinUpdateVersion: string | undefined, ) { if (explicitMinUpdateVersion) return { minUpdateVersion: explicitMinUpdateVersion, error: null as string | null } if (!autoMinUpdateVersion || !channel) return { minUpdateVersion: null, error: null as string | null } const remoteVersion = await getChannelVersionRecord(appId, channel) if (!remoteVersion || remoteVersion.native_packages == null) return { minUpdateVersion: null, error: null as string | null } if (!remoteVersion.min_update_version) return { minUpdateVersion: null, error: 'Invalid remote min update version' } return { minUpdateVersion: remoteVersion.min_update_version, error: null as string | null, } } async function buildUploadPayload(appId: string, options: UploadOptions) { const distPath = options.path const plainZipBuffer = buildZipBuffer(distPath) const nativePackages = options.ignoreCompatibilityCheck ? null : await extractNativePackages(appId, options.packageJsonPaths, undefined) if (options.encrypt) { if (!options.encryptionKey) return { error: 'Missing encryption key' as const } const privateKey = await readFile(options.encryptionKey, 'utf8') const keyId = getKeyIdFromPrivateKey(privateKey) const iv = randomBytes(16) const aesKey = randomBytes(16) const cipher = createCipheriv('aes-128-cbc', aesKey, iv) const encryptedZipBuffer = Buffer.concat([cipher.update(plainZipBuffer), cipher.final()]) const encryptedSessionKey = privateEncrypt({ key: privateKey, padding: constants.RSA_PKCS1_PADDING }, aesKey).toString('base64') return { checksum: checksumHex(encryptedZipBuffer), keyId, nativePackages, sessionKey: `${iv.toString('base64')}:${encryptedSessionKey}`, zipBuffer: encryptedZipBuffer, } as const } return { checksum: checksumHex(plainZipBuffer), keyId: null, nativePackages, sessionKey: null, zipBuffer: plainZipBuffer, } as const } /** * Create an SDK instance with test credentials */ export function createTestSDK(apikey: string = APIKEY_TEST_ORG_SUPER_ADMIN) { const sdk = new CapgoSDK({ apikey, apiHost: `${SUPABASE_URL}/functions/v1`, }) // Keep repo tests on repo-controlled RBAC checks while the published CLI SDK // still uses anonymous Supabase clients for direct table access. ;(sdk as any).addChannel = async ({ channelId, appId, default: isDefault, selfAssign }: { channelId: string appId: string default?: boolean selfAssign?: boolean }) => { const access = await getAuthorizedApp(apikey, appId, ['app.create_channel']) if ('error' in access) return { success: false, error: access.error } const { app } = access if (!app.owner_org) return { success: false, error: `App ${appId} does not have an owner organization` } if (await getChannelRecord(appId, channelId)) return { success: false, error: `Channel ${channelId} already exists` } const versionId = await getFirstVersionId(appId) if (!versionId) return { success: false, error: 'Cannot find version' } const { error } = await getSupabaseClient() .from('channels') .insert({ name: channelId, app_id: appId, version: versionId, owner_org: app.owner_org, created_by: app.user_id ?? USER_ID, public: isDefault ?? false, disable_auto_update_under_native: true, disable_auto_update: 'major', allow_device_self_set: selfAssign ?? false, allow_emulator: false, allow_device: false, allow_dev: false, allow_prod: false, ios: false, android: false, }) if (error) return { success: false, error: error.message } return { success: true } } ;(sdk as any).listChannels = async (appId: string) => { const access = await getAuthorizedApp(apikey, appId, ['app.read_channels']) if ('error' in access) return { success: false, error: access.error } const { data, error } = await getSupabaseClient() .from('channels') .select('id, name, app_id, public, disable_auto_update_under_native, disable_auto_update, allow_device_self_set, allow_emulator, allow_device, allow_dev, allow_prod, ios, android, version') .eq('app_id', appId) .order('created_at', { ascending: true }) if (error) return { success: false, error: error.message } return { success: true, data: data ?? [], } } ;(sdk as any).updateChannel = async ({ channelId, appId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, updatePackage, dev, emulator, device, prod, }: { channelId: string appId: string bundle?: string state?: string downgrade?: boolean ios?: boolean android?: boolean selfAssign?: boolean disableAutoUpdate?: Database['public']['Enums']['disable_update'] updatePackage?: Database['public']['Enums']['channel_update_package'] dev?: boolean emulator?: boolean device?: boolean prod?: boolean }) => { const apiKey = await getApiKeyRecord(apikey) if (!apiKey) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } const app = await getAppRecord(appId) if (!app) return { success: false, error: `App ${appId} does not exist` } const existingChannel = await getChannelRecord(appId, channelId) if (!existingChannel) return { success: false, error: 'Cannot find channel' } const hasBundlePromotion = bundle != null const hasSettingsUpdate = state != null || downgrade != null || ios != null || android != null || selfAssign != null || disableAutoUpdate != null || updatePackage != null || dev != null || emulator != null || device != null || prod != null if (hasSettingsUpdate && !(await apiKeyHasAnyChannelPermission(apikey, apiKey, app, existingChannel.id, ['channel.update_settings']))) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } if (hasBundlePromotion && !(await apiKeyHasAnyChannelPermission(apikey, apiKey, app, existingChannel.id, ['channel.promote_bundle']))) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } if (state && !['default', 'normal'].includes(state)) return { success: false, error: 'Unknown state' } const versionId = bundle ? await getVersionId(appId, bundle) : existingChannel.version if (!versionId) return { success: false, error: 'Cannot find version' } const { error } = await getSupabaseClient() .from('channels') .update({ version: versionId, ...(state === 'default' ? { public: true } : {}), ...(state === 'normal' ? { public: false } : {}), ...(typeof downgrade === 'boolean' ? { disable_auto_update_under_native: !downgrade } : {}), ...(typeof ios === 'boolean' ? { ios } : {}), ...(typeof android === 'boolean' ? { android } : {}), ...(typeof selfAssign === 'boolean' ? { allow_device_self_set: selfAssign } : {}), ...(disableAutoUpdate == null ? {} : { disable_auto_update: disableAutoUpdate }), ...(updatePackage == null ? {} : { update_package: updatePackage }), ...(typeof dev === 'boolean' ? { allow_dev: dev } : {}), ...(typeof emulator === 'boolean' ? { allow_emulator: emulator } : {}), ...(typeof device === 'boolean' ? { allow_device: device } : {}), ...(typeof prod === 'boolean' ? { allow_prod: prod } : {}), }) .eq('id', existingChannel.id) if (error) return { success: false, error: error.message } return { success: true } } ;(sdk as any).deleteChannel = async (channelId: string, appId: string) => { const apiKey = await getApiKeyRecord(apikey) if (!apiKey) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } const app = await getAppRecord(appId) if (!app) return { success: false, error: `App ${appId} does not exist` } const existingChannel = await getChannelRecord(appId, channelId) if (!existingChannel) return { success: false, error: 'Channel not found' } if (!(await apiKeyHasAnyChannelPermission(apikey, apiKey, app, existingChannel.id, ['channel.delete']))) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } const { error } = await getSupabaseClient() .from('channels') .delete() .eq('id', existingChannel.id) if (error) return { success: false, error: error.message } return { success: true } } ;(sdk as any).getCurrentBundle = async (appId: string, channelId: string) => { const apiKey = await getApiKeyRecord(apikey) if (!apiKey) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } const app = await getAppRecord(appId) if (!app) return { success: false, error: `App ${appId} does not exist` } const channel = await getChannelRecord(appId, channelId) if (!channel) return { success: false, error: `Channel ${channelId} not found for app ${appId}` } if (!(await apiKeyHasAnyChannelPermission(apikey, apiKey, app, channel.id, ['channel.read']))) return { success: false, error: 'Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.' } const version = await getChannelVersionRecord(appId, channelId) if (!version) return { success: false, error: `Channel ${channelId} not found for app ${appId}` } return { success: true, data: version.name, } } ;(sdk as any).listApps = async () => { const access = await getAppsForApiKey(apikey, ['app.read']) if ('error' in access) return { success: false, error: access.error } return { success: true, data: access.apps.map(app => ({ appId: app.app_id, name: app.name || 'Unknown', iconUrl: app.icon_url || undefined, createdAt: new Date(app.created_at || ''), })), } } ;(sdk as any).checkBundleCompatibility = async ({ appId, channel, nodeModules, packageJson, }: { appId: string channel: string nodeModules?: string packageJson?: string }) => { const access = await getAuthorizedApp(apikey, appId, ['app.read_bundles']) if ('error' in access) return { success: false, error: access.error } try { const data = await buildCompatibilityResult(appId, channel, packageJson, nodeModules) return { success: true, data, } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } } ;(sdk as any).uploadBundle = async (options: UploadOptions) => { const resolvedApiKey = options.apikey || apikey const access = await getAuthorizedApp(resolvedApiKey, options.appId, ['app.upload_bundle']) if ('error' in access) return { success: false, error: access.error } const { apiKey, app } = access const bundleVersion = options.bundle if (!bundleVersion) return { success: false, error: 'Missing bundle version' } const existingVersionId = await getVersionId(options.appId, bundleVersion) if (existingVersionId) return { success: false, error: `Version ${bundleVersion} already exists` } try { if (!options.disableCodeCheck && !(await hasNotifyAppReady(options.path))) return { success: false, error: 'Missing notifyAppReady in bundle code' } const uploadPayload = await buildUploadPayload(options.appId, options) if ('error' in uploadPayload) return { success: false, error: uploadPayload.error } const requestedChannels = parseUploadChannels(options.channel) if (options.channel !== undefined && requestedChannels.length === 0) return { success: false, error: 'Missing channel name' } const targetChannels = requestedChannels.length > 0 ? requestedChannels : parseUploadChannels('production') const remoteChecksums = new Map() for (const targetChannel of targetChannels) { const currentChannelVersion = await getChannelVersionRecord(options.appId, targetChannel) remoteChecksums.set(targetChannel, currentChannelVersion?.checksum ?? null) } const { channelsToAssign } = getChannelsToAssignByChecksum(targetChannels, uploadPayload.checksum, remoteChecksums) if (channelsToAssign.length === 0) { return { success: false, error: 'Cannot upload the same bundle content', } } for (const targetChannel of channelsToAssign) { const channelRecord = await getChannelRecord(options.appId, targetChannel) if (channelRecord) { const canPromote = await apiKeyHasAnyChannelPermission(resolvedApiKey, apiKey, app, channelRecord.id, ['channel.promote_bundle']) if (!canPromote) return { success: false, error: 'Cannot set channel because this API key lacks channel.promote_bundle for the target channel' } if (options.selfAssign && !(await apiKeyHasAnyChannelPermission(resolvedApiKey, apiKey, app, channelRecord.id, ['channel.update_settings']))) return { success: false, error: 'Cannot enable device self-assign because this API key lacks channel.update_settings' } continue } if (!app.owner_org) return { success: false, error: 'App does not have an owner organization' } if (!(await apiKeyHasAnyAppPermission(resolvedApiKey, apiKey, app, ['app.create_channel']))) return { success: false, error: 'Cannot create target channel because this API key lacks app.create_channel' } } const minUpdateVersion = await resolveUploadMinUpdateVersion( options.appId, channelsToAssign[0] ?? targetChannels[0], options.autoMinUpdateVersion, options.minUpdateVersion, ) if (minUpdateVersion.error) return { success: false, error: minUpdateVersion.error } const r2Path = `orgs/${app.owner_org}/apps/${options.appId}/${bundleVersion}.zip` const versionId = await createBundleRecord(options.appId, bundleVersion, app, apiKey, { checksum: uploadPayload.checksum, comment: options.comment, key_id: uploadPayload.keyId, min_update_version: minUpdateVersion.minUpdateVersion, native_packages: uploadPayload.nativePackages, r2_path: r2Path, session_key: uploadPayload.sessionKey, }) try { await uploadBufferToStorage(r2Path, uploadPayload.zipBuffer) } catch (uploadError) { try { await deleteBundleRecord(versionId) } catch (cleanupError) { throw new Error(`Upload failed and cleanup failed: ${getErrorMessage(uploadError)}; cleanup error: ${getErrorMessage(cleanupError)}`) } throw uploadError } for (const targetChannel of channelsToAssign) { const channelRecord = await getChannelRecord(options.appId, targetChannel) if (channelRecord) { const { error } = await getSupabaseClient() .from('channels') .update({ version: versionId, ...(typeof options.selfAssign === 'boolean' ? { allow_device_self_set: options.selfAssign } : {}), }) .eq('id', channelRecord.id) if (error) return { success: false, error: error.message } continue } const { data: createdChannel, error: createError } = await getSupabaseClient() .from('channels') .insert({ name: targetChannel, app_id: options.appId, owner_org: app.owner_org, created_by: app.user_id ?? USER_ID, version: null, allow_device_self_set: options.selfAssign ?? false, }) .select('id') .single() if (createError || !createdChannel) return { success: false, error: createError?.message ?? 'Cannot create target channel' } const { error: promoteError } = await getSupabaseClient() .from('channels') .update({ version: versionId }) .eq('id', createdChannel.id) if (promoteError) return { success: false, error: promoteError.message } } return { success: true, checksum: uploadPayload.checksum, sessionKey: uploadPayload.sessionKey, storageProvider: 'r2-direct', } } catch (error) { return { success: false, error: getErrorMessage(error) } } } return sdk } /** * Upload a bundle using the SDK with test-specific defaults * Provides: auto path calculation, disables code checks, uses zip format */ export async function uploadBundleSDK( appId: string, version: string, channel?: string, additionalOptions?: Partial, ) { const sdk = createTestSDK(additionalOptions?.apikey ?? APIKEY_TEST_ORG_SUPER_ADMIN) const options: UploadOptions = { appId, path: join(tempFileFolder(appId), 'dist'), bundle: version, channel, disableCodeCheck: true, // Skip notifyAppReady check for tests useZip: true, useTus: false, // TUS protocol uses localApiFiles from capacitor.config.json ...additionalOptions, } const previousOperation = sdkCwdQueue let operationComplete: () => void sdkCwdQueue = new Promise(resolve => operationComplete = resolve) await previousOperation const originalCwd = cwd() try { chdir(tempFileFolder(appId)) return await sdk.uploadBundle(options) } finally { chdir(originalCwd) operationComplete!() } } // Note: Tests should use createTestSDK() directly for channel operations // Example: const sdk = createTestSDK(); await sdk.addChannel({ channelId, appId }) /** * Generate encryption keys using the SDK * Uses a queue to serialize operations (prevent concurrent conflicts when creating keys in project root) * * IMPORTANT: The SDK's generateEncryptionKeys() modifies the project's capacitor.config.ts * to add the public key. This function backs up and restores the config file to prevent * test pollution. * * Since tests run concurrently but key generation is serialized via the queue, we: * 1. Back up the config at the start of each queued operation * 2. Restore it at the end of each queued operation (in finally block) * This ensures each test sees a clean config file. */ export async function generateEncryptionKeysSDK(appId: string, force = true) { const { existsSync, renameSync, readFileSync, writeFileSync } = await import('node:fs') // Queue this operation to run after previous key generations complete. let operationComplete: () => void const previousOperation = keyGenerationQueue keyGenerationQueue = new Promise(resolve => operationComplete = resolve) await previousOperation // Backup the capacitor.config.ts content AFTER waiting for the queue // This ensures we get a clean config (either original or restored by previous operation) let configBackup: string | null = null if (existsSync(CAPACITOR_CONFIG_PATH)) { configBackup = readFileSync(CAPACITOR_CONFIG_PATH, 'utf-8') } try { const folderPath = tempFileFolder(appId) const result = await new Promise<{ success: boolean, error?: string }>((resolve) => { const script = ` import { CapgoSDK } from '@capgo/cli/sdk' const sdk = new CapgoSDK({ apiHost: ${JSON.stringify(`${SUPABASE_URL}/functions/v1`)}, }) const result = await sdk.generateEncryptionKeys({ force: ${force ? 'true' : 'false'} }) console.log(JSON.stringify(result)) process.exit(result.success ? 0 : 1) ` execFile('bun', ['-e', script], { cwd: ROOT_DIR }, (error, stdout, stderr) => { const output = `${stdout}\n${stderr}`.trim() if (error) { resolve({ success: false, error: output || error.message, }) return } const lines = output .split('\n') .map(line => line.trim()) .filter(Boolean) const jsonLine = [...lines].reverse().find(line => line.startsWith('{') && line.endsWith('}')) if (!jsonLine) { resolve({ success: false, error: output || 'generateEncryptionKeys returned no result', }) return } try { resolve(JSON.parse(jsonLine)) } catch { resolve({ success: false, error: output || 'generateEncryptionKeys returned invalid JSON', }) } }) }) if (!result.success) { return result } // Find where the keys were actually created and move them to the test folder const privateKeySource = join(ROOT_DIR, '.capgo_key_v2') const publicKeySource = join(ROOT_DIR, '.capgo_key_v2.pub') const privateKeyDest = join(folderPath, '.capgo_key_v2') const publicKeyDest = join(folderPath, '.capgo_key_v2.pub') // Check if keys exist in project root if (existsSync(privateKeySource) && existsSync(publicKeySource)) { renameSync(privateKeySource, privateKeyDest) renameSync(publicKeySource, publicKeyDest) } else { // Keys might have been created in the test folder already // Check if they exist there if (!existsSync(privateKeyDest) || !existsSync(publicKeyDest)) { return { success: false, error: `Keys not found in expected locations. Checked:\n- ${privateKeySource}\n- ${privateKeyDest}`, } } } return result } finally { // Add a small delay to allow any async SDK operations to complete // The SDK may be writing to the config file asynchronously await new Promise(resolve => setTimeout(resolve, 100)) // Restore the capacitor.config.ts from our backup (SDK modified it with the public key) if (configBackup !== null) { try { writeFileSync(CAPACITOR_CONFIG_PATH, configBackup) } catch { // Best effort cleanup - don't fail the test if restore fails console.warn('Warning: Failed to restore capacitor.config.ts from backup') } } // Signal that this operation is complete operationComplete!() } } // Export BASE_DEPENDENCIES for compatibility export { BASE_DEPENDENCIES, BASE_DEPENDENCIES_OLD }