diff --git a/cli/package.json b/cli/package.json index 4ec2e038bb..297bf06794 100644 --- a/cli/package.json +++ b/cli/package.json @@ -110,6 +110,7 @@ "test:mcp-build-job": "bun test/test-mcp-build-job.mjs", "test:mcp-build-tools": "bun test/test-mcp-build-tools.mjs", "test:app-created-source": "bun test/test-app-created-source.mjs", + "test:app-add-exists": "bun test/test-app-add-exists.mjs", "test:app-list-output-text": "bun test/test-app-list-output-text.mjs", "test:doctor-analytics": "bun test/test-doctor-analytics.mjs", "test:posthog-exception": "bun test/test-posthog-exception.mjs", @@ -175,7 +176,7 @@ "test:android-version": "bun test/test-android-version.mjs", "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-add-exists && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:builder-project-discovery": "bun test/test-builder-project-discovery.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", diff --git a/cli/src/app/add.ts b/cli/src/app/add.ts index 854d3b0908..b0bb9dabc1 100644 --- a/cli/src/app/add.ts +++ b/cli/src/app/add.ts @@ -4,8 +4,9 @@ import type { Organization } from '../utils' import { existsSync, readFileSync } from 'node:fs' import { intro, log, outro } from '@clack/prompts' import { buildCliRequestHeaders } from '../analytics/cli-headers' -import { getInvocationSource } from '../analytics/track' +import { getInvocationSource, trackEvent } from '../analytics/track' import { getAppIconStoragePath, newIconPath } from '../api/app' +import { getAppListPath } from './list' import { checkAlerts } from '../api/update' import { isAiAgentEnvironment } from '../init/onboarding-source' import { CliUserError } from '../shared/cli-user-error' @@ -16,9 +17,11 @@ import { formatCapgoApiErrorBody, formatError, getAppId, + getCapgoCliHttpStatus, getConfig, getContentType, getOrganizationWithPermission, + invokeCapgoCliApi, resolveCapgoPublicApiHost, resolveUserIdFromApiKey, sendEvent, @@ -78,6 +81,149 @@ export function isStorageObjectConflict(error: unknown) { return status === 409 || statusCode === '409' } +export function isDuplicateAppCreateError(error: unknown, httpStatus?: number) { + if (httpStatus === 409) + return true + const message = formatError(error).toLowerCase() + return message.includes('app_id_already_exists') || message.includes('app id already exists') +} + +export type AppAddDuplicateOutcome = 'duplicate_owned' | 'duplicate_taken' | 'not_duplicate' + +type AppListRow = { app_id?: string } + +async function isAppListedInOrganization( + apikey: string, + appId: string, + ownerOrg: string, + options?: { supaHost?: string, supaAnon?: string }, +): Promise { + let page = 0 + while (true) { + const { data, error } = await invokeCapgoCliApi( + getAppListPath(page, ownerOrg), + { + apikey, + method: 'GET', + body: undefined, + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }, + ) + + if (error) { + const status = getCapgoCliHttpStatus(error) + if (status === 400 || status === 401 || status === 403) + return null + throw error + } + + const batch = Array.isArray(data) ? data : [] + if (batch.some(row => row.app_id === appId)) + return true + if (batch.length < 50) + return false + page += 1 + } +} + +async function isAppInTargetOrganization( + apikey: string, + appId: string, + ownerOrg: string, + options?: { supaHost?: string, supaAnon?: string }, +): Promise { + const { data, error } = await invokeCapgoCliApi<{ owner_org?: string }>( + `app/${encodeURIComponent(appId)}`, + { + apikey, + method: 'GET', + body: undefined, + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }, + ) + + if (error) { + const status = getCapgoCliHttpStatus(error) + if (status === 404) + return false + if (status === 401 || status === 403) + return null + throw error + } + + return data?.owner_org === ownerOrg +} + +async function isDuplicateAppOwnedByCaller( + params: { + apikey: string + appId: string + ownerOrg: string + supaHost?: string + supaAnon?: string + }, + deps: { + isAppInTargetOrganization?: typeof isAppInTargetOrganization + isAppListedInOrganization?: typeof isAppListedInOrganization + } = {}, +): Promise { + const lookup = deps.isAppInTargetOrganization ?? isAppInTargetOrganization + const appInTargetOrg = await lookup( + params.apikey, + params.appId, + params.ownerOrg, + { supaHost: params.supaHost, supaAnon: params.supaAnon }, + ) + + if (appInTargetOrg === true) + return true + if (appInTargetOrg === false) + return false + + const listAppsInOrg = deps.isAppListedInOrganization ?? isAppListedInOrganization + const listedInOrg = await listAppsInOrg( + params.apikey, + params.appId, + params.ownerOrg, + { supaHost: params.supaHost, supaAnon: params.supaAnon }, + ) + if (listedInOrg === true) + return true + if (listedInOrg === false) + return false + + throw new Error('Cannot verify app ownership for this API key. Grant app.read or org.read, then retry.') +} + +export async function resolveAppAddDuplicateOutcome( + params: { + apikey: string + appId: string + ownerOrg: string + createError: unknown + httpStatus?: number + supaHost?: string + supaAnon?: string + }, + deps: { + isAppInTargetOrganization?: typeof isAppInTargetOrganization + isAppListedInOrganization?: typeof isAppListedInOrganization + } = {}, +): Promise { + if (!isDuplicateAppCreateError(params.createError, params.httpStatus)) + return 'not_duplicate' + + try { + const owned = await isDuplicateAppOwnedByCaller(params, deps) + return owned ? 'duplicate_owned' : 'duplicate_taken' + } + catch (error) { + throw new Error(formatError(error)) + } +} + async function createAppViaApi( apikey: string, params: { @@ -124,7 +270,9 @@ async function createAppViaApi( const data = await response.json().catch(() => null) if (!response.ok) { const details = formatCapgoApiErrorBody(data) || `HTTP ${response.status}` - throw new Error(details) + const error = new Error(details) as Error & { httpStatus?: number } + error.httpStatus = response.status + throw error } const createdAppId = (data as { app_id?: string } | null)?.app_id @@ -233,6 +381,7 @@ export async function addAppInternal( ? 'mcp' : isAiAgentEnvironment() ? 'ai' : 'cli' + let appAlreadyExists = false 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. @@ -248,24 +397,69 @@ export async function addAppInternal( }) } catch (error) { - const message = formatError(error) - if (!silent) - log.error(`Could not add app ${message}`) - throw new Error(`Could not add app ${message}`) + let duplicateOutcome: AppAddDuplicateOutcome + try { + duplicateOutcome = await resolveAppAddDuplicateOutcome({ + apikey: options.apikey!, + appId, + ownerOrg: organizationUid, + createError: error, + httpStatus: (error as { httpStatus?: number }).httpStatus, + supaHost: options.supaHost, + supaAnon: options.supaAnon, + }) + } + catch (ownershipError) { + const message = formatError(ownershipError) + if (!silent) + log.error(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) + } + + if (duplicateOutcome === 'duplicate_owned') { + appAlreadyExists = true + } + else if (duplicateOutcome === 'duplicate_taken') { + const takenMessage = `App ID ${appId} already exists` + if (!silent) + log.error(`Could not add app: ${takenMessage}`) + throw new Error(`Could not add app: ${takenMessage}`) + } + else { + const message = formatError(error) + if (!silent) + log.error(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) + } } - await sendEvent(options.apikey!, { - channel: 'app', - event: 'App Created', - icon: '๐Ÿ†•', - org_id: organizationUid, - tracking_version: 2, - tags: { 'app-id': appId, 'source': appCreateSource }, - notifyConsole: true, - }).catch(() => {}) + if (appAlreadyExists) { + void trackEvent({ + channel: 'app', + event: 'CLI Recovered App Already Exists', + appId, + apikey: options.apikey!, + orgId: organizationUid, + tags: { source: appCreateSource }, + }) + } + else { + await sendEvent(options.apikey!, { + channel: 'app', + event: 'App Created', + icon: '๐Ÿ†•', + org_id: organizationUid, + tracking_version: 2, + tags: { 'app-id': appId, 'source': appCreateSource }, + notifyConsole: true, + }).catch(() => {}) + } if (!silent) { - log.success(`App ${appId} added to Capgo`) + if (appAlreadyExists) + log.success(`App ${appId} already exists in Capgo`) + else + log.success(`App ${appId} added to Capgo`) log.info(`This app is accessible to all members of your organization based on their permissions`) log.info(`Next step: upload a bundle with "npx @capgo/cli bundle upload ${appId}"`) outro('Done โœ…') diff --git a/cli/test/test-app-add-exists.mjs b/cli/test/test-app-add-exists.mjs new file mode 100644 index 0000000000..30e8d3ebb5 --- /dev/null +++ b/cli/test/test-app-add-exists.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { + isDuplicateAppCreateError, + resolveAppAddDuplicateOutcome, +} from '../src/app/add.ts' + +console.log('๐Ÿงช Testing app add duplicate handling...\n') + +assert.equal(isDuplicateAppCreateError(new Error('app_id_already_exists | App ID already exists')), true) +assert.equal(isDuplicateAppCreateError(new Error('App ID already exists'), 409), true) +assert.equal(isDuplicateAppCreateError(new Error('HTTP 409'), 409), true) +assert.equal(isDuplicateAppCreateError(new Error('network unavailable')), false) +assert.equal(isDuplicateAppCreateError(new Error('insufficient permissions')), false) + +const ownedOutcome = await resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('app_id_already_exists | App ID already exists'), + httpStatus: 409, + }, + { + isAppInTargetOrganization: async () => true, + }, +) +assert.equal(ownedOutcome, 'duplicate_owned') + +const takenOutcome = await resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('app_id_already_exists | App ID already exists'), + httpStatus: 409, + }, + { + isAppInTargetOrganization: async () => false, + isAppListedInOrganization: async () => false, + }, +) +assert.equal(takenOutcome, 'duplicate_taken') + +const otherOrgReadableOutcome = await resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('app_id_already_exists | App ID already exists'), + httpStatus: 409, + }, + { + isAppInTargetOrganization: async (_apikey, _appId, ownerOrg) => { + assert.equal(ownerOrg, 'org_123') + return false + }, + }, +) +assert.equal(otherOrgReadableOutcome, 'duplicate_taken') + +const orgListOwnedOutcome = await resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('app_id_already_exists | App ID already exists'), + httpStatus: 409, + }, + { + isAppInTargetOrganization: async () => null, + isAppListedInOrganization: async () => true, + }, +) +assert.equal(orgListOwnedOutcome, 'duplicate_owned') + +const otherErrorOutcome = await resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('Cannot create app | validation failed'), + httpStatus: 400, + }, + { + isAppInTargetOrganization: async () => { + throw new Error('isAppInTargetOrganization should not run for non-duplicate errors') + }, + }, +) +assert.equal(otherErrorOutcome, 'not_duplicate') + +await assert.rejects( + () => resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('app_id_already_exists | App ID already exists'), + httpStatus: 409, + }, + { + isAppInTargetOrganization: async () => { + throw new Error('upstream unavailable') + }, + }, + ), + /upstream unavailable/, +) + +await assert.rejects( + () => resolveAppAddDuplicateOutcome( + { + apikey: 'test-key', + appId: 'com.example.app', + ownerOrg: 'org_123', + createError: new Error('app_id_already_exists | App ID already exists'), + httpStatus: 409, + }, + { + isAppInTargetOrganization: async () => null, + isAppListedInOrganization: async () => null, + }, + ), + /Grant app.read or org.read/, +) + +console.log('โœ… app add duplicate handling tests passed') diff --git a/cli/test/test-app-created-source.mjs b/cli/test/test-app-created-source.mjs index bd391e4b8f..c4cf015268 100644 --- a/cli/test/test-app-created-source.mjs +++ b/cli/test/test-app-created-source.mjs @@ -9,13 +9,12 @@ console.log('๐Ÿงช Testing App Created source resolution...\n') const appAddSource = readFileSync(new URL('../src/app/add.ts', import.meta.url), 'utf8') -assert.doesNotMatch(appAddSource, /\bcheckAppExists\b/) assert.doesNotMatch(appAddSource, /\bensureAppDoesNotExist\b/) +assert.match(appAddSource, /resolveAppAddDuplicateOutcome/) +assert.match(appAddSource, /CLI Recovered App Already Exists/) assert.match(appAddSource, /method:\s*'POST'/) assert.match(appAddSource, /appId === 'io\.ionic\.starter'/) assert.match(appAddSource, /upsert:\s*false/) -assert.doesNotMatch(appAddSource, /\bdefaultAppIconPath\b/) -assert.match(appAddSource, /\.\.\.\(params\.iconUrl \? \{ icon: params\.iconUrl \} : \{\}\)/) assert.equal(isStorageObjectConflict({ statusCode: '409' }), true) assert.equal(isStorageObjectConflict({ status: 409 }), true)