Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
79502d9
fix(cli): unblock app add when pending onboarding app exists
cursoragent Jul 23, 2026
91094cf
fix(test): stabilize app-create CI after storage RLS fix
cursoragent Jul 23, 2026
dac18ab
fix(test): correct images insert policy pgTAP assertion
cursoragent Jul 23, 2026
a91956d
fix(test): match quoted apps.app_id in images policy pgTAP
cursoragent Jul 23, 2026
f66d5f0
fix(test): diagnose images insert storage policy pgTAP mismatch
cursoragent Jul 23, 2026
ac4f09b
fix(db): qualify storage.objects.name in images RLS policies
cursoragent Jul 23, 2026
13995ea
chore(ci): retrigger backend tests after port bind flake
cursoragent Jul 23, 2026
989c780
fix(cli,db): address review on onboarding icon create path
cursoragent Jul 23, 2026
c5a3a8c
fix(cli,api): authenticate app create via Capgo API key host path
cursoragent Jul 23, 2026
649da26
chore(ci): retrigger tests after cancelled run
cursoragent Jul 23, 2026
5badc38
fix(cli,api): honor self-host API host and serialize onboarding compl…
cursoragent Jul 23, 2026
ff31a0b
fix(test): restore compatible images storage policy pgTAP checks
cursoragent Jul 23, 2026
a3a42e0
refactor(cli): share Capgo public API host resolver across org and app
cursoragent Jul 23, 2026
41abc29
revert(api): drop anon-JWT fallback in middlewareAuth
cursoragent Jul 23, 2026
bbca8cd
merge: resolve main conflict in cleanup_queue migration
cursoragent Jul 23, 2026
9039517
Merge branch 'main' into cursor/fix-app-add-rls-5c96
riderx Jul 24, 2026
24c393c
fix(db): remove leftover merge conflict markers in cleanup_queue migr…
cursoragent Jul 24, 2026
6a8cf47
chore(ci): retrigger tests after conflict-marker fix
cursoragent Jul 24, 2026
1ac4b72
fix(ci): restore private/cli-mcp-tests submodule gitlink
cursoragent Jul 25, 2026
356a414
chore(ci): retrigger tests after flaky Cloudflare shard failure
cursoragent Jul 25, 2026
f26bdde
fix(db): limit org.create_app image RLS to icon path
cursoragent Jul 25, 2026
09b50bb
test(db): assert icon-only org.create_app image update policy
cursoragent Jul 25, 2026
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
35 changes: 23 additions & 12 deletions cli/src/api/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Database } from '../types/supabase.types'
import { log } from '@clack/prompts'
import { getPMAndCommand, hasCliPermission, show2FADeniedError } from '../utils'
import { formatCapgoApiErrorBody, getPMAndCommand, hasCliPermission, resolveCapgoPublicApiHost, show2FADeniedError } from '../utils'

