diff --git a/cli/src/api/app.ts b/cli/src/api/app.ts index ee25ad3de5..f6a51668f5 100644 --- a/cli/src/api/app.ts +++ b/cli/src/api/app.ts @@ -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, appid: string) { const { data: app } = await supabase @@ -178,7 +178,6 @@ 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) @@ -186,7 +185,7 @@ export async function checkAppExistsAndHasPermissionOrgErr( 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) diff --git a/cli/src/bundle/partial.ts b/cli/src/bundle/partial.ts index a61a2e60c9..9ade5c6c8c 100644 --- a/cli/src/bundle/partial.ts +++ b/cli/src/bundle/partial.ts @@ -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 = { @@ -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 { diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 6be47cd20d..41cbc07414 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -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}`) @@ -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) { diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 35c3ad60be..ad520bda69 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1607,6 +1607,22 @@ export async function zipFileWindows(filePath: string): Promise { 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 { return new Promise((resolve, reject) => { sendEvent(apikey, { @@ -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"}')