tests/cli-sdk-utils.ts
Rama main · réplica de lectura del repositorio original.
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<string>()
const knownApps = new Map<string, { app_id: string, owner_org: string | null, user_id: string | null }>()
// 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<string, string> = 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 = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Test App</title>
</head>
<body>
<h1>Test App v1.0.0</h1>
<script>
// Call notifyAppReady() as required by Capgo
if (window.CapacitorUpdater) {
window.CapacitorUpdater.notifyAppReady();
}
</script>
</body>
</html>`
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<string, string>) {
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<string[]> {
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<string, string>
}
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<Awaited<ReturnType<typeof getAppRecord>>>,
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<string, string | null>()
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<UploadOptions>,
) {
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 }