export async function checkAppExists(supabase: SupabaseClient<Database>, appid: string) {
const { data: app } = await supabase
Expand Down Expand Up @@ -98,23 +98,34 @@ export async function findAppInOrganization(
}

export async function completePendingOnboardingApp(
supabase: SupabaseClient<Database>,
_supabase: SupabaseClient<Database>,
orgId: string,
appId: string,
apikey: string,
options?: { supaHost?: string, supaAnon?: string },
): Promise<void> {
const { data, error } = await supabase
.from('apps')
.update({ need_onboarding: false })
.select('app_id')
.eq('owner_org', orgId)
.eq('app_id', appId)
.eq('need_onboarding', true)
// Prefer Capgo API host (or self-hosted /functions/v1) with the API key so
// org.create_app keys can finish pending onboarding without app.update_settings.
const apiHost = await resolveCapgoPublicApiHost(options)
const response = await fetch(`${apiHost}/app/${encodeURIComponent(appId)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': apikey,
'capgkey': apikey,
},
body: JSON.stringify({
need_onboarding: false,
}),
})

if (error) {
throw new Error(`Could not complete onboarding for app ${appId}: ${error.message}`)
const data = await response.json().catch(() => null)
if (!response.ok) {
const details = formatCapgoApiErrorBody(data) || `HTTP ${response.status}`
throw new Error(`Could not complete onboarding for app ${appId}: ${details}`)
}

if (!data?.length) {
if (!(data as { app_id?: string } | null)?.app_id) {
throw new Error(`Could not complete onboarding for app ${appId} in org ${orgId}: app was not found or is no longer pending onboarding`)
}
}
Expand Down
119 changes: 74 additions & 45 deletions cli/src/app/add.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Buffer } from 'node:buffer'
import type { AppOptions } from '../schemas/app'
import type { Database } from '../types/supabase.types'
import type { Organization } from '../utils'
import { existsSync, readFileSync } from 'node:fs'
import { intro, log, outro } from '@clack/prompts'
Expand All @@ -12,11 +10,13 @@ import {
assertCliPermission,
createSupabaseClient,
findSavedKey,
formatCapgoApiErrorBody,
formatError,
getAppId,
getConfig,
getContentType,
getOrganizationWithPermission,
resolveCapgoPublicApiHost,
resolveUserIdFromApiKey,
sendEvent,
} from '../utils'
Expand Down Expand Up @@ -54,7 +54,7 @@ function ensureOptions(appId: string, options: AppOptions, silent: boolean) {
}

async function ensureAppDoesNotExist(
supabase: SupabaseClient<Database>,
supabase: Awaited<ReturnType<typeof createSupabaseClient>>,
appId: string,
silent: boolean,
) {
Expand All @@ -75,25 +75,61 @@ async function ensureAppDoesNotExist(

export type AppCreateSource = 'cli-direct' | 'onboarding' | 'mcp'

function isMissingAppBuildOnboardingSchemaError(error: { message?: string, details?: string, hint?: string, code?: string | null } | null) {
if (!error)
return false

const text = [error.message, error.details, error.hint, error.code]
.filter(Boolean)
.join(' ')
.toLowerCase()

return text.includes('created_from_onboarding')
|| text.includes('onboarding_completed_at')
}

export function resolveAppCreateSource(explicit?: AppCreateSource): AppCreateSource {
if (explicit)
return explicit
return getInvocationSource() === 'mcp' ? 'mcp' : 'cli-direct'
}

async function createAppViaApi(
apikey: string,
params: {
ownerOrg: string
appId: string
name: string
iconUrl: string
createdFromOnboarding: boolean
supaHost?: string
supaAnon?: string
},
) {
// Prefer Capgo API host (or self-hosted /functions/v1) with the API key.
// Avoid supabase.functions.invoke: it always sends Authorization: Bearer <anon>.
const apiHost = await resolveCapgoPublicApiHost({
supaHost: params.supaHost,
supaAnon: params.supaAnon,
})
const response = await fetch(`${apiHost}/app`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': apikey,
'capgkey': apikey,
},
body: JSON.stringify({
owner_org: params.ownerOrg,
app_id: params.appId,
name: params.name,
icon: params.iconUrl,
need_onboarding: false,
created_from_onboarding: params.createdFromOnboarding,
}),
})

const data = await response.json().catch(() => null)
if (!response.ok) {
const details = formatCapgoApiErrorBody(data) || `HTTP ${response.status}`
throw new Error(details)
}

const createdAppId = (data as { app_id?: string } | null)?.app_id
if (!createdAppId) {
throw new Error('App create API returned no app_id')
}

return data as { app_id: string, icon_url?: string, name?: string }
}

export async function addAppInternal(
initialAppId: string,
options: AppOptions,
Expand Down Expand Up @@ -165,6 +201,8 @@ export async function addAppInternal(
const iconPath = getAppIconStoragePath(organizationUid, appId)
let iconUrl = defaultAppIconPath

// Icon upload is best-effort. Storage RLS issues must not block app creation;
// the web onboarding path already continues without an icon on upload failure.
if (iconBuff && iconType) {
const { error } = await supabase.storage
.from('images')
Expand All @@ -175,42 +213,33 @@ export async function addAppInternal(

if (error) {
if (!silent)
log.error(`Could not add app ${formatError(error)}`)
throw new Error(`Could not add app ${formatError(error)}`)
log.warn(`Could not upload app icon (${formatError(error)}). Continuing with the default icon.`)
}
else {
iconUrl = iconPath
}

iconUrl = iconPath
}

const appCreateSource = resolveAppCreateSource(source)
const baseAppInsert = {
icon_url: iconUrl,
owner_org: organizationUid,
user_id: userId,
name,
app_id: appId,
}
const insertApp = (withOnboardingMetrics: boolean) => supabase
.from('apps')
.insert({
...baseAppInsert,
...(withOnboardingMetrics
? {
created_from_onboarding: true,
}
: {}),
})

const insertResult = await insertApp(appCreateSource === 'onboarding')
let dbError = insertResult.error
if (dbError && appCreateSource === 'onboarding' && isMissingAppBuildOnboardingSchemaError(dbError)) {
const fallbackInsertResult = await insertApp(false)
dbError = fallbackInsertResult.error
try {
// Use the same authorized API path as the web console. Direct PostgREST inserts
// hit apps/storage RLS and fail for common API-key + pending-onboarding setups.
await createAppViaApi(options.apikey!, {
ownerOrg: organizationUid,
appId,
name,
iconUrl,
createdFromOnboarding: appCreateSource === 'onboarding',
supaHost: options.supaHost,
supaAnon: options.supaAnon,
})
}
if (dbError) {
catch (error) {
const message = formatError(error)
if (!silent)
log.error(`Could not add app ${formatError(dbError)}`)
throw new Error(`Could not add app ${formatError(dbError)}`)
log.error(`Could not add app ${message}`)
throw new Error(`Could not add app ${message}`)
}

await sendEvent(options.apikey!, {
Expand Down
27 changes: 18 additions & 9 deletions cli/src/init/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ import { getRepoStarStatus, isRepoStarredInSession, starAllRepositories, starRep
import { createKeyInternal } from '../key'
import { doLoginExists, loginInternal } from '../login'
import { writeOnboardingSupportBundle, writeSupportBundleFiles } from '../onboarding-support'
import { contactSupport } from '../support/contact-support'
import { uploadSupportLogs } from '../support/support-upload'
import { copyToClipboard, revealInFinder } from '../support/clipboard'
import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log'
import { showReplicationProgress } from '../replicationProgress'
import { formatRunnerCommand, splitRunnerCommand } from '../runner-command'
import { copyToClipboard, revealInFinder } from '../support/clipboard'
import { contactSupport } from '../support/contact-support'
import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log'
import { uploadSupportLogs } from '../support/support-upload'
import { consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getConfigForWrite, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils'
import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict'
import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts'
import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime'
import { finishActiveCliReplay, getActiveCliReplaySessionId, isCliTelemetryDisabled, startInitReplay } from './replay'
import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime'
import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui'
import { CAPGO_UPDATER_PACKAGE, getUpdaterInstallState } from './updater'

Expand Down Expand Up @@ -1866,6 +1866,7 @@ async function maybeReusePendingOnboardingApp(
apikey: string,
appId: string | undefined,
supabase: Awaited<ReturnType<typeof createSupabaseClient>>,
options?: Pick<SuperOptions, 'supaHost' | 'supaAnon'>,
) {
const pendingApps = await listPendingOnboardingApps(supabase, organization.gid)
const selectedApp = await selectPendingOnboardingApp(organization.gid, apikey, appId, pendingApps)
Expand All @@ -1888,7 +1889,10 @@ async function maybeReusePendingOnboardingApp(
const cleanupSpinner = pSpinner()
cleanupSpinner.start(`Preparing ${selectedAppId} for real onboarding`)
try {
await completePendingOnboardingApp(supabase, organization.gid, selectedAppId)
await completePendingOnboardingApp(supabase, organization.gid, selectedAppId, apikey, {
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
})
cleanupSpinner.stop('Pending onboarding app prepared ✅')
}
catch (error) {
Expand Down Expand Up @@ -2055,11 +2059,16 @@ async function completeExistingAppPendingOnboarding(
supabase: Awaited<ReturnType<typeof createSupabaseClient>>,
organization: Organization,
appId: string,
apikey: string,
options?: Pick<SuperOptions, 'supaHost' | 'supaAnon'>,
) {
const s = pSpinner()
s.start(`Preparing existing app ${appId}`)
try {
await completePendingOnboardingApp(supabase, organization.gid, appId)
await completePendingOnboardingApp(supabase, organization.gid, appId, apikey, {
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
})
s.stop('Existing app prepared ✅')
}
catch (error) {
Expand Down Expand Up @@ -2091,7 +2100,7 @@ async function resolveExistingAppConflict(

if (useExistingApp === true) {
if (existingApp.need_onboarding)
await completeExistingAppPendingOnboarding(supabase, organization, appId)
await completeExistingAppPendingOnboarding(supabase, organization, appId, apikey, options)

await saveAppIdToCapacitorConfig(appId)
return 'use-existing'
Expand Down Expand Up @@ -4943,7 +4952,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
globalAppId = appId
}

const pendingOnboardingSelection = await maybeReusePendingOnboardingApp(organization, options.apikey, appId, supabase)
const pendingOnboardingSelection = await maybeReusePendingOnboardingApp(organization, options.apikey, appId, supabase, options)
appId = pendingOnboardingSelection.appId ?? appId
await ensureCapacitorProjectReady(orgId, options.apikey, appId, pendingOnboardingSelection.pendingApp)
selectedPackageJsonPath = path.resolve(globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME))
Expand Down
29 changes: 4 additions & 25 deletions cli/src/organization/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import {
assertOrgPermission,
check2FAAccessForOrg,
createSupabaseClient,
defaultApiHost,
findSavedKey,
formatError,
getLocalConfig,
getRemoteConfig,
normalizeSupabaseHost,
resolveCapgoPublicApiHost,
resolveConfiguredCapgoPublicApiHost,
sendEvent,
} from '../utils'

Expand All @@ -36,29 +34,10 @@ interface OrganizationUpdateResponse {
message?: string
}

interface OrganizationApiHostConfig {
hostApi: string
supaHost?: string
supaKey?: string
}

export function resolveConfiguredOrganizationUpdateApiHost(config: OrganizationApiHostConfig) {
if (config.supaHost && config.supaKey && config.hostApi === defaultApiHost)
return `${normalizeSupabaseHost(config.supaHost)}/functions/v1`

return config.hostApi
}
export const resolveConfiguredOrganizationUpdateApiHost = resolveConfiguredCapgoPublicApiHost
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

export async function resolveOrganizationUpdateApiHost(options: Pick<OrganizationSetOptions, 'supaHost' | 'supaAnon'>, silent: boolean) {
if (options.supaHost && options.supaAnon)
return `${normalizeSupabaseHost(options.supaHost)}/functions/v1`

const localConfig = await getLocalConfig(silent)
if (localConfig.supaHost && localConfig.supaKey)
return resolveConfiguredOrganizationUpdateApiHost(localConfig)

const config = await getRemoteConfig(silent)
return config.hostApi
return resolveCapgoPublicApiHost(options, silent)
}

async function updateOrganizationViaApi(apikey: string, payload: OrganizationUpdatePayload, silent: boolean, apiHost: string) {
Expand Down
Loading
Loading