diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 5b6b3e7d8d..9da59ca369 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -1341,7 +1341,13 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio if (options.verbose) log.info(`[Verbose] Target channel${channels.length > 1 ? 's' : ''}: ${channelLabel}`) - // Now if it does exist we will fetch the org id + // Verify the app exists and this key may upload BEFORE the org lookup, so a + // missing app or bad key yields an actionable message (e.g. run `app add`) + // instead of the opaque "Cannot get organization id" thrown below. + if (options.verbose) + log.info(`[Verbose] Checking app existence and upload permission...`) + await checkAppExistsAndHasPermissionOrgErr(supabase, apikey, appid, 'app.upload_bundle', silent, true) + const orgId = await getOrganizationId(supabase, appid) if (options.verbose) log.info(`[Verbose] Organization ID: ${orgId}`) diff --git a/cli/src/posthog.ts b/cli/src/posthog.ts index 324bab3e77..840bd4b08c 100644 --- a/cli/src/posthog.ts +++ b/cli/src/posthog.ts @@ -177,11 +177,56 @@ function getCommanderCode(error: unknown) { return typeof code === 'string' ? code : undefined } +// Lowercased markers for expected user-configuration failures (bad/missing API +// key, app not created yet). These are surfaced with the backend error codes or +// the CLI's own wording — matched case-insensitively against the error message. +const EXPECTED_USER_ERROR_MARKERS = [ + 'invalid_apikey', + 'invalid apikey', + 'no_key_provided', + 'no key provided', + 'invalid api key or insufficient permissions', + 'does not exist, run first', +] + +function getErrorStatus(error: unknown): number | undefined { + if (!error || typeof error !== 'object') + return undefined + const candidate = error as { status?: unknown, statusCode?: unknown, context?: { status?: unknown } } + if (typeof candidate.status === 'number') + return candidate.status + if (typeof candidate.statusCode === 'number') + return candidate.statusCode + // supabase-js FunctionsHttpError keeps the upstream Response on `.context`. + const contextStatus = candidate.context && typeof candidate.context === 'object' + ? (candidate.context as { status?: unknown }).status + : undefined + return typeof contextStatus === 'number' ? contextStatus : undefined +} + +/** + * Expected user-configuration failures: a bad or missing API key (401 + * `invalid_apikey` / `no_key_provided`) or an app that has not been created + * yet. These are real user errors, not CLI bugs — they are still counted via + * `trackCommandFailed`, but must NOT be sent to error tracking as exceptions. + */ +export function isExpectedUserError(error: unknown) { + const message = (error instanceof Error + ? error.message + : typeof error === 'string' ? error : '').toLowerCase() + + if (EXPECTED_USER_ERROR_MARKERS.some(marker => message.includes(marker))) + return true + + // A 401 from any Capgo endpoint is an auth/config problem on the caller side. + return getErrorStatus(error) === 401 +} + export function shouldCapturePosthogException(error: unknown) { - // Expected user-facing failures (missing input, a channel with no bundle, - // insufficient permissions, …) are legitimate states, not crashes — never - // open an error tracking issue for them. - if (error instanceof CliUserError) + // Expected user-facing failures (CliUserError, bad/missing key, app not + // created, …) are legitimate states, not crashes — never open an error + // tracking issue for them. + if (error instanceof CliUserError || isExpectedUserError(error)) return false return !getCommanderCode(error)?.startsWith('commander.') } @@ -221,7 +266,8 @@ export async function capturePosthogException(payload: CapturePosthogExceptionPa const topFrame = frames[0] // Deliberately exclude the CLI version from the fingerprint so the same bug // stays a single error-tracking issue across releases instead of minting a - // brand-new issue on every version bump. + // brand-new issue on every version bump. Version is still reported via + // `cli_version` below. const fingerprint = [ payload.functionName, payload.kind, diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 3ce5bd5640..9c8999ca93 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -2062,8 +2062,9 @@ export async function getOrganizationId(supabase: SupabaseClient, appI .single() if (!data || error) { - log.error(`Cannot get organization id for app id ${appId}`) - formatError(error) + // Surface the underlying PostgREST cause instead of discarding it — a bare + // "Cannot get organization id" leaves both users and triage with no signal. + log.error(`Cannot get organization id for app id ${appId}: ${formatError(error)}`) throw new Error(`Cannot get organization id for app id ${appId}`) } return data.owner_org diff --git a/cli/test/test-posthog-exception.mjs b/cli/test/test-posthog-exception.mjs index f266469f02..f5d68d7616 100644 --- a/cli/test/test-posthog-exception.mjs +++ b/cli/test/test-posthog-exception.mjs @@ -5,6 +5,7 @@ import { Command } from 'commander' import { capturePosthogException, getCommandPath, + isExpectedUserError, shouldCapturePosthogException, } from '../src/posthog.ts' import { CliUserError } from '../src/shared/cli-user-error.ts' @@ -67,9 +68,10 @@ try { assert.equal(body.properties.status, 1) assert.match(body.properties.distinct_id, /^cli:[^:]+:bundle upload$/) // Fingerprint must NOT include the CLI version, so the same bug stays one - // error-tracking issue across releases. + // error-tracking issue across releases (version still reported via cli_version). assert.equal(body.properties.$exception_fingerprint, 'bundle upload:unhandled_error:Error:runUpload:/src/index.ts:1') assert.doesNotMatch(body.properties.$exception_fingerprint, /cli:/) + assert.equal(body.properties.cli_version, body.properties.distinct_id.split(':')[1]) assert.equal(body.properties.$exception_list[0].type, 'Error') assert.equal(body.properties.$exception_list[0].value, 'boom') assert.equal(body.properties.$exception_list[0].mechanism.handled, true) @@ -147,6 +149,20 @@ try { new CliUserError('Channel does not have a bundle linked', { channel: 'canary' }).message, ) + // Expected user errors must be skipped by exception capture (they are still + // counted via trackCommandFailed at the call site). + assert.equal(isExpectedUserError(new Error('Invalid API key or insufficient permissions.')), true) + assert.equal(isExpectedUserError(new Error('invalid_apikey')), true) + assert.equal(isExpectedUserError(new Error('no_key_provided')), true) + assert.equal(isExpectedUserError(new Error('App com.example does not exist, run first `npx @capgo/cli app add com.example` to create it')), true) + assert.equal(isExpectedUserError({ context: { status: 401 } }), true) + assert.equal(isExpectedUserError({ status: 401 }), true) + assert.equal(isExpectedUserError(new Error('Cannot get organization id for app id com.example')), false) + assert.equal(isExpectedUserError(new Error('boom')), false) + assert.equal(shouldCapturePosthogException(new Error('invalid_apikey')), false) + assert.equal(shouldCapturePosthogException({ context: { status: 401 } }), false) + assert.equal(shouldCapturePosthogException(new Error('Cannot get organization id for app id com.example')), true) + console.log('CLI PostHog exception capture tests passed') } finally {