Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
6 changes: 5 additions & 1 deletion .github/workflows/build_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ jobs:
- name: Update functions
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }}
run: supabase functions deploy
# Capgo cloud publishes allowlisted functions only (pg_net + functions.invoke).
# Self-hosting keeps deploying all functions via `supabase functions deploy`.
run: |
mapfile -t FUNCTIONS < <(bun scripts/supabase-cloud-functions.ts list)
Comment thread
riderx marked this conversation as resolved.
Outdated
supabase functions deploy "${FUNCTIONS[@]}"

read_replica_schema:
needs: changes
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ We support both deployments for practical reasons:

In production, we route most traffic through Cloudflare Workers for cost and
scale, while Supabase remains the reference backend and the default for
self-hosted deployments. Private endpoints and trigger/CRON workloads still run
on Supabase in production.
self-hosted deployments. Capgo cloud still publishes the Supabase functions used
by Postgres `pg_net` (`triggers`) and by `supabase.functions.invoke` from the
console/CLI (those always target `SUPABASE_URL`, not Cloudflare). Plugin hot
paths and other unused Capgo cloud endpoints are not published on Supabase.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Project structure (self-hosting map)

Expand Down Expand Up @@ -448,7 +450,27 @@ Seed the secret for functions:
supabase secrets set --env-file supabase/functions/.env
```

Push the functions to the cloud:
### Deploy Capgo Cloud Supabase functions

Capgo Cloud (prod / preprod / alpha) only publishes allowlisted Supabase
functions from `scripts/supabase-cloud-functions.ts`:

- `triggers` forever (`pg_net` queue consumer)
- `bundle`, `channel`, `files`, `private` until **2026-10-28** (old Capgo CLI
still called these via `supabase.functions.invoke` on `sb.capgo.app`; new
CLI uses `api.capgo.app` / `files.capgo.app`)

Console traffic uses Cloudflare (`VITE_API_HOST`). Plugin hot paths and unused
ops endpoints are skipped on Capgo cloud Supabase.

```bash
bun run deploy:supabase:prod
# or: bunx supabase functions deploy $(bun scripts/supabase-cloud-functions.ts deploy-args) --project-ref <ref>
```

### Deploy self-hosted Supabase functions

Self-hosted installs should keep deploying every function:

```bash
supabase functions deploy
Expand Down
54 changes: 32 additions & 22 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { confirmWithRememberedChoice } from '../promptPreferences'
import { showReplicationProgress } from '../replicationProgress'
import { formatTable } from '../terminal-table'
import { usesAlwaysDirectUpdate } from '../updaterConfig'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { getVersionSuggestions, interactiveVersionBump } from '../versionHelpers'
import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta'
import { checkIndexPosition, searchInDirectory } from './check'
Expand Down Expand Up @@ -596,7 +596,7 @@ async function uploadBundleToCapgoCloud(apikey: string, supabase: SupabaseType,
if (options.verbose)
log.info(`[Verbose] Using standard upload (non-TUS), getting presigned URL...`)

const url = await uploadUrl(supabase, appid, bundle)
const url = await uploadUrl(apikey, appid, bundle, options)
if (!url) {
log.error(`Cannot get upload url`)
if (options.verbose)
Expand Down Expand Up @@ -672,7 +672,7 @@ async function uploadBundleToCapgoCloud(apikey: string, supabase: SupabaseType,
log.info(`[Verbose] Cleaning up failed version from database...`)

try {
await deletedFailedVersion(supabase, appid, bundle)
await deletedFailedVersion(apikey, appid, bundle, options)
if (options.verbose)
log.info(`[Verbose] Failed version cleaned up`)
}
Expand Down Expand Up @@ -874,20 +874,24 @@ async function formatFunctionInvokeError(error: unknown): Promise<string> {
}

async function promoteExistingChannel(
supabase: SupabaseType,
apikey: string,
appid: string,
versionId: number,
targetChannel: UploadTargetChannel,
localConfig: localConfigType,
displayBundleUrl: boolean,
options?: { supaHost?: string, supaAnon?: string },
): Promise<boolean> {
const { error } = await supabase.functions.invoke('bundle', {
const { error } = await invokeCapgoCliApi('bundle', {
apikey,
method: 'PUT',
body: JSON.stringify({
body: {
app_id: appid,
version_id: versionId,
channel_id: targetChannel.id,
}),
},
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
})

if (error)
Expand Down Expand Up @@ -917,8 +921,8 @@ async function setVersionInChannel(
targetChannel: UploadTargetChannel | null,
requireChannelAssignment = false,
selfAssign?: boolean,
cliHost?: { supaHost?: string, supaAnon?: string },
): Promise<boolean> {

const canPromoteTargetChannel = targetChannel !== null
&& await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id })
const canCreateChannel = targetChannel === null
Expand All @@ -938,12 +942,12 @@ async function setVersionInChannel(
const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id })
if (!canUpdateChannelSettings) {
log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings')
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)
return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost)
}
}

if (!selfAssign)
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)
return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost)

