Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 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 { formatCapgoApiErrorBody, getPMAndCommand, hasCliPermission, resolveCapgoPublicApiHost, show2FADeniedError } from '../utils'
import { appAddHintMessage, formatCapgoApiErrorBody, hasCliPermission, resolveCapgoPublicApiHost, show2FADeniedError } from '../utils'

export async function checkAppExists(supabase: SupabaseClient<Database>, appid: string) {
const { data: app } = await supabase
Expand Down Expand Up @@ -178,15 +178,14 @@ export async function checkAppExistsAndHasPermissionOrgErr(
skip2FACheck = false,
channelId?: number | null,
) {
const pm = getPMAndCommand()
const isChannelScopedPermission = channelId != null && requiredPermissionKey.startsWith('channel.')

// Check 2FA compliance first (unless already checked earlier)
if (!skip2FACheck)
await check2FAComplianceForApp(supabase, appid, silent)

if (!isChannelScopedPermission && !(await checkAppExists(supabase, appid))) {
const msg = `App ${appid} does not exist, run first \`${pm.runner} @capgo/cli app add ${appid}\` to create it`
const msg = appAddHintMessage(appid)
if (!silent)
log.error(msg)
throw new Error(msg)
Expand Down
11 changes: 10 additions & 1 deletion cli/src/bundle/partial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { parse } from '@std/semver'
import * as micromatch from 'micromatch'
import * as tus from 'tus-js-client'
import { encryptChecksum, encryptChecksumV3, encryptSource } from '../api/crypto'
import { BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, deltaManifestTooLargeMessage, findRoot, generateManifest, getContentType, getInstalledVersion, getLocalConfig, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils'
import { appAddHintMessage, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, deltaManifestTooLargeMessage, findRoot, generateManifest, getContentType, getInstalledVersion, getLocalConfig, isAppNotFoundError, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils'
import { getUploadReporter } from './reporter'

const log = {
Expand Down Expand Up @@ -314,6 +314,15 @@ export async function uploadPartial(
onError: (error) => {
const errorMessage = error.toString()

// Turn the backend's `app_not_found` rejection into the actionable `app add`
// hint. Without this the raw tus error object escapes as an unhandled
// rejection instead of a clear user error.
if (isAppNotFoundError(error)) {
log.error(`Failed to upload ${filePathUnix}: ${errorMessage}`)
reject(new Error(appAddHintMessage(appId)))
return
}

// Try to extract requestId from error message
let requestId: string | undefined
try {
Expand Down
15 changes: 10 additions & 5 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,14 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
// Check 2FA compliance early to give a clear error message
await check2FAComplianceForApp(supabase, appid, silent)

// Fail fast if the app does not exist (or we lack upload permission) BEFORE sending any
// bundle bytes. Otherwise the whole bundle uploads first and the files backend rejects the
// TUS request with a raw `app_not_found` 404, hiding the actionable `app add` guidance.
// 2FA was already checked just above, so skip the redundant check here.
await checkAppExistsAndHasPermissionOrgErr(supabase, apikey, appid, 'app.upload_bundle', silent, true)
if (options.verbose)
log.info(`[Verbose] App exists and API key has app.upload_bundle permission`)

const userId = await resolveUserIdFromApiKey(supabase, apikey)
if (options.verbose)
log.info(`[Verbose] User verified successfully, user_id: ${userId}`)
Expand Down Expand Up @@ -1834,11 +1842,8 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
log.info(`[Verbose] Version record updated successfully`)
}

// Check we have app access to this appId
if (options.verbose)
log.info(`[Verbose] Checking app permissions...`)

await checkAppExistsAndHasPermissionOrgErr(supabase, apikey, appid, 'app.upload_bundle', silent, true)
// App existence and app.upload_bundle permission were already verified up front (before any
// upload). Here we only need the extra bundle.delete permission for linked-bundle cleanup.
const canDeleteBundle = await hasCliPermission(supabase, apikey, 'bundle.delete', { appId: appid })

if (options.verbose) {
Expand Down
22 changes: 22 additions & 0 deletions cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,22 @@ export async function zipFileWindows(filePath: string): Promise<Buffer> {
return zip.toBuffer()
}

export function appAddHintMessage(appId: string): string {
const pm = getPMAndCommand()
return `App ${appId} does not exist, run first \`${pm.runner} @capgo/cli app add ${appId}\` to create it`
}

// The files backend rejects uploads for unknown apps with a `404 app_not_found` body
// (see supabase/functions/_backend/files/files.ts). Detect it from either a tus
// DetailedError (which exposes the raw response body) or a generic error message so we
// can surface the actionable `app add` hint instead of a raw tus error string.
export function isAppNotFoundError(error: unknown): boolean {
const detailed = error as { originalResponse?: { getBody?: () => string } }
const responseBody = detailed?.originalResponse?.getBody?.()
const message = error instanceof Error ? error.message : String(error ?? '')
return `${responseBody ?? ''} ${message}`.includes('app_not_found')
}

export async function uploadTUS(apikey: string, data: Buffer, orgId: string, appId: string, name: string, spinner: UploadSpinner, localConfig: CapgoConfig, chunkSize: number): Promise<boolean> {
return new Promise((resolve, reject) => {
sendEvent(apikey, {
Expand Down Expand Up @@ -1639,6 +1655,12 @@ export async function uploadTUS(apikey: string, data: Buffer, orgId: string, app
// Callback for errors which cannot be fixed using retries
onError(error) {
log.error(`Error uploading bundle: ${error.message}`)
// Turn the backend's `app_not_found` rejection into the actionable `app add`
// hint instead of leaking a raw tus error string to the user.
if (isAppNotFoundError(error)) {
reject(new Error(appAddHintMessage(appId)))
return
}
if (error instanceof tus.DetailedError) {
const body = error.originalResponse?.getBody()
const jsonBody = JSON.parse(body || '{"error": "unknown error"}')
Expand Down
Loading