const { error: dbError3, data } = await updateOrCreateChannel(supabase, {
name: channel,
Expand All @@ -969,14 +973,17 @@ async function setVersionInChannel(
// The channel endpoint creates the preview channel, receives its scoped
// lifecycle binding, and promotes this bundle in one transaction.
if (canCreateChannel) {
const { error, data } = await supabase.functions.invoke('channel', {
const { error, data } = await invokeCapgoCliApi('channel', {
apikey,
method: 'POST',
body: JSON.stringify({
body: {
app_id: appid,
channel,
version: bundle,
...(selfAssign ? { allow_device_self_set: true } : {}),
}),
},
supaHost: cliHost?.supaHost,
supaAnon: cliHost?.supaAnon,
})
if (error) {
uploadFail(`Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`)
Expand Down Expand Up @@ -1031,6 +1038,7 @@ async function setRolloutVersionInChannel(
rolloutPercentageBps: number,
rolloutCacheTtlSeconds?: number,
selfAssign?: boolean,
cliHost?: { supaHost?: string, supaAnon?: string },
): Promise<boolean> {
if (!targetChannel)
uploadFail(`Cannot set rollout, channel ${channel} must already exist with a stable bundle`)
Expand All @@ -1048,9 +1056,10 @@ async function setRolloutVersionInChannel(
uploadFail('Cannot set rollout because this API key lacks channel.update_settings for the target channel')

const shouldResumeSameRollout = targetChannel.rollout_version === versionId && rolloutPercentageBps > 0
const { error: rolloutError } = await supabase.functions.invoke('channel', {
const { error: rolloutError } = await invokeCapgoCliApi('channel', {
apikey,
method: 'POST',
body: JSON.stringify({
body: {
app_id: appid,
channel,
rolloutVersion: bundle,
Expand All @@ -1059,7 +1068,9 @@ async function setRolloutVersionInChannel(
...(shouldResumeSameRollout ? { rolloutPaused: false } : {}),
...(rolloutCacheTtlSeconds != null ? { rolloutCacheTtlSeconds } : {}),
...(selfAssign ? { allow_device_self_set: true } : {}),
}),
},
supaHost: cliHost?.supaHost,
supaAnon: cliHost?.supaAnon,
})

if (rolloutError)
Expand Down Expand Up @@ -1514,7 +1525,6 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
if (options.delta && manifest.length > MAX_MANIFEST_ENTRIES)
uploadFail(deltaManifestTooLargeMessage(manifest.length))


if (options.verbose)
log.info(`[Verbose] Creating version record in database...`)

Expand Down Expand Up @@ -1589,7 +1599,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
}
catch (error) {
try {
await deletedFailedVersion(supabase, appid, bundle)
await deletedFailedVersion(apikey, appid, bundle, options)
}
catch (cleanupError) {
uploadFail(`Cannot upload bundle to S3 ${formatError(error)}. Cleanup of the incomplete version also failed (${formatError(cleanupError)}); delete bundle ${bundle} manually before retrying.`)
Expand Down Expand Up @@ -1665,11 +1675,11 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
log.info(`[Verbose] Writing ${finalManifest.length} manifest rows via set_manifest...`)

try {
await setVersionManifest(supabase, appid, bundle, finalManifest)
await setVersionManifest(apikey, appid, bundle, finalManifest, options)
}
catch (error) {
try {
await deletedFailedVersion(supabase, appid, bundle)
await deletedFailedVersion(apikey, appid, bundle, options)
}
catch (cleanupError) {
uploadFail(`Cannot set bundle manifest ${formatError(error)}. Cleanup of the incomplete version also failed (${formatError(cleanupError)}); delete bundle ${bundle} manually before retrying.`)
Expand Down Expand Up @@ -1729,8 +1739,8 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
? uploadTargetChannels.get(targetChannel) ?? null
: await findUploadTargetChannel(supabase, appid, targetChannel)
const targetChannelVersionSet = rolloutPercentageBps != null
? await setRolloutVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, appid, localConfig, uploadTargetChannel, rolloutPercentageBps, options.rolloutCacheTtlSeconds, options.selfAssign)
: await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, userId, orgId, appid, localConfig, uploadTargetChannel, channelAssignmentRequired, options.selfAssign)
? await setRolloutVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, appid, localConfig, uploadTargetChannel, rolloutPercentageBps, options.rolloutCacheTtlSeconds, options.selfAssign, options)
: await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, userId, orgId, appid, localConfig, uploadTargetChannel, channelAssignmentRequired, options.selfAssign, options)
if (targetChannelVersionSet)
channelVersionSet.add(targetChannel)
if (options.verbose)
Expand Down
19 changes: 7 additions & 12 deletions cli/src/channel/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,7 @@ import { intro, log, outro } from '@clack/prompts'
import { check2FAComplianceForApp, checkAppExistsAndHasPermissionOrgErr } from '../api/app'
import { delChannel, findBundleIdByChannelName, findChannel } from '../api/channels'
import { deleteAppVersion } from '../api/versions'
import {
createSupabaseClient,
findSavedKey,
formatError,
getAppId,
getConfig,
hasCliPermission,
sendEvent,
} from '../utils'
import { createSupabaseClient, findSavedKey, formatError, getAppId, getConfig, hasCliPermission, invokeCapgoCliApi, sendEvent } from '../utils'

export async function deleteChannelInternal(channelId: string, appId: string, options: ChannelDeleteOptions, silent = false) {
if (!silent)
Expand Down Expand Up @@ -65,13 +57,16 @@ export async function deleteChannelInternal(channelId: string, appId: string, op
if (!silent)
log.info(`Deleting preview channel ${appId}#${channelId} and its bundle from Capgo`)

const { error } = await supabase.functions.invoke('channel', {
const { error } = await invokeCapgoCliApi('channel', {
apikey: options.apikey!,
method: 'DELETE',
body: JSON.stringify({
body: {
app_id: appId,
channel: channelId,
delete_bundle: true,
}),
},
supaHost: options.supaHost,
supaAnon: options.supaAnon,
Comment thread
riderx marked this conversation as resolved.
})
if (error) {
const message = `Cannot delete preview channel and bundle: ${formatError(error)}`
Expand Down
24 changes: 7 additions & 17 deletions cli/src/channel/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,7 @@ import { findChannel } from '../api/channels'
import { sendUpdateNotificationsForChannels } from '../notifications/send-update'
import { printPreviewQrForResolvedTarget, resolveChannelPreviewTarget } from '../preview/qr'
import { formatTable } from '../terminal-table'
import {
checkCompatibilityNativePackages,
checkPlanValid,
createSupabaseClient,
findSavedKey,
getAppId,
getBundleVersion,
getCompatibilityDetails,
getConfig,
isCompatible,
resolveUserIdFromApiKey,
sendEvent,
updateOrCreateChannel,
} from '../utils'
import { checkCompatibilityNativePackages, checkPlanValid, createSupabaseClient, findSavedKey, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, invokeCapgoCliApi, isCompatible, resolveUserIdFromApiKey, sendEvent, updateOrCreateChannel } from '../utils'

/**
* Display a compatibility table for the given packages
Expand Down Expand Up @@ -609,13 +596,16 @@ export async function setChannelInternal(channel: string, appId: string, options
}

if (hasStableBundlePromotion && !hasSettingsUpdate) {
const { error } = await supabase.functions.invoke('bundle', {
const { error } = await invokeCapgoCliApi('bundle', {
apikey: options.apikey!,
method: 'PUT',
body: JSON.stringify({
body: {
app_id: appId,
version_id: channelPayload.version,
channel_id: existingChannel.id,
}),
},
supaHost: options.supaHost,
Comment thread
riderx marked this conversation as resolved.
supaAnon: options.supaAnon,
})
if (error) {
if (!silent)
Expand Down
Loading
Loading